feat(auth): add 2FA with OTP, backup codes and trusted devices
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
114
shared/auth/backup_codes_service.py
Normal file
114
shared/auth/backup_codes_service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Backup Codes Service - fallback 2FA când emailul nu sosește."""
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BACKUP_CODE_COUNT = 10
|
||||
BACKUP_CODE_EXPIRE_DAYS = 365 # 1 an
|
||||
|
||||
# Alphabet for backup codes: uppercase letters + digits (no ambiguous chars like 0/O, 1/I/L)
|
||||
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
return hashlib.sha256(code.upper().strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def generate_backup_codes(username: str, server_id: Optional[str]) -> list[str]:
|
||||
"""Generează 10 coduri de backup, stochează hash-uri în DB, returnează codurile plain."""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
expires_at = (datetime.now() + timedelta(days=BACKUP_CODE_EXPIRE_DAYS)).isoformat()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
# Șterge codurile vechi ale userului
|
||||
await db.execute(
|
||||
"DELETE FROM backup_codes WHERE UPPER(username) = ? AND (server_id = ? OR (server_id IS NULL AND ? IS NULL))",
|
||||
(username_upper, server_id, server_id)
|
||||
)
|
||||
|
||||
# Generează 10 coduri noi (8 chars uppercase alfanumeric)
|
||||
codes: list[str] = []
|
||||
for _ in range(BACKUP_CODE_COUNT):
|
||||
code = "".join(secrets.choice(_ALPHABET) for _ in range(8))
|
||||
codes.append(code)
|
||||
code_hash = _hash_code(code)
|
||||
await db.execute(
|
||||
"""INSERT INTO backup_codes (username, server_id, code_hash, used, created_at)
|
||||
VALUES (?, ?, ?, 0, ?)""",
|
||||
(username_upper, server_id, code_hash, now)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
logger.info(f"[BACKUP_CODES] Generated {len(codes)} codes for '{username}' (server={server_id})")
|
||||
return codes
|
||||
|
||||
|
||||
async def verify_backup_code(username: str, server_id: Optional[str], code: str) -> bool:
|
||||
"""Verifică și marchează codul ca folosit. False dacă invalid/deja folosit."""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
code_hash = _hash_code(code)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"""SELECT id FROM backup_codes
|
||||
WHERE UPPER(username) = ? AND code_hash = ? AND used = 0
|
||||
AND (server_id = ? OR (server_id IS NULL AND ? IS NULL))""",
|
||||
(username_upper, code_hash, server_id, server_id)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.warning(f"[BACKUP_CODES] Invalid or used code for '{username}'")
|
||||
return False
|
||||
|
||||
# Marchează ca folosit
|
||||
await db.execute(
|
||||
"UPDATE backup_codes SET used = 1, used_at = ? WHERE id = ?",
|
||||
(datetime.now().isoformat(), row["id"])
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
logger.info(f"[BACKUP_CODES] Code used for '{username}' (server={server_id})")
|
||||
return True
|
||||
|
||||
|
||||
async def has_backup_codes(username: str, server_id: Optional[str]) -> bool:
|
||||
"""Verifică dacă userul are coduri de backup active (nefolosite)."""
|
||||
count = await get_remaining_count(username, server_id)
|
||||
return count > 0
|
||||
|
||||
|
||||
async def get_remaining_count(username: str, server_id: Optional[str]) -> int:
|
||||
"""Numără codurile nefolosite."""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"""SELECT COUNT(*) as cnt FROM backup_codes
|
||||
WHERE UPPER(username) = ? AND used = 0
|
||||
AND (server_id = ? OR (server_id IS NULL AND ? IS NULL))""",
|
||||
(username_upper, server_id, server_id)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
finally:
|
||||
await db.close()
|
||||
Reference in New Issue
Block a user