memleketmeselesi/mm_api/services/kyc.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

224 lines
7.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 os
import secrets
import shutil
from datetime import datetime, timezone, timedelta
from pathlib import Path
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from psycopg import AsyncConnection
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/opt/mm/uploads/kyc"))
VIEW_TOKEN_TTL = timedelta(minutes=10)
# AES-256-GCM — 32 byte key, hex encoded in env
_raw_key = bytes.fromhex(os.getenv("KYC_ENCRYPTION_KEY", "0" * 64))
_aesgcm = AESGCM(_raw_key)
# --- Şifreleme yardımcıları ---
def _encrypt(data: bytes) -> bytes:
nonce = secrets.token_bytes(12)
return nonce + _aesgcm.encrypt(nonce, data, None)
def _decrypt(data: bytes) -> bytes:
return _aesgcm.decrypt(data[:12], data[12:], None)
def _hash_tc(tc: str) -> str:
return hashlib.sha256(tc.encode()).hexdigest()
# --- Dosya işlemleri ---
def _request_dir(request_uuid: str) -> Path:
path = UPLOAD_DIR / request_uuid
path.mkdir(parents=True, exist_ok=True)
return path
def save_file_encrypted(request_uuid: str, filename: str, data: bytes) -> str:
"""Dosyayı şifreli kaydeder, göreli path döner."""
enc = _encrypt(data)
path = _request_dir(request_uuid) / (filename + ".enc")
path.write_bytes(enc)
return str(path)
def read_file_encrypted(path: str) -> bytes:
return _decrypt(Path(path).read_bytes())
def delete_request_files(request_uuid: str):
d = UPLOAD_DIR / request_uuid
if d.exists():
shutil.rmtree(d)
# --- DB işlemleri ---
async def submit(
conn: AsyncConnection,
user_id: int,
tc_kimlik: str,
front_path: str,
back_path: str,
selfie_path: str,
) -> dict:
# Aynı TC ile başka kayıtlı kullanıcı var mı?
tc_hash = _hash_tc(tc_kimlik)
existing = await (await conn.execute(
"SELECT id FROM users WHERE tc_kimlik_hash = %s AND id != %s", (tc_hash, user_id)
)).fetchone()
if existing:
raise ValueError("Bu kimlik numarası başka bir hesaba kayıtlı")
# Bekleyen başvuru var mı?
pending = await (await conn.execute(
"SELECT id FROM verification_requests WHERE user_id = %s AND status = 'pending'", (user_id,)
)).fetchone()
if pending:
raise ValueError("Bekleyen bir başvurunuz zaten mevcut")
tc_enc = _encrypt(tc_kimlik.encode())
await conn.execute(
"UPDATE users SET tc_kimlik_enc = %s, tc_kimlik_hash = %s, kyc_status = 'pending' WHERE id = %s",
(tc_enc, tc_hash, user_id)
)
row = await (await conn.execute(
"""INSERT INTO verification_requests (user_id, id_front_path, id_back_path, selfie_path)
VALUES (%s, %s, %s, %s) RETURNING id, submitted_at""",
(user_id, front_path, back_path, selfie_path)
)).fetchone()
await conn.commit()
return {"id": row[0], "status": "pending", "submitted_at": row[1]}
async def list_pending(conn: AsyncConnection) -> list[dict]:
rows = await (await conn.execute(
"""SELECT vr.id, vr.user_id, u.email, vr.submitted_at, vr.rejection_count
FROM verification_requests vr
JOIN users u ON u.id = vr.user_id
WHERE vr.status = 'pending'
ORDER BY vr.submitted_at ASC"""
)).fetchall()
return [
{"id": r[0], "user_id": r[1], "email": r[2], "submitted_at": r[3], "rejection_count": r[4]}
for r in rows
]
async def get_request(conn: AsyncConnection, request_id: int) -> dict | None:
row = await (await conn.execute(
"""SELECT vr.id, vr.user_id, u.email, vr.status, vr.submitted_at,
vr.id_front_path, vr.id_back_path, vr.selfie_path,
vr.review_note, vr.reviewed_at, vr.rejection_count
FROM verification_requests vr
JOIN users u ON u.id = vr.user_id
WHERE vr.id = %s""",
(request_id,)
)).fetchone()
if not row:
return None
return {
"id": row[0], "user_id": row[1], "email": row[2], "status": row[3],
"submitted_at": row[4], "id_front_path": row[5], "id_back_path": row[6],
"selfie_path": row[7], "review_note": row[8], "reviewed_at": row[9],
"rejection_count": row[10],
}
async def create_view_token(conn: AsyncConnection, request_id: int, admin_id: int) -> str:
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + VIEW_TOKEN_TTL
await conn.execute(
"INSERT INTO kyc_view_tokens (token, request_id, created_by, expires_at) VALUES (%s, %s, %s, %s)",
(token, request_id, admin_id, expires_at)
)
await conn.commit()
return token
async def consume_view_token(conn: AsyncConnection, token: str) -> dict | None:
"""Token'ı tüketir (tek kullanımlık), geçerliyse request bilgilerini döner."""
row = await (await conn.execute(
"""SELECT request_id, expires_at, used_at
FROM kyc_view_tokens WHERE token = %s""",
(token,)
)).fetchone()
if not row:
return None
request_id, expires_at, used_at = row
if used_at is not None:
return None
if expires_at < datetime.now(timezone.utc):
return None
await conn.execute(
"UPDATE kyc_view_tokens SET used_at = NOW() WHERE token = %s", (token,)
)
await conn.commit()
return await get_request(conn, request_id)
async def approve(conn: AsyncConnection, request_id: int, reviewed_by: int) -> dict:
req = await get_request(conn, request_id)
if not req:
raise ValueError("Başvuru bulunamadı")
if req["status"] != "pending":
raise ValueError("Bu başvuru zaten işlenmiş")
await conn.execute(
"""UPDATE verification_requests
SET status = 'approved', reviewed_by = %s, reviewed_at = NOW()
WHERE id = %s""",
(reviewed_by, request_id)
)
await conn.execute(
"UPDATE users SET kyc_status = 'verified', kyc_verified_at = NOW() WHERE id = %s",
(req["user_id"],)
)
await conn.commit()
delete_request_files(_request_uuid(req))
return {"ok": True}
async def reject(conn: AsyncConnection, request_id: int, reviewed_by: int, note: str) -> dict:
req = await get_request(conn, request_id)
if not req:
raise ValueError("Başvuru bulunamadı")
if req["status"] != "pending":
raise ValueError("Bu başvuru zaten işlenmiş")
await conn.execute(
"""UPDATE verification_requests
SET status = 'rejected', reviewed_by = %s, reviewed_at = NOW(),
review_note = %s, rejection_count = rejection_count + 1
WHERE id = %s""",
(reviewed_by, note, request_id)
)
await conn.execute(
"UPDATE users SET kyc_status = 'rejected' WHERE id = %s",
(req["user_id"],)
)
await conn.commit()
delete_request_files(_request_uuid(req))
return {"ok": True}
async def get_tc_kimlik(conn: AsyncConnection, user_id: int) -> str | None:
"""Adli soruşturma: kullanıcının TC kimlik nosunu döner."""
row = await (await conn.execute(
"SELECT tc_kimlik_enc FROM users WHERE id = %s", (user_id,)
)).fetchone()
if not row or not row[0]:
return None
return _decrypt(bytes(row[0])).decode()
def _request_uuid(req: dict) -> str:
# Dosya path'inden UUID klasör adını çıkar
return Path(req["id_front_path"]).parent.name