memleketmeselesi/mm_api/services/auth.py
Mukan Erkin 2498e75594 init: memleketmeselesi platform — API + migrations
FastAPI + PostgreSQL 16. KYC, issue sistemi, permission/group yönetimi,
session yönetimi, API client auth (kışla kapısı), officials/persons CRUD.
Migration 0001–0013 dahil.
2026-04-27 23:06:59 +03:00

226 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import hashlib
import secrets
from datetime import datetime, timezone, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
from psycopg import AsyncConnection
from mm_api.models.auth import RegisterRequest, LoginRequest
ph = PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2)
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=30)
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()
def _gen_token() -> str:
return secrets.token_urlsafe(32)
async def register(conn: AsyncConnection, data: RegisterRequest) -> dict:
existing = await (await conn.execute(
"SELECT id FROM users WHERE email = %s", (data.email,)
)).fetchone()
if existing:
raise ValueError("Bu e-posta zaten kayıtlı")
password_hash = ph.hash(data.password)
row = await (await conn.execute(
"INSERT INTO users (email, password_hash) VALUES (%s, %s) RETURNING id, email, kyc_status, created_at",
(data.email, password_hash)
)).fetchone()
await conn.commit()
return {"id": row[0], "email": row[1], "kyc_status": row[2], "created_at": row[3]}
async def login(conn: AsyncConnection, data: LoginRequest, ip: str, user_agent: str) -> dict:
row = await (await conn.execute(
"SELECT id, password_hash, is_active FROM users WHERE email = %s", (data.email,)
)).fetchone()
if not row:
raise ValueError("E-posta veya şifre hatalı")
user_id, password_hash, is_active = row
if not is_active:
raise ValueError("Hesap askıya alınmış")
try:
ph.verify(password_hash, data.password)
except (VerifyMismatchError, VerificationError, InvalidHashError):
raise ValueError("E-posta veya şifre hatalı")
if ph.check_needs_rehash(password_hash):
new_hash = ph.hash(data.password)
await conn.execute("UPDATE users SET password_hash = %s WHERE id = %s", (new_hash, user_id))
device_id = await (await conn.execute(
"""INSERT INTO user_devices (user_id, device_name, user_agent, last_ip)
VALUES (%s, %s, %s, %s) RETURNING id""",
(user_id, data.device_name, user_agent, ip)
)).fetchone()
device_id = device_id[0]
tokens = await _issue_tokens(conn, user_id, device_id)
await conn.execute("UPDATE users SET last_login_at = NOW() WHERE id = %s", (user_id,))
await conn.commit()
return tokens
async def refresh(conn: AsyncConnection, refresh_token: str, ip: str) -> dict:
token_hash = _hash_token(refresh_token)
row = await (await conn.execute(
"SELECT id, user_id, device_id, used_at, revoked, expires_at FROM refresh_tokens WHERE token_hash = %s",
(token_hash,)
)).fetchone()
if not row:
raise ValueError("Geçersiz token")
rt_id, user_id, device_id, used_at, revoked, expires_at = row
if used_at is not None or revoked:
await conn.execute(
"UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s", (user_id,)
)
await conn.execute(
"DELETE FROM access_tokens WHERE user_id = %s", (user_id,)
)
await conn.commit()
raise ValueError("Token zaten kullanılmış, tüm oturumlar kapatıldı")
now = datetime.now(timezone.utc)
if expires_at < now:
raise ValueError("Token süresi dolmuş")
await conn.execute(
"UPDATE refresh_tokens SET used_at = NOW() WHERE id = %s", (rt_id,)
)
await conn.execute(
"DELETE FROM access_tokens WHERE user_id = %s AND device_id = %s", (user_id, device_id)
)
# IP güncelle
await conn.execute(
"UPDATE user_devices SET last_ip = %s, last_seen_at = NOW() WHERE id = %s", (ip, device_id)
)
tokens = await _issue_tokens(conn, user_id, device_id)
await conn.commit()
return tokens
async def logout(conn: AsyncConnection, access_token: str):
token_hash = _hash_token(access_token)
row = await (await conn.execute(
"SELECT user_id, device_id FROM access_tokens WHERE token_hash = %s", (token_hash,)
)).fetchone()
if row:
user_id, device_id = row
await conn.execute("DELETE FROM access_tokens WHERE token_hash = %s", (token_hash,))
await conn.execute(
"UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s AND device_id = %s AND revoked = FALSE",
(user_id, device_id)
)
await conn.commit()
async def logout_all(conn: AsyncConnection, user_id: int):
"""Kullanıcının tüm cihazlarındaki oturumları kapatır."""
await conn.execute("DELETE FROM access_tokens WHERE user_id = %s", (user_id,))
await conn.execute("UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s AND revoked = FALSE", (user_id,))
await conn.commit()
async def logout_device(conn: AsyncConnection, user_id: int, device_id: int):
"""Belirli bir cihazın oturumunu kapatır. Sadece kendi cihazını kapatabilir."""
row = await (await conn.execute(
"SELECT id FROM user_devices WHERE id = %s AND user_id = %s", (device_id, user_id)
)).fetchone()
if not row:
raise ValueError("Cihaz bulunamadı")
await conn.execute("DELETE FROM access_tokens WHERE user_id = %s AND device_id = %s", (user_id, device_id))
await conn.execute(
"UPDATE refresh_tokens SET revoked = TRUE WHERE user_id = %s AND device_id = %s AND revoked = FALSE",
(user_id, device_id)
)
await conn.commit()
async def get_current_user(conn: AsyncConnection, access_token: str) -> dict | None:
token_hash = _hash_token(access_token)
row = await (await conn.execute(
"""SELECT u.id, u.email, u.kyc_status, u.is_active, at.expires_at, at.device_id
FROM access_tokens at
JOIN users u ON u.id = at.user_id
WHERE at.token_hash = %s""",
(token_hash,)
)).fetchone()
if not row:
return None
if row[4] < datetime.now(timezone.utc):
return None
if not row[3]:
return None
# last_seen_at güncelle (fire-and-forget, hata olsa da önemli değil)
await conn.execute(
"UPDATE user_devices SET last_seen_at = NOW() WHERE id = %s", (row[5],)
)
return {"id": row[0], "email": row[1], "kyc_status": row[2]}
async def list_sessions(conn: AsyncConnection, user_id: int) -> list[dict]:
"""Kullanıcının aktif oturumlarını (cihazlarını) döner."""
rows = await (await conn.execute(
"""SELECT d.id, d.device_name, d.user_agent, d.last_ip, d.last_seen_at, d.created_at,
EXISTS(SELECT 1 FROM access_tokens at WHERE at.device_id = d.id AND at.expires_at > NOW()) AS has_active_token
FROM user_devices d
WHERE d.user_id = %s
AND EXISTS(
SELECT 1 FROM refresh_tokens rt
WHERE rt.device_id = d.id AND rt.revoked = FALSE AND rt.expires_at > NOW()
)
ORDER BY d.last_seen_at DESC""",
(user_id,)
)).fetchall()
return [
{
"device_id": r[0],
"device_name": r[1],
"user_agent": r[2],
"last_ip": str(r[3]) if r[3] else None,
"last_seen_at": r[4],
"created_at": r[5],
"is_active": r[6],
}
for r in rows
]
async def cleanup_expired_tokens(conn: AsyncConnection):
"""Süresi dolmuş token'ları temizler. Uygulama başlangıcında çağrılır."""
await conn.execute("SELECT cleanup_expired_tokens()")
await conn.commit()
async def _issue_tokens(conn: AsyncConnection, user_id: int, device_id: int) -> dict:
access_token = _gen_token()
refresh_token = _gen_token()
now = datetime.now(timezone.utc)
await conn.execute(
"INSERT INTO access_tokens (user_id, device_id, token_hash, expires_at) VALUES (%s, %s, %s, %s)",
(user_id, device_id, _hash_token(access_token), now + ACCESS_TTL)
)
await conn.execute(
"INSERT INTO refresh_tokens (user_id, device_id, token_hash, expires_at) VALUES (%s, %s, %s, %s)",
(user_id, device_id, _hash_token(refresh_token), now + REFRESH_TTL)
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": int(ACCESS_TTL.total_seconds()),
}