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.
226 lines
8.2 KiB
Python
226 lines
8.2 KiB
Python
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()),
|
||
}
|