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()), }