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:
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user