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:
Claude Agent
2026-02-24 17:25:00 +00:00
parent b001b94e37
commit 1839285ac3
26 changed files with 2402 additions and 312 deletions

View File

@@ -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:
"""