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()
|
||||
@@ -56,6 +56,10 @@ class LoginRequest(BaseModel):
|
||||
description="ID-ul serverului Oracle pentru autentificare (opțional în modul single-server)",
|
||||
example="romfast"
|
||||
)
|
||||
trusted_device_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Token de trusted device din localStorage (pentru skip 2FA)"
|
||||
)
|
||||
|
||||
@validator('username')
|
||||
def username_alphanumeric(cls, v):
|
||||
@@ -83,17 +87,25 @@ class TokenResponse(BaseModel):
|
||||
"""Model pentru răspunsul de autentificare cu token-uri"""
|
||||
access_token: str = Field(description="JWT access token")
|
||||
refresh_token: Optional[str] = Field(
|
||||
default=None,
|
||||
default=None,
|
||||
description="JWT refresh token (opțional)"
|
||||
)
|
||||
token_type: str = Field(
|
||||
default="bearer",
|
||||
default="bearer",
|
||||
description="Tipul token-ului (întotdeauna 'bearer')"
|
||||
)
|
||||
expires_in: int = Field(
|
||||
description="Timpul de expirare al access token-ului în secunde"
|
||||
)
|
||||
user: 'CurrentUser' = Field(description="Informațiile utilizatorului autentificat")
|
||||
trusted_device_token: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Token de stocat în localStorage (prezent doar dacă trust_device=True)"
|
||||
)
|
||||
backup_codes: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="Coduri de backup generate la primul 2FA reușit (afișați utilizatorului o singură dată!)"
|
||||
)
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
@@ -340,5 +352,70 @@ class CheckEmailResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class VerifyBackupCodeRequest(BaseModel):
|
||||
"""Request pentru POST /auth/verify-backup-code"""
|
||||
code: str = Field(..., min_length=6, max_length=12, description="Codul de recuperare (ex: AB3K9PQR)")
|
||||
email: str = Field(..., description="Email sau username")
|
||||
server_id: Optional[str] = Field(default=None, description="ID server Oracle")
|
||||
trust_device: bool = Field(default=False, description="Ține minte dispozitivul 30 de zile")
|
||||
|
||||
|
||||
# Update la forward references pentru TokenResponse
|
||||
TokenResponse.model_rebuild()
|
||||
TokenResponse.model_rebuild()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODELE 2FA WEB LOGIN
|
||||
# ============================================================================
|
||||
|
||||
class LoginRequires2FAResponse(BaseModel):
|
||||
"""
|
||||
Răspuns returnat de POST /auth/login când 2FA este necesar.
|
||||
|
||||
Frontend-ul detectează câmpul requires_2fa=True și afișează pasul de cod.
|
||||
Email-ul complet se trimite la /auth/verify-2fa-code.
|
||||
"""
|
||||
requires_2fa: bool = Field(
|
||||
default=True,
|
||||
description="Întotdeauna True când se solicită 2FA"
|
||||
)
|
||||
masked_email: str = Field(
|
||||
description="Emailul mascat pentru afișare (ex: m***@romfast.ro)"
|
||||
)
|
||||
email: str = Field(
|
||||
description="Emailul complet — de trimis la /auth/verify-2fa-code"
|
||||
)
|
||||
|
||||
|
||||
class Verify2FARequest(BaseModel):
|
||||
"""Request pentru POST /auth/verify-2fa-code"""
|
||||
code: str = Field(
|
||||
...,
|
||||
min_length=6,
|
||||
max_length=6,
|
||||
description="Codul OTP de 6 cifre primit pe email"
|
||||
)
|
||||
email: str = Field(
|
||||
...,
|
||||
description="Emailul primit în răspunsul de la /auth/login (câmpul 'email')"
|
||||
)
|
||||
server_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID-ul serverului Oracle (pentru multi-server mode)"
|
||||
)
|
||||
trust_device: bool = Field(
|
||||
default=False,
|
||||
description="Dacă utilizatorul vrea să fie ținut minte pe acest dispozitiv"
|
||||
)
|
||||
|
||||
|
||||
class Resend2FARequest(BaseModel):
|
||||
"""Request pentru POST /auth/resend-2fa-code"""
|
||||
email: str = Field(
|
||||
...,
|
||||
description="Emailul unde se retrimite codul OTP"
|
||||
)
|
||||
server_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID-ul serverului Oracle (pentru multi-server mode)"
|
||||
)
|
||||
281
shared/auth/otp_service.py
Normal file
281
shared/auth/otp_service.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
OTP (One-Time Password) Service pentru Web 2FA
|
||||
|
||||
Stochează OTP-urile în memorie (dict singleton).
|
||||
Nu e nevoie de SQLite — OTP-urile expiră în 5 minute și backend-ul
|
||||
rulează cu --workers 1 (single-worker garantat).
|
||||
|
||||
Utilizare:
|
||||
code = await create_otp(email, username, server_id)
|
||||
result = verify_otp(email, code)
|
||||
# result: {"success": True, "username": ..., "server_id": ...}
|
||||
# result: {"success": False, "error": ..., "error_code": ...}
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from backend.modules.telegram.auth.email_auth import generate_email_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTE
|
||||
# ============================================================================
|
||||
|
||||
OTP_EXPIRY_MINUTES = 5 # Codul expiră după 5 minute
|
||||
OTP_MAX_ATTEMPTS = 5 # Max încercări greșite per cod
|
||||
OTP_MAX_SENDS_PER_WINDOW = 3 # Max coduri trimise în fereastra de rate limit
|
||||
OTP_RATE_WINDOW_MINUTES = 10 # Fereastra de rate limiting (minute)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# IN-MEMORY STORE
|
||||
# ============================================================================
|
||||
|
||||
# Structura entry:
|
||||
# {
|
||||
# "email@domeniu.ro": {
|
||||
# "code": "483921",
|
||||
# "username": "MARIUS M",
|
||||
# "server_id": "romfast" | None,
|
||||
# "expires_at": datetime,
|
||||
# "attempts": 0,
|
||||
# "send_count": 1,
|
||||
# "created_at": datetime, # pentru rate limiting (fereastra de 10 min)
|
||||
# }
|
||||
# }
|
||||
_otp_store: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FUNCȚII UTILITARE
|
||||
# ============================================================================
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
"""
|
||||
Maschează emailul pentru afișare sigură.
|
||||
|
||||
Exemple:
|
||||
"marius@romfast.ro" → "m***@romfast.ro"
|
||||
"ab@domeniu.ro" → "a***@domeniu.ro"
|
||||
"x@y.com" → "x***@y.com"
|
||||
"@invalid" → "***"
|
||||
"""
|
||||
if "@" not in email:
|
||||
return "***"
|
||||
local, domain = email.split("@", 1)
|
||||
masked_local = (local[0] + "***") if local else "***"
|
||||
return f"{masked_local}@{domain}"
|
||||
|
||||
|
||||
def _is_rate_limited(email: str) -> bool:
|
||||
"""
|
||||
Verifică dacă emailul a depășit limita de trimiteri OTP.
|
||||
|
||||
Limita: OTP_MAX_SENDS_PER_WINDOW trimiteri în OTP_RATE_WINDOW_MINUTES minute.
|
||||
Returnează True dacă este rate limited (nu mai poate trimite cod).
|
||||
"""
|
||||
entry = _otp_store.get(email)
|
||||
if not entry:
|
||||
return False
|
||||
|
||||
# Dacă entry-ul a expirat, îl ștergem și permitem un nou OTP
|
||||
if datetime.now() > entry["expires_at"]:
|
||||
del _otp_store[email]
|
||||
return False
|
||||
|
||||
# Verificăm fereastra de rate limit (separată de expiry-ul codului)
|
||||
window_end = entry["created_at"] + timedelta(minutes=OTP_RATE_WINDOW_MINUTES)
|
||||
if datetime.now() > window_end:
|
||||
# Fereastra de rate limit a expirat — permitem trimitere nouă
|
||||
return False
|
||||
|
||||
return entry.get("send_count", 0) >= OTP_MAX_SENDS_PER_WINDOW
|
||||
|
||||
|
||||
def _cleanup_expired() -> None:
|
||||
"""Curăță OTP-urile expirate din store. Apelat automat la create_otp."""
|
||||
now = datetime.now()
|
||||
expired = [email for email, entry in _otp_store.items() if now > entry["expires_at"]]
|
||||
for email in expired:
|
||||
del _otp_store[email]
|
||||
logger.debug(f"[OTP] Cleaned up expired OTP for {email}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PUBLICĂ
|
||||
# ============================================================================
|
||||
|
||||
async def create_otp(
|
||||
email: str,
|
||||
username: str,
|
||||
server_id: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generează și stochează un OTP nou pentru email.
|
||||
|
||||
Args:
|
||||
email: Adresa de email a utilizatorului (lowercase)
|
||||
username: Username Oracle (uppercase) — salvat pentru JWT la verificare
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Codul generat (str, 6 cifre) sau None dacă este rate limited
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
|
||||
# Curățăm OTP-urile expirate periodic
|
||||
_cleanup_expired()
|
||||
|
||||
# Verificăm rate limiting
|
||||
if _is_rate_limited(email):
|
||||
logger.warning(f"[OTP] Rate limit exceeded for {email[:3]}*** (max {OTP_MAX_SENDS_PER_WINDOW} sends/{OTP_RATE_WINDOW_MINUTES}min)")
|
||||
return None
|
||||
|
||||
# Generăm cod crypto-secure (6 cifre)
|
||||
code = generate_email_code()
|
||||
|
||||
now = datetime.now()
|
||||
existing = _otp_store.get(email)
|
||||
|
||||
# Păstrăm created_at și send_count din entry-ul anterior (pentru rate limiting corect)
|
||||
if existing:
|
||||
send_count = existing.get("send_count", 0) + 1
|
||||
created_at = existing.get("created_at", now)
|
||||
else:
|
||||
send_count = 1
|
||||
created_at = now
|
||||
|
||||
_otp_store[email] = {
|
||||
"code": code,
|
||||
"username": username,
|
||||
"server_id": server_id,
|
||||
"expires_at": now + timedelta(minutes=OTP_EXPIRY_MINUTES),
|
||||
"attempts": 0,
|
||||
"send_count": send_count,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
logger.info(f"[OTP] Created OTP for {email[:3]}*** (username={username}, server={server_id}, send_count={send_count})")
|
||||
return code
|
||||
|
||||
|
||||
def verify_otp(email: str, code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verifică OTP-ul introdus de utilizator.
|
||||
|
||||
Args:
|
||||
email: Adresa de email
|
||||
code: Codul de 6 cifre introdus de utilizator
|
||||
|
||||
Returns:
|
||||
Succes: {"success": True, "username": "MARIUS M", "server_id": "romfast"}
|
||||
Eroare: {"success": False, "error": "...", "error_code": "..."}
|
||||
|
||||
Error codes:
|
||||
OTP_NOT_FOUND — nu există OTP activ pentru email
|
||||
OTP_EXPIRED — OTP-ul a expirat (5 minute)
|
||||
OTP_MAX_ATTEMPTS — prea multe încercări greșite (5)
|
||||
OTP_INVALID — codul este greșit
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
entry = _otp_store.get(email)
|
||||
|
||||
# OTP inexistent
|
||||
if not entry:
|
||||
logger.warning(f"[OTP] No active OTP for {email[:3]}***")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cod invalid sau expirat. Solicitați un cod nou.",
|
||||
"error_code": "OTP_NOT_FOUND",
|
||||
}
|
||||
|
||||
# OTP expirat
|
||||
if datetime.now() > entry["expires_at"]:
|
||||
delete_otp(email)
|
||||
logger.info(f"[OTP] Expired OTP attempt for {email[:3]}***")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Codul a expirat. Solicitați un cod nou.",
|
||||
"error_code": "OTP_EXPIRED",
|
||||
}
|
||||
|
||||
# Prea multe încercări greșite
|
||||
if entry["attempts"] >= OTP_MAX_ATTEMPTS:
|
||||
delete_otp(email)
|
||||
logger.warning(f"[OTP] Max attempts reached for {email[:3]}***")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Prea multe încercări greșite. Solicitați un cod nou.",
|
||||
"error_code": "OTP_MAX_ATTEMPTS",
|
||||
}
|
||||
|
||||
# Incrementăm contorul de încercări ÎNAINTE de verificare
|
||||
entry["attempts"] += 1
|
||||
|
||||
# Cod greșit
|
||||
if entry["code"] != code.strip():
|
||||
remaining = OTP_MAX_ATTEMPTS - entry["attempts"]
|
||||
logger.warning(f"[OTP] Invalid code for {email[:3]}*** (attempt {entry['attempts']}, {remaining} remaining)")
|
||||
|
||||
# La ultima încercare permisă, blocăm imediat și ștergem OTP-ul
|
||||
if remaining <= 0:
|
||||
delete_otp(email)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Prea multe încercări greșite. Solicitați un cod nou.",
|
||||
"error_code": "OTP_MAX_ATTEMPTS",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cod incorect. Mai aveți {remaining} {'încercare' if remaining == 1 else 'încercări'}.",
|
||||
"error_code": "OTP_INVALID",
|
||||
}
|
||||
|
||||
# Cod corect — ștergem din store și returnăm datele salvate
|
||||
username = entry["username"]
|
||||
server_id = entry["server_id"]
|
||||
delete_otp(email)
|
||||
|
||||
logger.info(f"[OTP] OTP verified successfully for {email[:3]}*** (username={username})")
|
||||
return {
|
||||
"success": True,
|
||||
"username": username,
|
||||
"server_id": server_id,
|
||||
}
|
||||
|
||||
|
||||
def delete_otp(email: str) -> None:
|
||||
"""
|
||||
Șterge OTP-ul pentru email din store.
|
||||
Apelat după verificare reușită sau când utilizatorul renunță.
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
if email in _otp_store:
|
||||
del _otp_store[email]
|
||||
logger.debug(f"[OTP] Deleted OTP for {email[:3]}***")
|
||||
|
||||
|
||||
def get_otp_entry(email: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Returnează entry-ul OTP pentru email (read-only).
|
||||
Folosit de endpoint-ul /resend-2fa-code pentru a verifica că sesiunea există.
|
||||
|
||||
Returns:
|
||||
Dict cu datele OTP sau None dacă nu există / a expirat
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
entry = _otp_store.get(email)
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
# Verificăm că nu e expirat
|
||||
if datetime.now() > entry["expires_at"]:
|
||||
delete_otp(email)
|
||||
return None
|
||||
|
||||
return entry
|
||||
@@ -14,7 +14,7 @@ Endpoints disponibile:
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||
@@ -24,7 +24,9 @@ from .models import (
|
||||
LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest,
|
||||
CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse,
|
||||
AuthError, AuthStats, CheckEmailRequest, CheckEmailResponse, ServerInfo,
|
||||
CheckIdentityRequest, CheckIdentityResponse
|
||||
CheckIdentityRequest, CheckIdentityResponse,
|
||||
LoginRequires2FAResponse, Verify2FARequest, Resend2FARequest,
|
||||
VerifyBackupCodeRequest,
|
||||
)
|
||||
from .auth_service import auth_service, AuthenticationError
|
||||
from .jwt_handler import jwt_handler
|
||||
@@ -33,6 +35,16 @@ from .dependencies import (
|
||||
security_required, security_optional
|
||||
)
|
||||
from .middleware import default_rate_limiter, RateLimiter
|
||||
from .otp_service import (
|
||||
create_otp, verify_otp, get_otp_entry, _mask_email
|
||||
)
|
||||
from .trusted_device_service import (
|
||||
create_trusted_device_token, verify_trusted_device_token
|
||||
)
|
||||
from .backup_codes_service import (
|
||||
generate_backup_codes, verify_backup_code,
|
||||
has_backup_codes, get_remaining_count
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,6 +70,80 @@ def create_auth_router(
|
||||
# Rate limiter pentru check-identity/check-email: 5 requests per minut per IP
|
||||
check_identity_rate_limiter = RateLimiter(max_requests=5, time_window=60)
|
||||
|
||||
# Rate limitere pentru 2FA
|
||||
verify_2fa_rate_limiter = RateLimiter(max_requests=10, time_window=300) # 10 req / 5 min per IP
|
||||
resend_2fa_rate_limiter = RateLimiter(max_requests=3, time_window=600) # 3 req / 10 min per IP
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HELPER FUNCTIONS (private, în scope-ul create_auth_router)
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def _get_email_for_username(username: str, server_id: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
Caută emailul unui utilizator în Oracle după username.
|
||||
Returnează emailul lowercase sau None dacă nu există / nu e setat.
|
||||
"""
|
||||
try:
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
cursor.execute("""
|
||||
SELECT LOWER(TRIM(EMAIL))
|
||||
FROM CONTAFIN_ORACLE.UTILIZATORI
|
||||
WHERE UPPER(UTILIZATOR) = :username
|
||||
AND INACTIV = 0
|
||||
AND STERS = 0
|
||||
AND EMAIL IS NOT NULL
|
||||
AND TRIM(EMAIL) IS NOT NULL
|
||||
""", {"username": username.upper()})
|
||||
row = cursor.fetchone()
|
||||
if row and row[0] and "@" in row[0]:
|
||||
return row[0].strip()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"[2FA] Error getting email for username '{username}': {e}")
|
||||
return None
|
||||
|
||||
async def _create_token_response_for_user(
|
||||
username: str,
|
||||
server_id: Optional[str],
|
||||
response: Response
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Creează TokenResponse complet pentru un utilizator deja verificat.
|
||||
Folosit după verificarea OTP (pasul 2 al 2FA) — fără re-verificare parolă.
|
||||
"""
|
||||
companies = await auth_service.get_user_companies(username, server_id)
|
||||
permissions = await auth_service.get_user_permissions(
|
||||
username, companies[0] if companies else "", server_id
|
||||
)
|
||||
|
||||
jwt_tokens = jwt_handler.create_token_response(
|
||||
username=username,
|
||||
companies=companies,
|
||||
user_id=None,
|
||||
permissions=permissions,
|
||||
server_id=server_id,
|
||||
)
|
||||
|
||||
current_user = CurrentUser(
|
||||
username=username,
|
||||
user_id=None,
|
||||
companies=companies,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
|
||||
return TokenResponse(
|
||||
access_token=jwt_tokens.access_token,
|
||||
refresh_token=jwt_tokens.refresh_token,
|
||||
token_type=jwt_tokens.token_type,
|
||||
expires_in=jwt_tokens.expires_in,
|
||||
user=current_user,
|
||||
)
|
||||
|
||||
@router.post("/check-identity", response_model=CheckIdentityResponse, status_code=status.HTTP_200_OK)
|
||||
async def check_identity(
|
||||
check_data: CheckIdentityRequest,
|
||||
@@ -223,103 +309,346 @@ def create_auth_router(
|
||||
detail="Error checking email"
|
||||
)
|
||||
|
||||
@router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
@router.post("/login", status_code=status.HTTP_200_OK)
|
||||
async def login(
|
||||
login_data: LoginRequest,
|
||||
request: Request,
|
||||
response: Response
|
||||
) -> TokenResponse:
|
||||
response: Response,
|
||||
):
|
||||
"""
|
||||
Autentifică un utilizator și returnează token-urile JWT
|
||||
Autentifică un utilizator.
|
||||
|
||||
Acest endpoint:
|
||||
- Validează credențialele utilizatorului în Oracle
|
||||
- Obține firmele la care utilizatorul are acces
|
||||
- Generează access și refresh token-uri JWT
|
||||
- Aplică rate limiting pentru securitate
|
||||
- Suportă modul multi-server (server_id opțional)
|
||||
Flow cu 2FA (utilizator are email în Oracle):
|
||||
1. Verifică credențialele în Oracle
|
||||
2. Trimite cod OTP pe email
|
||||
3. Returnează {requires_2fa: true, masked_email, email}
|
||||
→ Frontend afișează câmpul de cod
|
||||
→ Userul introduce codul → POST /auth/verify-2fa-code → JWT
|
||||
|
||||
Args:
|
||||
login_data: Datele de autentificare (username, password, server_id opțional)
|
||||
request: Request-ul HTTP (pentru rate limiting)
|
||||
response: Response-ul HTTP (pentru header-e)
|
||||
|
||||
Returns:
|
||||
Token-urile JWT și informațiile utilizatorului
|
||||
Fallback fără 2FA (utilizator fără email):
|
||||
- Returnează TokenResponse direct (comportament anterior)
|
||||
|
||||
Raises:
|
||||
HTTPException 400: Pentru server_id invalid
|
||||
HTTPException 401: Pentru credențiale invalide
|
||||
HTTPException 500: Pentru erori de sistem
|
||||
HTTPException 400: server_id invalid
|
||||
HTTPException 401: credențiale invalide
|
||||
HTTPException 429: rate limit OTP depășit
|
||||
HTTPException 503: email service indisponibil
|
||||
HTTPException 500: eroare internă
|
||||
"""
|
||||
try:
|
||||
# Log tentativa de autentificare
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
server_info = f" on server {login_data.server_id}" if login_data.server_id else ""
|
||||
logger.info(f"Login attempt for user {login_data.username}{server_info} from IP {client_ip}")
|
||||
logger.info(f"[LOGIN] Attempt for '{login_data.username}'{server_info} from IP {client_ip}")
|
||||
|
||||
# Validare server_id dacă specificat (multi-server mode)
|
||||
# Validare server_id (cod existent, păstrat intact)
|
||||
if login_data.server_id:
|
||||
from backend.config import settings
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
# Verifică dacă serverul există în configurație
|
||||
server_config = settings.get_oracle_server(login_data.server_id)
|
||||
if not server_config:
|
||||
logger.warning(f"Invalid server_id '{login_data.server_id}' in login request")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid server_id: '{login_data.server_id}'. Server not found in configuration."
|
||||
)
|
||||
|
||||
# Verifică dacă serverul este înregistrat în pool
|
||||
if not oracle_pool.is_server_registered(login_data.server_id):
|
||||
logger.warning(f"Server '{login_data.server_id}' not registered in pool")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Server '{login_data.server_id}' is not available."
|
||||
)
|
||||
|
||||
# Autentifică și creează token-urile
|
||||
success, token_response, error_message = await auth_service.authenticate_and_create_tokens(
|
||||
login_data.username,
|
||||
login_data.password,
|
||||
login_data.server_id
|
||||
# Pas 1: Rezolvă email → username dacă input conține '@'
|
||||
actual_username = login_data.username
|
||||
input_email: Optional[str] = None
|
||||
|
||||
if "@" in login_data.username:
|
||||
input_email = login_data.username.lower().strip()
|
||||
resolved = await auth_service.get_username_by_email(input_email, login_data.server_id)
|
||||
if not resolved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid username or password"
|
||||
)
|
||||
actual_username = resolved
|
||||
logger.info(f"[LOGIN] Email '{input_email}' resolved to username '{actual_username}'")
|
||||
|
||||
# Pas 2: Verifică credențialele Oracle
|
||||
is_valid = await auth_service.verify_user_credentials(
|
||||
actual_username, login_data.password, login_data.server_id
|
||||
)
|
||||
|
||||
if not success:
|
||||
logger.warning(f"Failed login attempt for user {login_data.username}{server_info}: {error_message}")
|
||||
if not is_valid:
|
||||
logger.warning(f"[LOGIN] Failed credentials for '{actual_username}'{server_info}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=error_message or "Authentication failed"
|
||||
detail="Invalid username or password"
|
||||
)
|
||||
|
||||
# token_response.user este deja populat corect de auth_service.authenticate_and_create_tokens
|
||||
# cu username-ul Oracle rezolvat (nu email-ul) și lista de firme
|
||||
# Pas 3: Caută emailul utilizatorului (dacă nu îl știm deja din input)
|
||||
user_email = input_email
|
||||
if not user_email:
|
||||
user_email = await _get_email_for_username(actual_username, login_data.server_id)
|
||||
|
||||
# Header-e de securitate
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
# Pas 2.5: Verificare trusted device — skip 2FA dacă tokenul e valid
|
||||
if login_data.trusted_device_token and user_email:
|
||||
is_trusted = await verify_trusted_device_token(
|
||||
login_data.trusted_device_token,
|
||||
actual_username,
|
||||
login_data.server_id,
|
||||
)
|
||||
if is_trusted:
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Device known for '{actual_username}' — skip 2FA"
|
||||
)
|
||||
return await _create_token_response_for_user(
|
||||
actual_username, login_data.server_id, response
|
||||
)
|
||||
# Invalid/expirat → fail silently, continuă cu 2FA normal
|
||||
|
||||
# Pas 4: Dacă are email → trimitem OTP (2FA)
|
||||
if user_email:
|
||||
code = await create_otp(user_email, actual_username, login_data.server_id)
|
||||
|
||||
if code is None:
|
||||
# Rate limited
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Prea multe cereri de cod. Așteptați 10 minute și încercați din nou."
|
||||
)
|
||||
|
||||
# Trimitem emailul
|
||||
try:
|
||||
from backend.modules.telegram.utils.email_service import get_email_service
|
||||
email_service = get_email_service()
|
||||
email_sent = await email_service.send_auth_code(user_email, code, actual_username)
|
||||
|
||||
if not email_sent:
|
||||
logger.error(f"[2FA] Failed to send OTP email to {user_email[:3]}***")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Nu s-a putut trimite codul de verificare. Încercați din nou."
|
||||
)
|
||||
|
||||
logger.info(f"[2FA] OTP sent to {user_email[:3]}*** for user '{actual_username}'")
|
||||
|
||||
except ImportError:
|
||||
# Email service nu e disponibil — fallback la login direct
|
||||
logger.warning("[2FA] Email service not available, falling back to direct login")
|
||||
user_email = None
|
||||
|
||||
# Pas 5: Dacă 2FA activ → returnăm cerere de cod
|
||||
if user_email:
|
||||
return LoginRequires2FAResponse(
|
||||
requires_2fa=True,
|
||||
masked_email=_mask_email(user_email),
|
||||
email=user_email,
|
||||
)
|
||||
|
||||
# Pas 6: Fallback — fără email → JWT direct (comportament anterior)
|
||||
logger.info(f"[LOGIN] No email for '{actual_username}', issuing JWT directly (no 2FA)")
|
||||
return await _create_token_response_for_user(actual_username, login_data.server_id, response)
|
||||
|
||||
logger.info(f"Successful login for user {login_data.username}{server_info}")
|
||||
return token_response
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions as-is (e.g., 401 for invalid credentials)
|
||||
raise
|
||||
except AuthenticationError as e:
|
||||
logger.error(f"Authentication error for user {login_data.username}: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=str(e)
|
||||
)
|
||||
logger.error(f"[LOGIN] Authentication error for '{login_data.username}': {e}")
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during login for user {login_data.username}: {str(e)}")
|
||||
logger.error(f"[LOGIN] Unexpected error for '{login_data.username}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal authentication error"
|
||||
)
|
||||
|
||||
@router.post("/verify-2fa-code", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def verify_2fa_code(
|
||||
verify_data: Verify2FARequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Verifică codul OTP și emite JWT tokens (pasul 2 al 2FA).
|
||||
|
||||
Args:
|
||||
verify_data: {code: "483921", email: "marius@romfast.ro", server_id: "romfast"}
|
||||
|
||||
Returns:
|
||||
TokenResponse cu JWT tokens
|
||||
|
||||
Raises:
|
||||
HTTPException 400: cod invalid, expirat sau prea multe încercări
|
||||
HTTPException 429: rate limit depășit (IP)
|
||||
HTTPException 500: eroare internă
|
||||
"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Rate limiting per IP
|
||||
if not verify_2fa_rate_limiter.is_allowed(client_ip):
|
||||
reset_time = verify_2fa_rate_limiter.get_reset_time(client_ip)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Prea multe cereri. Încercați din nou mai târziu.",
|
||||
headers={
|
||||
"X-RateLimit-Limit": "10",
|
||||
"X-RateLimit-Remaining": "0",
|
||||
"X-RateLimit-Reset": str(reset_time),
|
||||
},
|
||||
)
|
||||
|
||||
result = verify_otp(verify_data.email, verify_data.code)
|
||||
|
||||
if not result["success"]:
|
||||
error_code = result.get("error_code", "OTP_ERROR")
|
||||
http_status = (
|
||||
status.HTTP_429_TOO_MANY_REQUESTS
|
||||
if error_code == "OTP_MAX_ATTEMPTS"
|
||||
else status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
raise HTTPException(status_code=http_status, detail=result["error"])
|
||||
|
||||
# OTP valid — creăm JWT
|
||||
username = result["username"]
|
||||
server_id = result.get("server_id") or verify_data.server_id
|
||||
|
||||
logger.info(f"[2FA] OTP verified OK for '{username}' from IP {client_ip}")
|
||||
|
||||
token_response = await _create_token_response_for_user(username, server_id, response)
|
||||
|
||||
# Dacă utilizatorul a bifat "Ține minte acest dispozitiv"
|
||||
if verify_data.trust_device:
|
||||
trusted_token = await create_trusted_device_token(username, server_id)
|
||||
token_response.trusted_device_token = trusted_token
|
||||
logger.info(f"[TRUSTED_DEVICE] Token generated for '{username}' (server={server_id})")
|
||||
|
||||
# Generăm backup codes dacă nu există deja
|
||||
if not await has_backup_codes(username, server_id):
|
||||
codes = await generate_backup_codes(username, server_id)
|
||||
token_response.backup_codes = codes
|
||||
logger.info(f"[BACKUP_CODES] Generated {len(codes)} backup codes for '{username}'")
|
||||
|
||||
return token_response
|
||||
|
||||
@router.post("/resend-2fa-code", status_code=status.HTTP_200_OK)
|
||||
async def resend_2fa_code(
|
||||
resend_data: Resend2FARequest,
|
||||
request: Request,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Retrimite codul OTP pe email (butonul "Retrimite codul" din frontend).
|
||||
|
||||
Verifică că există o sesiune OTP activă pentru email înainte de a retrimite.
|
||||
|
||||
Returns:
|
||||
{"message": "Codul a fost retrimis", "masked_email": "m***@romfast.ro"}
|
||||
|
||||
Raises:
|
||||
HTTPException 404: sesiunea OTP nu mai există (expirată sau deja verificată)
|
||||
HTTPException 429: rate limit depășit
|
||||
HTTPException 503: email service indisponibil
|
||||
"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
|
||||
# Rate limiting per IP
|
||||
if not resend_2fa_rate_limiter.is_allowed(client_ip):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Prea multe cereri de retrimis. Așteptați și încercați din nou.",
|
||||
)
|
||||
|
||||
email = resend_data.email.lower().strip()
|
||||
|
||||
# Verificăm că există sesiune OTP activă pentru email
|
||||
entry = get_otp_entry(email)
|
||||
if not entry:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Sesiunea de autentificare a expirat. Reîncepeti procesul de login.",
|
||||
)
|
||||
|
||||
username = entry["username"]
|
||||
server_id = entry.get("server_id") or resend_data.server_id
|
||||
|
||||
# Creăm cod nou (cu rate limiting)
|
||||
code = await create_otp(email, username, server_id)
|
||||
if code is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="Prea multe cereri de cod. Așteptați 10 minute și încercați din nou.",
|
||||
)
|
||||
|
||||
# Trimitem emailul
|
||||
try:
|
||||
from backend.modules.telegram.utils.email_service import get_email_service
|
||||
email_service = get_email_service()
|
||||
sent = await email_service.send_auth_code(email, code, username)
|
||||
|
||||
if not sent:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Nu s-a putut retrimite codul. Încercați din nou.",
|
||||
)
|
||||
|
||||
logger.info(f"[2FA] OTP resent to {email[:3]}*** for user '{username}'")
|
||||
return {
|
||||
"message": "Codul a fost retrimis",
|
||||
"masked_email": _mask_email(email),
|
||||
}
|
||||
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Serviciul de email nu este disponibil.",
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# BACKUP CODES ENDPOINT
|
||||
# -------------------------------------------------------------------------
|
||||
backup_code_rate_limiter = RateLimiter(max_requests=5, time_window=300) # 5 req / 5 min
|
||||
|
||||
@router.post("/verify-backup-code", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def verify_backup_code_endpoint(
|
||||
verify_data: VerifyBackupCodeRequest,
|
||||
request: Request,
|
||||
response: Response,
|
||||
) -> TokenResponse:
|
||||
"""
|
||||
Verifică un cod de recuperare (backup code) și emite JWT tokens.
|
||||
|
||||
Fallback pentru cazul când emailul OTP nu sosește.
|
||||
|
||||
Raises:
|
||||
HTTPException 400: cod invalid sau deja folosit
|
||||
HTTPException 429: rate limit depășit
|
||||
"""
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
if not backup_code_rate_limiter.is_allowed(client_ip):
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Prea multe cereri. Încercați din nou mai târziu."
|
||||
)
|
||||
|
||||
email = verify_data.email.lower().strip()
|
||||
# Rezolvă username din email (dacă e email) sau e direct username
|
||||
if "@" in email:
|
||||
actual_username = await auth_service.get_username_by_email(email, verify_data.server_id)
|
||||
if not actual_username:
|
||||
raise HTTPException(status_code=400, detail="Email invalid")
|
||||
else:
|
||||
actual_username = email.upper()
|
||||
|
||||
is_valid = await verify_backup_code(actual_username, verify_data.server_id, verify_data.code)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail="Cod de recuperare invalid sau deja folosit")
|
||||
|
||||
logger.info(f"[BACKUP_CODE] Used backup code for '{actual_username}' from {client_ip}")
|
||||
token_response = await _create_token_response_for_user(actual_username, verify_data.server_id, response)
|
||||
|
||||
if verify_data.trust_device:
|
||||
trusted_token = await create_trusted_device_token(actual_username, verify_data.server_id)
|
||||
token_response.trusted_device_token = trusted_token
|
||||
logger.info(f"[TRUSTED_DEVICE] Token generated via backup code for '{actual_username}'")
|
||||
|
||||
return token_response
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||
async def refresh_token(refresh_data: RefreshTokenRequest) -> TokenResponse:
|
||||
"""
|
||||
|
||||
195
shared/auth/trusted_device_service.py
Normal file
195
shared/auth/trusted_device_service.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Trusted Device Service pentru Web 2FA — async SQLite version
|
||||
|
||||
Permite utilizatorilor să „țină minte" un dispozitiv timp de 30 de zile.
|
||||
Dacă tokenul de dispozitiv de încredere este valid, pasul OTP este sărit.
|
||||
|
||||
Securitate critică:
|
||||
- Tokenul brut (64 hex chars) este returnat clientului și stocat în localStorage
|
||||
- Pe server se stochează DOAR sha256(token), niciodată tokenul brut
|
||||
- Skip-uiește DOAR OTP-ul, nu verificarea Oracle (user+parolă rămân obligatorii)
|
||||
- Expiră automat după TRUSTED_DEVICE_EXPIRE_DAYS zile
|
||||
|
||||
Storage: SQLite (shared/database/app_db.py) — tabelul trusted_devices.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTE
|
||||
# ============================================================================
|
||||
|
||||
TRUSTED_DEVICE_EXPIRE_DAYS = int(os.environ.get("TRUSTED_DEVICE_EXPIRE_DAYS", "30"))
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""Calculează sha256 al tokenului brut."""
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PUBLICĂ (async)
|
||||
# ============================================================================
|
||||
|
||||
async def create_trusted_device_token(username: str, server_id: Optional[str]) -> str:
|
||||
"""
|
||||
Generează un token de dispozitiv de încredere și îl stochează (ca hash).
|
||||
|
||||
Args:
|
||||
username: Username Oracle (uppercase)
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Tokenul brut (64 hex chars) — de returnat clientului pentru localStorage
|
||||
"""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
raw_token = secrets.token_hex(64)
|
||||
token_hash = _hash_token(raw_token)
|
||||
|
||||
now = datetime.now()
|
||||
expires_at = now + timedelta(days=TRUSTED_DEVICE_EXPIRE_DAYS)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
# Cleanup expired tokens for this user
|
||||
await db.execute(
|
||||
"DELETE FROM trusted_devices WHERE expires_at < ?",
|
||||
(now.isoformat(),)
|
||||
)
|
||||
|
||||
# Insert new token
|
||||
await db.execute(
|
||||
"""INSERT INTO trusted_devices (token_hash, username, server_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(token_hash, username.upper(), server_id, expires_at.isoformat(), now.isoformat())
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Token created for '{username}' "
|
||||
f"(server={server_id}, expires in {TRUSTED_DEVICE_EXPIRE_DAYS}d)"
|
||||
)
|
||||
return raw_token
|
||||
|
||||
|
||||
async def verify_trusted_device_token(
|
||||
raw_token: str,
|
||||
username: str,
|
||||
server_id: Optional[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Verifică dacă tokenul de dispozitiv de încredere este valid.
|
||||
|
||||
Verifică: hash există, username corespunde, nu a expirat.
|
||||
Fail silently — returnează False fără a ridica excepție.
|
||||
|
||||
Args:
|
||||
raw_token: Tokenul brut din localStorage
|
||||
username: Username Oracle (uppercase)
|
||||
server_id: ID-ul serverului Oracle
|
||||
|
||||
Returns:
|
||||
True dacă dispozitivul este de încredere și autentificarea poate sări OTP-ul
|
||||
"""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
try:
|
||||
token_hash = _hash_token(raw_token)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT username, server_id, expires_at FROM trusted_devices WHERE token_hash = ?",
|
||||
(token_hash,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"[TRUSTED_DEVICE] Token not found for '{username}'")
|
||||
return False
|
||||
|
||||
stored_username = row["username"]
|
||||
stored_server = row["server_id"]
|
||||
expires_at_str = row["expires_at"]
|
||||
|
||||
# Verifică username (case-insensitive)
|
||||
if stored_username.upper() != username.upper():
|
||||
logger.warning(
|
||||
f"[TRUSTED_DEVICE] Username mismatch: "
|
||||
f"expected '{username}', got '{stored_username}'"
|
||||
)
|
||||
return False
|
||||
|
||||
# Cross-server trust — log but allow
|
||||
if stored_server != server_id:
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Cross-server trust for '{username}': "
|
||||
f"token from '{stored_server}', logging into '{server_id}' — allowing"
|
||||
)
|
||||
|
||||
# Verifică expirarea
|
||||
if expires_at_str < datetime.now().isoformat():
|
||||
logger.info(f"[TRUSTED_DEVICE] Token expired for '{username}'")
|
||||
await db.execute("DELETE FROM trusted_devices WHERE token_hash = ?", (token_hash,))
|
||||
await db.commit()
|
||||
return False
|
||||
|
||||
logger.info(f"[TRUSTED_DEVICE] Token valid for '{username}' (server={server_id})")
|
||||
return True
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[TRUSTED_DEVICE] Unexpected error during verification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def revoke_all_for_user(username: str, server_id: Optional[str] = None) -> int:
|
||||
"""
|
||||
Revocă toate token-urile de dispozitiv de încredere ale unui utilizator.
|
||||
|
||||
Args:
|
||||
username: Username Oracle (uppercase)
|
||||
server_id: Dacă specificat, revocă doar pentru serverul respectiv
|
||||
|
||||
Returns:
|
||||
Numărul de token-uri revocate
|
||||
"""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
if server_id is not None:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM trusted_devices WHERE UPPER(username) = ? AND server_id = ?",
|
||||
(username_upper, server_id)
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM trusted_devices WHERE UPPER(username) = ?",
|
||||
(username_upper,)
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Revoked {deleted} tokens for '{username}'"
|
||||
+ (f" (server={server_id})" if server_id else "")
|
||||
)
|
||||
|
||||
return deleted
|
||||
101
shared/database/app_db.py
Normal file
101
shared/database/app_db.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Shared SQLite database pentru toate datele auth-related (trusted devices, backup codes, email cache)."""
|
||||
import aiosqlite
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_DIR = Path(__file__).parent.parent.parent / "backend" / "data"
|
||||
DB_PATH = DB_DIR / "app.db"
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
conn = await aiosqlite.connect(DB_PATH)
|
||||
conn.row_factory = aiosqlite.Row
|
||||
return conn
|
||||
|
||||
|
||||
async def init_app_db():
|
||||
"""Create all auth-related tables. Safe to call multiple times."""
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA busy_timeout=5000")
|
||||
await db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Telegram tables (delegate init from telegram/db/database.py)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS telegram_users (
|
||||
telegram_user_id INTEGER PRIMARY KEY,
|
||||
username TEXT, first_name TEXT NOT NULL, last_name TEXT,
|
||||
oracle_username TEXT, jwt_token TEXT, jwt_refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP, linked_at TIMESTAMP,
|
||||
last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT 1
|
||||
)""")
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS telegram_auth_codes (
|
||||
code TEXT PRIMARY KEY, telegram_user_id INTEGER, oracle_username TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT 0, used_at TIMESTAMP, server_id TEXT,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)""")
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS telegram_sessions (
|
||||
session_id TEXT PRIMARY KEY, telegram_user_id INTEGER NOT NULL,
|
||||
conversation_state TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)""")
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS email_auth_codes (
|
||||
code TEXT PRIMARY KEY, email TEXT NOT NULL, oracle_username TEXT NOT NULL,
|
||||
telegram_user_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL, used INTEGER DEFAULT 0, used_at TIMESTAMP,
|
||||
failed_attempts INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)""")
|
||||
|
||||
# Trusted devices (migrated from JSON)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
|
||||
# Backup codes (new)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS backup_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT,
|
||||
code_hash TEXT NOT NULL,
|
||||
used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP
|
||||
)""")
|
||||
|
||||
# Email-server cache (migrated from in-memory)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS email_server_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(email, server_id)
|
||||
)""")
|
||||
|
||||
# Indexes for telegram
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_auth_codes_telegram_user ON telegram_auth_codes(telegram_user_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON telegram_auth_codes(expires_at)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sessions_telegram_user ON telegram_sessions(telegram_user_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON telegram_sessions(expires_at)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_auth_email ON email_auth_codes(email)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_auth_telegram_user ON email_auth_codes(telegram_user_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_auth_expires ON email_auth_codes(expires_at)")
|
||||
|
||||
# Indexes for new tables
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_trusted_devices_user ON trusted_devices(username, server_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_trusted_devices_expires ON trusted_devices(expires_at)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_backup_codes_user ON backup_codes(username, server_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_cache_email ON email_server_cache(email)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_cache_server ON email_server_cache(server_id)")
|
||||
|
||||
await db.commit()
|
||||
logger.info("[APP_DB] Database initialized successfully")
|
||||
Reference in New Issue
Block a user