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

3
.gitignore vendored
View File

@@ -531,6 +531,9 @@ backend/data/receipts/uploads/*
backend/data/ocr_queue/
!backend/data/*/.gitkeep
# Auth trusted devices (conține date sensibile — nu commita!)
data/auth/trusted_devices.json
# PRD tasks (generated, not tracked)
tasks/

View File

@@ -0,0 +1,20 @@
{
"47002b97f50e2efa46e269ca5361d10fe1e0731b80d1f74b2be731d584ec01b0": {
"username": "MARIUS M",
"server_id": "romfast",
"expires_at": "2026-03-26T13:13:05.664076",
"created_at": "2026-02-24T13:13:05.664076"
},
"00d05404386fa39ef3dfb85a283e4514954f8823f48e72032ce323918fd01982": {
"username": "MARIUS M",
"server_id": "romfast",
"expires_at": "2026-03-26T13:55:14.773910",
"created_at": "2026-02-24T13:55:14.773910"
},
"7b3a0379d6f199d6ee808a7114c328c05e9a462358c89f54fce63ca2bb01298c": {
"username": "MARIUS M",
"server_id": "romfast",
"expires_at": "2026-03-26T14:01:30.254613",
"created_at": "2026-02-24T14:01:30.254613"
}
}

View File

@@ -154,15 +154,15 @@ async def init_data_entry_db():
async def init_telegram_db():
"""Initialize Telegram SQLite database."""
logger.info("[TELEGRAM] Initializing SQLite database...")
"""Initialize shared app database (trusted devices, backup codes, telegram tables)."""
logger.info("[TELEGRAM] Initializing shared app database...")
try:
from backend.modules.telegram.db import init_database, cleanup_expired_codes, cleanup_expired_sessions, cleanup_expired_email_codes
await init_database()
logger.info(f"[TELEGRAM] ✅ Database initialized: {settings.telegram_sqlite_database_path}")
from shared.database.app_db import init_app_db
await init_app_db()
logger.info("[TELEGRAM] ✅ Shared app database initialized")
# Cleanup expired data
from backend.modules.telegram.db import cleanup_expired_codes, cleanup_expired_sessions, cleanup_expired_email_codes
expired_codes = await cleanup_expired_codes()
expired_sessions = await cleanup_expired_sessions()
expired_email_codes = await cleanup_expired_email_codes()
@@ -544,6 +544,9 @@ app.add_middleware(
"/", "/docs", "/health", "/redoc", "/openapi.json",
"/api/auth/login", "/api/auth/refresh", "/api/auth/check-email",
"/api/auth/check-identity", # US-013: Dual login support (email + username)
"/api/auth/verify-2fa-code", # 2FA: verificare cod OTP (public — fără JWT)
"/api/auth/resend-2fa-code", # 2FA: retrimite cod OTP (public — fără JWT)
"/api/auth/verify-backup-code", # Backup codes: verificare cod de recuperare (public — fără JWT)
"/api/system/auth-mode", # Public endpoint for login mode detection
"/api/telegram/auth/verify-user",
"/api/telegram/auth/verify-email",

View File

@@ -1,182 +1,52 @@
"""
SQLite Database Setup for Telegram Bot
This module handles database connection, initialization, and schema creation.
Uses aiosqlite for async SQLite operations.
Delegates to shared/database/app_db.py for unified database.
All tables (telegram, trusted devices, backup codes) live in app.db.
"""
import aiosqlite
import logging
from pathlib import Path
from datetime import datetime, timedelta
from typing import Optional
from shared.database.app_db import DB_PATH, get_db as _get_app_db, init_app_db
logger = logging.getLogger(__name__)
# Database file location
DB_DIR = Path(__file__).parent.parent.parent / "data"
DB_PATH = DB_DIR / "telegram_bot.db"
# SQLite busy timeout in milliseconds (wait for locks instead of failing immediately)
SQLITE_BUSY_TIMEOUT_MS = 5000
# Re-export DB_PATH for backward compatibility (operations.py imports it)
__all__ = [
'get_db_connection',
'init_database',
'cleanup_expired_codes',
'cleanup_expired_sessions',
'cleanup_expired_email_codes',
'get_database_stats',
'DB_PATH',
]
async def get_db_connection() -> aiosqlite.Connection:
"""
Get a database connection.
Get a database connection. Delegates to shared app_db.
Returns:
aiosqlite.Connection: Database connection
"""
conn = await aiosqlite.connect(DB_PATH)
conn.row_factory = aiosqlite.Row # Enable column access by name
return conn
return await _get_app_db()
async def init_database() -> None:
"""
Initialize the database and create all tables.
Initialize the database. Delegates to shared init_app_db().
Safe to call multiple times - only creates tables if they don't exist.
"""
try:
# Ensure data directory exists
DB_DIR.mkdir(parents=True, exist_ok=True)
logger.info(f"Database directory: {DB_DIR}")
async with aiosqlite.connect(DB_PATH) as db:
# Enable WAL mode for better concurrent access
await db.execute("PRAGMA journal_mode=WAL")
# Set busy timeout to wait for locks instead of failing immediately
await db.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}")
# Enable foreign keys
await db.execute("PRAGMA foreign_keys = ON")
# Create telegram_users table
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
)
""")
# Create telegram_auth_codes table
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,
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
)
""")
# Create telegram_sessions table
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)
)
""")
# Create email_auth_codes table (email-based authentication)
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)
)
""")
# Create indexes for better query performance
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)
""")
# Create indexes for email_auth_codes table
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)
""")
# Migration: add server_id column to telegram_auth_codes if missing
try:
await db.execute("ALTER TABLE telegram_auth_codes ADD COLUMN server_id TEXT")
await db.commit()
logger.info("Migration: added server_id column to telegram_auth_codes")
except Exception:
pass # Column already exists
await db.commit()
logger.info("Database initialized successfully")
# Log table info
cursor = await db.execute("""
SELECT name FROM sqlite_master
WHERE type='table'
ORDER BY name
""")
tables = await cursor.fetchall()
logger.info(f"Existing tables: {[t[0] for t in tables]}")
except Exception as e:
logger.error(f"Failed to initialize database: {e}")
raise
await init_app_db()
logger.info("Database initialized successfully (delegated to app_db)")
async def cleanup_expired_codes() -> int:
"""
Delete expired authentication codes from the database.
This should be called periodically (e.g., every hour).
Returns:
int: Number of expired codes deleted
@@ -205,7 +75,6 @@ async def cleanup_expired_codes() -> int:
async def cleanup_expired_sessions() -> int:
"""
Delete expired sessions from the database.
This should be called periodically (e.g., daily).
Returns:
int: Number of expired sessions deleted
@@ -234,7 +103,6 @@ async def cleanup_expired_sessions() -> int:
async def cleanup_expired_email_codes() -> int:
"""
Delete expired and old used email codes from the database.
This should be called periodically (e.g., hourly).
Returns:
int: Number of email codes deleted
@@ -242,7 +110,6 @@ async def cleanup_expired_email_codes() -> int:
try:
async with aiosqlite.connect(DB_PATH) as db:
db.row_factory = aiosqlite.Row
# Delete expired codes or used codes older than 1 day
cursor = await db.execute("""
DELETE FROM email_auth_codes
WHERE expires_at < ?
@@ -311,15 +178,3 @@ async def get_database_stats() -> dict:
except Exception as e:
logger.error(f"Failed to get database stats: {e}")
return {}
# Export main functions
__all__ = [
'get_db_connection',
'init_database',
'cleanup_expired_codes',
'cleanup_expired_sessions',
'cleanup_expired_email_codes',
'get_database_stats',
'DB_PATH',
]

View 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()

View File

@@ -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):
@@ -94,6 +98,14 @@ class TokenResponse(BaseModel):
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()
# ============================================================================
# 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
View 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

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 not success:
logger.warning(f"Failed login attempt for user {login_data.username}{server_info}: {error_message}")
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=error_message or "Authentication failed"
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 is_valid:
logger.warning(f"[LOGIN] Failed credentials for '{actual_username}'{server_info}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
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
logger.info(f"Successful login for user {login_data.username}{server_info}")
return token_response
# 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)
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:
"""

View 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
View 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")

View File

@@ -219,6 +219,10 @@ const fetchAvailableServers = async () => {
const handleServerSwitched = async (newServerId) => {
console.log('[App] Server switched to:', newServerId)
// Clear selected company FIRST to prevent stale data from old server
companyStore.clearSelectedCompany()
console.log('[App] Company selection cleared after server switch')
// Reset period store for the new server context (US-010)
periodStore.reset()
console.log('[App] Period store reset after server switch')

View File

@@ -686,6 +686,35 @@
/* Server dropdown in login form uses default styling (inherits from global rules above) */
/* Server dropdown in header is styled in header.css to match CompanySelector */
/* ===== All Dialogs - Dark Mode ===== */
/* Dialog background + content folosesc design tokens */
[data-theme="dark"] .p-dialog,
[data-theme="dark"] .p-dialog .p-dialog-header,
[data-theme="dark"] .p-dialog .p-dialog-content,
[data-theme="dark"] .p-dialog .p-dialog-footer {
background: var(--surface-card) !important;
color: var(--text-color) !important;
}
[data-theme="dark"] .p-dialog .p-dialog-title {
color: var(--text-color) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .p-dialog,
:root:not([data-theme]) .p-dialog .p-dialog-header,
:root:not([data-theme]) .p-dialog .p-dialog-content,
:root:not([data-theme]) .p-dialog .p-dialog-footer {
background: var(--surface-card) !important;
color: var(--text-color) !important;
}
:root:not([data-theme]) .p-dialog .p-dialog-title {
color: var(--text-color) !important;
}
}
/* ===== Server Switch Password Modal (Mobile) ===== */
/* These styles must be global because Dialog is teleported to body */

View File

@@ -1191,7 +1191,10 @@ const handleLogout = async () => {
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany()
periodStore.reset()
// Reload data for the new server
await companyStore.loadCompanies()
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -431,7 +431,10 @@ const handleLogout = async () => {
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany();
periodStore.reset();
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -428,7 +428,10 @@ const handleLogout = async () => {
// Handle server switched event from drawer menu
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany();
periodStore.reset();
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -428,7 +428,10 @@ const handleLogout = async () => {
// Handle server switched event from drawer menu
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany();
periodStore.reset();
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -791,7 +791,10 @@ const handleLogout = async () => {
// Handle server switched from drawer menu (password already verified in modal)
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany();
periodStore.reset();
// Reload companies and periods for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -768,7 +768,10 @@ const handleLogout = async () => {
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany()
periodStore.reset()
// Reload data for the new server
await companyStore.loadCompanies()
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -442,7 +442,10 @@ const handleLogout = async () => {
// Handle server switched event from drawer menu
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany();
periodStore.reset();
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -172,7 +172,10 @@ const handleLogout = async () => {
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany()
periodStore.reset()
// Reload data for the new server
await companyStore.loadCompanies()
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -92,10 +92,12 @@ import { useRouter } from 'vue-router'
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
import MobileDrawerMenu from '@shared/components/mobile/MobileDrawerMenu.vue'
import { useAuthStore } from '@reports/stores/sharedStores'
import { useAuthStore, useCompanyStore, useAccountingPeriodStore } from '@reports/stores/sharedStores'
const router = useRouter()
const authStore = useAuthStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
// State
const showDrawer = ref(false)
@@ -117,8 +119,9 @@ const handleLogout = async () => {
// Handle server switch completed
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// SettingsHubView doesn't need to reload data - it's just a navigation hub
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany()
periodStore.reset()
}
// US-307: Removed custom mobileNavItems - using MobileBottomNav defaults

View File

@@ -409,7 +409,10 @@ const handleLogout = async () => {
// Handle server switch completed - reload data for new server
const handleServerSwitched = async (newServerId) => {
// Server switch already completed in MobileDrawerMenu modal
// Clear selected company and period FIRST to prevent stale data from old server
companyStore.clearSelectedCompany();
periodStore.reset();
// Reload data for the new server
await companyStore.loadCompanies();
if (companyStore.selectedCompany?.id_firma) {

View File

@@ -17,7 +17,81 @@
<p>Se încarcă...</p>
</div>
<!-- Simplified Login Form -->
<!-- 2FA Step verificare cod -->
<div v-else-if="authStore.loginStep === '2fa'" class="login-2fa">
<div class="twofa-header">
<i class="pi pi-envelope text-primary twofa-icon"></i>
<p class="twofa-info">
Cod trimis la <strong>{{ authStore.otpMaskedEmail }}</strong>
</p>
<p class="twofa-subinfo">Introduceți codul email sau un cod de recuperare</p>
</div>
<div class="form-group">
<InputText
id="otp-code"
v-model="unifiedCode"
:inputmode="isBackupCodeMode ? 'text' : 'numeric'"
:placeholder="isBackupCodeMode ? 'Cod de recuperare' : '_ _ _ _ _ _'"
maxlength="8"
class="w-full otp-input"
:class="{ invalid: otpError, 'backup-code-mode': isBackupCodeMode }"
autocomplete="one-time-code"
@input="handleUnifiedInput"
@keyup.enter="handleVerifyUnified"
/>
<span v-if="otpError" class="form-error">{{ otpError }}</span>
</div>
<!-- Eroare din store -->
<div v-if="authStore.error" class="login-error-message">
<i class="pi pi-exclamation-triangle"></i>
<span>{{ authStore.error }}</span>
</div>
<!-- Buton verificare -->
<Button
label="Verifică"
class="w-full login-button"
:loading="authStore.is2FALoading"
:disabled="unifiedCode.length < 6 || authStore.is2FALoading"
icon="pi pi-check"
icon-pos="right"
@click="handleVerifyUnified"
/>
<!-- Checkbox "Ține minte dispozitivul" -->
<div class="twofa-trust-device">
<Checkbox v-model="trustDevice" inputId="trust-device" :binary="true" />
<label for="trust-device" class="trust-device-label">
Ține minte acest dispozitiv 30 de zile
</label>
</div>
<!-- Retrimite codul (doar în modul OTP) -->
<div v-if="!isBackupCodeMode" class="twofa-resend">
<span v-if="authStore.resendCountdown > 0" class="resend-countdown">
Retrimite codul în {{ authStore.resendCountdown }}s
</span>
<a
v-else
href="#"
class="resend-link"
@click.prevent="handleResendOTP"
>
Retrimite codul
</a>
</div>
<!-- Înapoi la login -->
<div class="twofa-back">
<a href="#" class="back-link" @click.prevent="handleBackFromOTP">
<i class="pi pi-arrow-left"></i> Înapoi la login
</a>
</div>
</div>
<!-- Login Form (username + parolă) -->
<form
v-else
class="login-form"
@@ -107,6 +181,38 @@
</div>
</template>
</Card>
<!-- Modal: backup codes generate la primul 2FA -->
<Dialog
v-model:visible="showBackupCodesModal"
header="Coduri de recuperare generate"
:modal="true"
:closable="false"
:style="{ width: '400px' }"
>
<div class="backup-codes-modal-content">
<p class="backup-codes-info">
<strong>Important:</strong> Salvați aceste coduri în siguranță! Ele se afișează o singură dată.
Folosiți-le dacă nu primiți codul pe email.
</p>
<div class="backup-codes-grid">
<code v-for="code in backupCodesToShow" :key="code" class="backup-code-item">{{ code }}</code>
</div>
</div>
<template #footer>
<Button
label="Am salvat codurile"
icon="pi pi-check"
@click="showBackupCodesModal = false; router.push(props.redirectPath)"
/>
<Button
label="Copiază toate"
icon="pi pi-copy"
severity="secondary"
@click="copyBackupCodes"
/>
</template>
</Dialog>
</div>
</div>
</template>
@@ -115,6 +221,8 @@
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useToast } from "primevue/usetoast";
import Checkbox from "primevue/checkbox";
import Dialog from "primevue/dialog";
import Dropdown from "primevue/dropdown";
import Password from "primevue/password";
@@ -146,7 +254,7 @@ const router = useRouter();
const route = useRoute();
const toast = useToast();
// Form data
// Form data — login normal
const identity = ref("");
const identityError = ref("");
const selectedServer = ref(null);
@@ -154,6 +262,16 @@ const serverError = ref("");
const password = ref("");
const passwordError = ref("");
// Form data — 2FA (unified: acceptă atât cod OTP cât și cod de recuperare)
const unifiedCode = ref("");
const otpError = ref("");
const trustDevice = ref(false);
const backupCodesToShow = ref([]);
const showBackupCodesModal = ref(false);
// true când input-ul conține litere → mod cod de recuperare
const isBackupCodeMode = computed(() => /[A-Z]/.test(unifiedCode.value));
// Internal state for server loading
const isIdentityVerified = ref(false);
@@ -176,7 +294,9 @@ const canSubmit = computed(() => {
return true;
});
// Methods
// ============================================================================
// LOGIN FORM HANDLERS
// ============================================================================
const clearPasswordError = () => {
passwordError.value = "";
@@ -273,6 +393,17 @@ const handleLogin = async () => {
const result = await props.authStore.login(credentials);
if (result.success && result.requires_2fa) {
// Backend cere verificare OTP — focus pe input-ul de cod
unifiedCode.value = "";
otpError.value = "";
setTimeout(() => {
const otpInput = document.getElementById("otp-code");
if (otpInput) otpInput.focus();
}, 100);
return; // Nu redirectăm — așteptăm codul
}
if (result.success) {
toast.add({
severity: "success",
@@ -320,12 +451,122 @@ const handleLogin = async () => {
}
};
// ============================================================================
// 2FA HANDLERS
// ============================================================================
// Input unificat: filtrează și limitează lungimea în funcție de mod
const handleUnifiedInput = (event) => {
otpError.value = "";
props.authStore.clearError();
let val = event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
// OTP: doar cifre → maxim 6; backup code: are litere → maxim 8
if (/^[0-9]*$/.test(val)) {
val = val.slice(0, 6);
} else {
val = val.slice(0, 8);
}
unifiedCode.value = val;
// Auto-submit la 6 cifre (OTP complet)
if (/^\d{6}$/.test(val)) {
handleVerifyUnified();
}
};
// Verificare unificată — detectează tipul codului și apelează handler-ul corect
const handleVerifyUnified = async () => {
if (unifiedCode.value.length < 6) return;
otpError.value = "";
if (isBackupCodeMode.value) {
// Cod de recuperare (alfanumeric)
const result = await props.authStore.verifyBackupCode({
code: unifiedCode.value,
trustDevice: trustDevice.value,
});
if (result.success) {
toast.add({
severity: "success",
summary: "Autentificare reușită",
detail: "Ați folosit un cod de recuperare",
life: 3000,
});
router.push(props.redirectPath);
}
} else {
// Cod OTP (6 cifre)
if (unifiedCode.value.length !== 6) {
otpError.value = "Codul trebuie să aibă exact 6 cifre";
return;
}
const result = await props.authStore.verify2FA({
code: unifiedCode.value,
trustDevice: trustDevice.value,
});
if (result.success) {
if (result.backup_codes && result.backup_codes.length > 0) {
backupCodesToShow.value = result.backup_codes;
showBackupCodesModal.value = true;
} else {
toast.add({
severity: "success",
summary: "Autentificare reușită",
detail: `Bine ați venit, ${props.authStore.user?.username || ""}!`,
life: 3000,
});
router.push(props.redirectPath);
}
}
}
};
// Retrimite cod OTP
const handleResendOTP = async () => {
const result = await props.authStore.resendOTP();
if (result.success) {
toast.add({
severity: "info",
summary: "Cod retrimis",
detail: `Un cod nou a fost trimis la ${props.authStore.otpMaskedEmail}`,
life: 4000,
});
}
};
// Înapoi din 2FA — resetează la formularul de login
const handleBackFromOTP = () => {
unifiedCode.value = "";
otpError.value = "";
trustDevice.value = false;
props.authStore.clearError();
props.authStore.resetLoginFlow();
};
// Copiază backup codes în clipboard
const copyBackupCodes = async () => {
try {
await navigator.clipboard.writeText(backupCodesToShow.value.join('\n'));
toast.add({ severity: "success", summary: "Copiat!", life: 2000 });
} catch (e) {
toast.add({ severity: "warn", summary: "Nu s-a putut copia automat", life: 2000 });
}
};
// ============================================================================
// SHARED
// ============================================================================
// Clear errors on mount
const clearErrors = () => {
props.authStore.clearError();
identityError.value = "";
passwordError.value = "";
serverError.value = "";
unifiedCode.value = "";
otpError.value = "";
};
// Watch for selectedServerId changes from store (pre-selection from localStorage)
@@ -386,4 +627,144 @@ onUnmounted(() => {
/* Server dropdown - use normal styling like other form inputs */
/* No special overrides needed - inherits from primevue-overrides.css */
/* ============================================================
2FA STEP
============================================================ */
.login-2fa {
padding: 0 var(--space-lg) var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.twofa-header {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
}
.twofa-icon {
font-size: 2.5rem;
}
.twofa-info {
color: var(--text-color);
margin: 0;
font-size: 0.9375rem;
}
.twofa-subinfo {
color: var(--text-color-secondary);
font-size: 0.875rem;
margin: 0;
}
/* Input OTP — font mare, centrat, cifre spațiate */
.otp-input {
text-align: center !important;
font-size: 1.75rem !important;
letter-spacing: 0.6em !important;
font-weight: var(--font-semibold) !important;
font-variant-numeric: tabular-nums;
}
/* Mod cod de recuperare — font monospace, fără spacing exagerat */
.otp-input.backup-code-mode,
.otp-input.backup-code-mode input {
font-size: 1.375rem !important;
letter-spacing: 0.05em !important;
font-family: monospace;
}
.twofa-resend {
text-align: center;
}
.resend-countdown {
color: var(--text-color-secondary);
font-size: 0.875rem;
}
.resend-link {
color: var(--primary-color);
cursor: pointer;
font-size: 0.875rem;
text-decoration: none;
}
.resend-link:hover {
text-decoration: underline;
}
.twofa-back {
text-align: center;
}
.back-link {
color: var(--text-color-secondary);
font-size: 0.875rem;
text-decoration: none;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.back-link:hover {
color: var(--text-color);
}
/* Checkbox "Ține minte dispozitivul" */
.twofa-trust-device {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-xs);
}
.trust-device-label {
font-size: 0.875rem;
color: var(--text-color-secondary);
cursor: pointer;
user-select: none;
/* Touch target minim 44px */
min-height: 44px;
display: flex;
align-items: center;
}
/* Backup codes modal */
.backup-codes-modal-content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.backup-codes-info {
color: var(--text-color-secondary);
font-size: 0.875rem;
margin: 0;
}
.backup-codes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-sm);
}
.backup-code-item {
background: var(--surface-ground);
border: 1px solid var(--surface-border);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-md);
font-family: monospace;
font-size: 0.9375rem;
font-weight: var(--font-semibold);
text-align: center;
letter-spacing: 0.1em;
}
</style>

View File

@@ -70,7 +70,7 @@
</div>
</nav>
<!-- Server Switch Password Modal (US-009) -->
<!-- Server Switch Modal - Password + 2FA steps (US-009) -->
<Dialog
v-model:visible="showPasswordModal"
:header="`Schimbare server: ${targetServerName}`"
@@ -80,6 +80,8 @@
class="server-switch-modal"
>
<div class="server-switch-modal-content">
<!-- Step 1: Password -->
<template v-if="switchStep === 'password'">
<div class="form-field">
<Password
id="switch-password"
@@ -87,12 +89,66 @@
:feedback="false"
:toggleMask="true"
inputClass="w-full"
:inputStyle="passwordInputStyle"
class="w-full"
:disabled="isSwitching"
@keyup.enter="confirmServerSwitch"
autofocus
/>
</div>
</template>
<!-- Step 2: 2FA OTP -->
<template v-else-if="switchStep === '2fa'">
<p v-if="switchMaskedEmail" class="otp-info">
Cod trimis la <strong>{{ switchMaskedEmail }}</strong>
</p>
<div class="form-field">
<InputText
v-model="switchOtpCode"
placeholder="000000"
inputmode="numeric"
maxlength="6"
class="w-full otp-input"
:disabled="isSwitching"
@input="handleSwitchOtpInput"
@keyup.enter="confirmSwitch2FA"
autofocus
/>
</div>
<div class="form-field-inline">
<Checkbox v-model="switchTrustDevice" inputId="switch-trust" :binary="true" />
<label for="switch-trust" class="trust-label">Ține minte acest dispozitiv 30 zile</label>
</div>
<button
type="button"
class="resend-link"
:disabled="authStore?.resendCountdown > 0"
@click="handleSwitchResendOTP"
>
{{ authStore?.resendCountdown > 0 ? `Retrimite (${authStore.resendCountdown}s)` : 'Retrimite codul' }}
</button>
<!-- Backup code fallback pentru server switch -->
<div class="backup-switch-toggle" style="margin-top: var(--space-sm); text-align: center;">
<button
v-if="!showSwitchBackupInput"
type="button"
class="resend-link"
@click="showSwitchBackupInput = true"
>
Folosește cod de recuperare
</button>
<div v-else>
<InputText
v-model="switchBackupCode"
placeholder="Cod recuperare"
class="w-full"
style="margin-bottom: var(--space-sm);"
@input="switchBackupCode = switchBackupCode.toUpperCase().replace(/[^A-Z0-9]/g, '')"
/>
</div>
</div>
</template>
<div v-if="switchError" class="switch-error">
<i class="pi pi-exclamation-circle"></i>
@@ -101,6 +157,8 @@
</div>
<template #footer>
<!-- Password step footer -->
<template v-if="switchStep === 'password'">
<Button
label="Anulează"
severity="secondary"
@@ -108,12 +166,28 @@
@click="cancelServerSwitch"
/>
<Button
label="Confirma"
label="Confirmă"
:loading="isSwitching"
:disabled="!switchPassword"
@click="confirmServerSwitch"
/>
</template>
<!-- 2FA step footer -->
<template v-else-if="switchStep === '2fa'">
<Button
label="Înapoi"
severity="secondary"
:disabled="isSwitching"
@click="switchStep = 'password'"
/>
<Button
label="Verifică"
:loading="isSwitching"
:disabled="!switchOtpCode || switchOtpCode.length < 6"
@click="confirmSwitch2FA"
/>
</template>
</template>
</Dialog>
</header>
</template>
@@ -126,6 +200,8 @@ import ServerSelector from "../ServerSelector.vue";
import Dialog from "primevue/dialog";
import Password from "primevue/password";
import Button from "primevue/button";
import InputText from "primevue/inputtext";
import Checkbox from "primevue/checkbox";
export default {
name: "AppHeader",
@@ -136,6 +212,8 @@ export default {
Dialog,
Password,
Button,
InputText,
Checkbox,
},
props: {
// Header title/brand text
@@ -226,6 +304,21 @@ export default {
return server?.name || serverId;
};
// Dark mode detection for password input styling
const isDarkMode = computed(() => {
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark') return true;
if (attr === 'light') return false;
return window.matchMedia('(prefers-color-scheme: dark)').matches;
});
const passwordInputStyle = computed(() => isDarkMode.value ? {
background: 'var(--color-bg)',
color: 'var(--color-text)',
webkitTextFillColor: 'var(--color-text)',
borderColor: 'var(--color-border)',
} : {});
// Password confirmation modal state (US-009)
const showPasswordModal = ref(false);
const switchPassword = ref("");
@@ -234,6 +327,16 @@ export default {
const isSwitching = ref(false);
const switchError = ref("");
// 2FA step state for server switch
const switchStep = ref("password"); // 'password' | '2fa'
const switchOtpCode = ref("");
const switchTrustDevice = ref(false);
const switchMaskedEmail = ref("");
// Backup code state for server switch
const showSwitchBackupInput = ref(false);
const switchBackupCode = ref("");
const onServerChange = (event) => {
const newServerId = event.value;
// Don't process if same server selected
@@ -261,8 +364,33 @@ export default {
showPasswordModal.value = false;
switchPassword.value = "";
switchError.value = "";
switchOtpCode.value = "";
switchTrustDevice.value = false;
switchMaskedEmail.value = "";
switchStep.value = "password";
showSwitchBackupInput.value = false;
switchBackupCode.value = "";
targetServerId.value = null;
targetServerName.value = "";
// Clear pending server in auth store
if (props.authStore) {
props.authStore.pendingServerId = null;
}
};
const finishServerSwitch = () => {
showPasswordModal.value = false;
switchPassword.value = "";
switchOtpCode.value = "";
switchTrustDevice.value = false;
switchMaskedEmail.value = "";
switchStep.value = "password";
showSwitchBackupInput.value = false;
switchBackupCode.value = "";
// Update local state to reflect new server
currentServerId.value = targetServerId.value;
// Emit event for parent to reload data (companies, periods)
emit("server-switched", targetServerId.value);
};
const confirmServerSwitch = async () => {
@@ -283,16 +411,13 @@ export default {
try {
const result = await props.authStore.switchServer(targetServerId.value, switchPassword.value);
if (result.success) {
// Close modal
showPasswordModal.value = false;
switchPassword.value = "";
// Update local state to reflect new server
currentServerId.value = targetServerId.value;
// Emit event for parent to reload data (companies, periods)
emit("server-switched", targetServerId.value);
if (result.requires_2fa) {
// Show 2FA step — don't close modal
switchStep.value = "2fa";
switchMaskedEmail.value = result.masked_email || "";
switchError.value = "";
} else if (result.success) {
finishServerSwitch();
} else {
// Show error in modal
switchError.value = result.error || "Autentificare eșuată";
@@ -304,6 +429,52 @@ export default {
}
};
const confirmSwitch2FA = async () => {
isSwitching.value = true;
switchError.value = "";
try {
let result;
if (showSwitchBackupInput.value && switchBackupCode.value) {
// Verifică cu backup code
result = await props.authStore.verifyBackupCode({
code: switchBackupCode.value,
});
} else {
if (!switchOtpCode.value || switchOtpCode.value.trim().length === 0) {
switchError.value = "Introduceți codul OTP";
isSwitching.value = false;
return;
}
result = await props.authStore.verify2FA({
code: switchOtpCode.value,
trustDevice: switchTrustDevice.value,
});
}
if (result.success) {
finishServerSwitch();
} else {
switchError.value = result.error || "Cod invalid";
}
} catch (err) {
switchError.value = err.message || "Eroare la verificare";
} finally {
isSwitching.value = false;
}
};
const handleSwitchResendOTP = async () => {
const result = await props.authStore.resendOTP();
if (!result.success) {
switchError.value = result.error || "Nu s-a putut retrimite codul";
}
};
const handleSwitchOtpInput = (event) => {
switchOtpCode.value = event.target.value.replace(/\D/g, "").slice(0, 6);
};
const onCompanyChanged = (company) => {
emit("company-changed", company);
};
@@ -373,7 +544,81 @@ export default {
switchError,
cancelServerSwitch,
confirmServerSwitch,
// 2FA step in server switch modal
switchStep,
switchOtpCode,
switchTrustDevice,
switchMaskedEmail,
finishServerSwitch,
confirmSwitch2FA,
handleSwitchResendOTP,
handleSwitchOtpInput,
// Backup code in server switch
showSwitchBackupInput,
switchBackupCode,
// Dark mode password input
passwordInputStyle,
};
},
};
</script>
<style scoped>
/* Server switch modal - 2FA step styles */
.otp-info {
font-size: var(--text-sm);
color: var(--text-color-secondary);
margin-bottom: var(--space-sm);
}
.form-field {
margin-bottom: var(--space-sm);
}
.form-field-inline {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.trust-label {
font-size: var(--text-sm);
color: var(--text-color);
cursor: pointer;
}
.otp-input {
letter-spacing: 0.3em;
font-size: var(--text-lg);
text-align: center;
}
.resend-link {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--text-sm);
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.resend-link:disabled {
color: var(--text-color-secondary);
cursor: not-allowed;
text-decoration: none;
}
.switch-error {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--red-50);
color: var(--red-600);
border-radius: var(--radius-md);
font-size: var(--text-sm);
margin-top: var(--space-sm);
}
</style>

View File

@@ -249,7 +249,7 @@
</div>
</Transition>
<!-- Server Switch Password Modal -->
<!-- Server Switch Modal - Password + 2FA steps -->
<Dialog
v-model:visible="showServerPasswordModal"
:header="`Schimbare server: ${targetServerName}`"
@@ -259,6 +259,8 @@
class="mobile-server-switch-modal"
>
<div class="server-switch-modal-content">
<!-- Step 1: Password -->
<template v-if="switchStep === 'password'">
<Password
v-model="serverSwitchPassword"
:feedback="false"
@@ -269,6 +271,37 @@
@keyup.enter="confirmServerSwitch"
autofocus
/>
</template>
<!-- Step 2: 2FA OTP -->
<template v-else-if="switchStep === '2fa'">
<p v-if="switchMaskedEmail" class="otp-info">
Cod trimis la <strong>{{ switchMaskedEmail }}</strong>
</p>
<InputText
v-model="switchOtpCode"
placeholder="000000"
inputmode="numeric"
maxlength="6"
class="w-full otp-input"
:disabled="isSwitchingServer"
@input="handleSwitchOtpInput"
@keyup.enter="confirmSwitch2FA"
autofocus
/>
<div class="form-field-inline">
<Checkbox v-model="switchTrustDevice" inputId="mobile-switch-trust" :binary="true" />
<label for="mobile-switch-trust" class="trust-label">Ține minte acest dispozitiv 30 zile</label>
</div>
<button
type="button"
class="resend-link"
:disabled="props.authStore?.resendCountdown > 0"
@click="handleSwitchResendOTP"
>
{{ props.authStore?.resendCountdown > 0 ? `Retrimite (${props.authStore.resendCountdown}s)` : 'Retrimite codul' }}
</button>
</template>
<div v-if="serverSwitchError" class="switch-error">
<i class="pi pi-exclamation-circle"></i>
@@ -277,6 +310,8 @@
</div>
<template #footer>
<!-- Password step footer -->
<template v-if="switchStep === 'password'">
<Button
label="Anulează"
severity="secondary"
@@ -290,6 +325,22 @@
@click="confirmServerSwitch"
/>
</template>
<!-- 2FA step footer -->
<template v-else-if="switchStep === '2fa'">
<Button
label="Înapoi"
severity="secondary"
:disabled="isSwitchingServer"
@click="switchStep = 'password'"
/>
<Button
label="Verifică"
:loading="isSwitchingServer"
:disabled="!switchOtpCode || switchOtpCode.length < 6"
@click="confirmSwitch2FA"
/>
</template>
</template>
</Dialog>
</Teleport>
</template>
@@ -300,6 +351,8 @@ import { useRoute, useRouter } from 'vue-router'
import Dialog from 'primevue/dialog'
import Password from 'primevue/password'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Checkbox from 'primevue/checkbox'
/**
* MobileDrawerMenu - Material Design 3 inspired navigation drawer for mobile (v4)
@@ -430,6 +483,12 @@ const targetServerName = ref('')
const isSwitchingServer = ref(false)
const serverSwitchError = ref('')
// 2FA step state for server switch
const switchStep = ref('password') // 'password' | '2fa'
const switchOtpCode = ref('')
const switchTrustDevice = ref(false)
const switchMaskedEmail = ref('')
// US-608: Removed collapsible state management - using direct dropdowns now
// Computed properties for company selector
@@ -540,6 +599,27 @@ const cancelServerSwitch = () => {
showServerPasswordModal.value = false
serverSwitchPassword.value = ''
serverSwitchError.value = ''
switchOtpCode.value = ''
switchTrustDevice.value = false
switchMaskedEmail.value = ''
switchStep.value = 'password'
// Clear pending server in auth store
if (props.authStore) {
props.authStore.pendingServerId = null
}
}
const finishServerSwitch = () => {
showServerPasswordModal.value = false
serverSwitchPassword.value = ''
switchOtpCode.value = ''
switchTrustDevice.value = false
switchMaskedEmail.value = ''
switchStep.value = 'password'
// Emit event for parent to reload data
emit('server-switched', targetServerId.value)
// Close the drawer after successful switch
close()
}
const confirmServerSwitch = async () => {
@@ -559,16 +639,13 @@ const confirmServerSwitch = async () => {
try {
const result = await props.authStore.switchServer(targetServerId.value, serverSwitchPassword.value)
if (result.success) {
// Close modal and drawer
showServerPasswordModal.value = false
serverSwitchPassword.value = ''
// Emit event for parent to reload data
emit('server-switched', targetServerId.value)
// Close the drawer after successful switch
close()
if (result.requires_2fa) {
// Show 2FA step — don't close modal
switchStep.value = '2fa'
switchMaskedEmail.value = result.masked_email || ''
serverSwitchError.value = ''
} else if (result.success) {
finishServerSwitch()
} else {
serverSwitchError.value = result.error || 'Autentificare eșuată'
}
@@ -579,6 +656,44 @@ const confirmServerSwitch = async () => {
}
}
const confirmSwitch2FA = async () => {
if (!switchOtpCode.value || switchOtpCode.value.trim().length === 0) {
serverSwitchError.value = 'Introduceți codul OTP'
return
}
isSwitchingServer.value = true
serverSwitchError.value = ''
try {
const result = await props.authStore.verify2FA({
code: switchOtpCode.value,
trustDevice: switchTrustDevice.value,
})
if (result.success) {
finishServerSwitch()
} else {
serverSwitchError.value = result.error || 'Cod invalid'
}
} catch (error) {
serverSwitchError.value = error.message || 'Eroare la verificare'
} finally {
isSwitchingServer.value = false
}
}
const handleSwitchResendOTP = async () => {
const result = await props.authStore.resendOTP()
if (!result.success) {
serverSwitchError.value = result.error || 'Nu s-a putut retrimite codul'
}
}
const handleSwitchOtpInput = (event) => {
switchOtpCode.value = event.target.value.replace(/\D/g, '').slice(0, 6)
}
// Close dropdowns when drawer closes
watch(() => props.modelValue, (isOpen) => {
if (!isOpen) {
@@ -1634,6 +1749,47 @@ onMounted(() => {
width: 100%;
}
.otp-info {
font-size: var(--text-sm);
color: var(--text-color-secondary);
margin-bottom: var(--space-xs);
}
.otp-input {
letter-spacing: 0.3em;
font-size: var(--text-lg);
text-align: center;
width: 100%;
}
.form-field-inline {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.trust-label {
font-size: var(--text-sm);
color: var(--text-color);
cursor: pointer;
}
.resend-link {
background: none;
border: none;
color: var(--color-primary);
font-size: var(--text-sm);
cursor: pointer;
padding: 0;
text-decoration: underline;
}
.resend-link:disabled {
color: var(--text-color-secondary);
cursor: not-allowed;
text-decoration: none;
}
.switch-error {
display: flex;
align-items: center;

View File

@@ -28,6 +28,31 @@ const STORAGE_KEYS = {
AUTH_MODE: "auth_mode",
};
/**
* Returnează cheia localStorage pentru tokenul de trusted device.
* Cheia e per-user și per-server pentru izolare corectă.
* Exemplu: "trusted_device_MARIUS M_romfast"
*/
const _getTrustedDeviceKey = (username, serverId) => {
const base = `trusted_device_${(username || "unknown").toUpperCase()}`;
return serverId ? `${base}_${serverId}` : base;
};
/**
* Caută orice token de trusted device, indiferent de username sau server.
* Backend validează oricum username-ul corect.
*/
const _findAnyTrustedDeviceToken = () => {
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && key.startsWith("trusted_device_")) {
const val = localStorage.getItem(key);
if (val) return val;
}
}
return null;
};
/**
* Factory function to create an auth store with the provided API service
* @param {Object} apiService - Axios instance configured for the app's API
@@ -69,6 +94,18 @@ export function createAuthStore(apiService, options = {}) {
// Allows URL bookmark to pre-select a server (e.g., /login?server=romfast)
const preselectedServerId = ref(null);
// State - 2FA (pasul 2 din fluxul de login cu email)
const otpEmail = ref(""); // Email complet — trimis la /auth/verify-2fa-code
const otpMaskedEmail = ref(""); // Email mascat pentru display ("m***@romfast.ro")
const is2FALoading = ref(false); // Loading state pt butonul "Verifică"
const resendCountdown = ref(0); // Countdown 60s pt butonul "Retrimite codul"
let _resendTimer = null; // Interval timer intern (nu ref — nu e nevoie de reactivitate)
// State - pending server ID pentru 2FA în contextul server switch
// Login() returnează requires_2fa înainte de a actualiza selectedServerId,
// deci verify2FA() trebuie să folosească pendingServerId pentru server-ul corect
const pendingServerId = ref(null);
// Getters
const isAuthenticated = computed(() => !!accessToken.value);
const currentUser = computed(() => user.value);
@@ -217,8 +254,30 @@ export function createAuthStore(apiService, options = {}) {
payload.server_id = credentials.server_id;
}
// Trusted device: trimitem tokenul stocat local pentru posibil skip 2FA
// Fallback cross-server: dacă nu există token pentru serverul curent, căutăm orice token al utilizatorului
const tdKey = _getTrustedDeviceKey(credentials.username, credentials.server_id);
const storedTrustedToken = localStorage.getItem(tdKey) || _findAnyTrustedDeviceToken();
if (storedTrustedToken) {
payload.trusted_device_token = storedTrustedToken;
}
const response = await apiService.post("/auth/login", payload);
const { access_token, refresh_token, user: userData } = response.data;
const responseData = response.data;
// 2FA: backend cere verificare cod pe email
if (responseData.requires_2fa === true) {
otpEmail.value = responseData.email;
otpMaskedEmail.value = responseData.masked_email;
// Salvăm server_id pending — verify2FA() îl va folosi în loc de selectedServerId
pendingServerId.value = credentials.server_id || null;
loginStep.value = "2fa";
_startResendCountdown();
return { success: true, requires_2fa: true, masked_email: responseData.masked_email };
}
// Flow normal — extragem tokens
const { access_token, refresh_token, user: userData } = responseData;
// IMPORTANT: Update selectedServerId BEFORE user.value to ensure
// the companies store watch uses the correct server ID for localStorage key
@@ -273,6 +332,13 @@ export function createAuthStore(apiService, options = {}) {
availableServers.value = [];
// Note: Don't clear selectedServerId - keep it for next login pre-selection
// Reset 2FA state
otpEmail.value = "";
otpMaskedEmail.value = "";
is2FALoading.value = false;
pendingServerId.value = null;
_stopResendCountdown();
localStorage.removeItem(STORAGE_KEYS.ACCESS_TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER);
@@ -342,6 +408,13 @@ export function createAuthStore(apiService, options = {}) {
availableServers.value = [];
error.value = null;
// Keep selectedServerId from localStorage for pre-selection
// Reset 2FA state
otpEmail.value = "";
otpMaskedEmail.value = "";
is2FALoading.value = false;
pendingServerId.value = null;
_stopResendCountdown();
};
/**
@@ -370,6 +443,188 @@ export function createAuthStore(apiService, options = {}) {
preselectedServerId.value = serverId;
};
// -------------------------------------------------------------------------
// 2FA HELPERS — countdown timer
// -------------------------------------------------------------------------
const _startResendCountdown = () => {
resendCountdown.value = 60;
_stopResendCountdown(); // Curăță orice timer anterior
_resendTimer = setInterval(() => {
if (resendCountdown.value > 0) {
resendCountdown.value--;
} else {
_stopResendCountdown();
}
}, 1000);
};
const _stopResendCountdown = () => {
if (_resendTimer) {
clearInterval(_resendTimer);
_resendTimer = null;
}
resendCountdown.value = 0;
};
// -------------------------------------------------------------------------
// 2FA ACTIONS
// -------------------------------------------------------------------------
/**
* Verifică codul OTP introdus de utilizator (pasul 2 al 2FA).
* Dacă codul este valid, stochează JWT tokens și completează autentificarea.
*
* @param {Object} params - {code: "483921", trustDevice?: false}
* @returns {Promise<{success: boolean, error?: string}>}
*/
const verify2FA = async ({ code, trustDevice = false }) => {
is2FALoading.value = true;
error.value = null;
try {
const payload = {
code: code.trim(),
email: otpEmail.value,
trust_device: trustDevice,
};
// Adăugăm server_id — folosim pendingServerId dacă există (server switch context)
const effectiveServerId = pendingServerId.value || selectedServerId.value;
if (effectiveServerId) {
payload.server_id = effectiveServerId;
}
const response = await apiService.post("/auth/verify-2fa-code", payload);
const { access_token, refresh_token, user: userData, trusted_device_token } = response.data;
// IMPORTANT: Commit pendingServerId -> selectedServerId BEFORE user (pattern existent)
if (effectiveServerId) {
selectedServerId.value = effectiveServerId;
localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, effectiveServerId);
}
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token);
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
// Salvează tokenul de trusted device dacă utilizatorul a bifat "Ține minte"
if (trusted_device_token && userData?.username) {
const tdKey = _getTrustedDeviceKey(userData.username, effectiveServerId);
localStorage.setItem(tdKey, trusted_device_token);
}
// Curăță starea 2FA
otpEmail.value = "";
otpMaskedEmail.value = "";
pendingServerId.value = null;
_stopResendCountdown();
loginStep.value = "complete";
const backupCodes = response.data.backup_codes;
return { success: true, backup_codes: backupCodes };
} catch (err) {
error.value = err.response?.data?.detail || "Verificare eșuată. Încercați din nou.";
return { success: false, error: error.value };
} finally {
is2FALoading.value = false;
}
};
/**
* Retrimite codul OTP pe email (butonul "Retrimite codul").
* Resetează countdown-ul de 60 secunde.
*
* @returns {Promise<{success: boolean, error?: string}>}
*/
const resendOTP = async () => {
// Nu permite retrimis dacă countdown-ul nu a ajuns la 0
if (resendCountdown.value > 0) {
return { success: false, error: `Așteptați ${resendCountdown.value} secunde` };
}
error.value = null;
try {
const payload = { email: otpEmail.value };
const effectiveServerId = pendingServerId.value || selectedServerId.value;
if (effectiveServerId) {
payload.server_id = effectiveServerId;
}
await apiService.post("/auth/resend-2fa-code", payload);
// Resetăm countdown-ul
_startResendCountdown();
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Nu s-a putut retrimite codul.";
return { success: false, error: error.value };
}
};
/**
* Verifică un cod de backup (fallback când emailul nu sosește).
* @param {Object} params - {code: "AB3K9PQR", serverId?: "romfast"}
* @returns {Promise<{success: boolean, error?: string}>}
*/
const verifyBackupCode = async ({ code, serverId, trustDevice = false }) => {
is2FALoading.value = true;
error.value = null;
try {
const payload = {
code: code.trim().toUpperCase(),
email: otpEmail.value,
trust_device: trustDevice,
};
const effectiveServerId = serverId || pendingServerId.value || selectedServerId.value;
if (effectiveServerId) payload.server_id = effectiveServerId;
const response = await apiService.post("/auth/verify-backup-code", payload);
const { access_token, refresh_token, user: userData, trusted_device_token } = response.data;
if (effectiveServerId) {
selectedServerId.value = effectiveServerId;
localStorage.setItem(STORAGE_KEYS.LAST_SERVER_ID, effectiveServerId);
}
accessToken.value = access_token;
refreshToken.value = refresh_token;
user.value = userData;
localStorage.setItem(STORAGE_KEYS.ACCESS_TOKEN, access_token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refresh_token);
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userData));
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
if (trusted_device_token && userData?.username) {
const tdKey = _getTrustedDeviceKey(userData.username, effectiveServerId);
localStorage.setItem(tdKey, trusted_device_token);
}
otpEmail.value = "";
otpMaskedEmail.value = "";
pendingServerId.value = null;
_stopResendCountdown();
loginStep.value = "complete";
return { success: true };
} catch (err) {
error.value = err.response?.data?.detail || "Cod de recuperare invalid.";
return { success: false, error: error.value };
} finally {
is2FALoading.value = false;
}
};
/**
* Switch to a different server without full logout (US-007)
* Re-authenticates the current user on the new server.
@@ -425,6 +680,13 @@ export function createAuthStore(apiService, options = {}) {
isAuthenticating, // Flag pentru a preveni 401 redirect în timpul login/server-switch
preselectedServerId, // US-004: URL bookmark pre-selection
// State - 2FA
otpEmail,
otpMaskedEmail,
is2FALoading,
resendCountdown,
pendingServerId,
// Getters
isAuthenticated,
currentUser,
@@ -454,6 +716,11 @@ export function createAuthStore(apiService, options = {}) {
// Actions - Server switch (US-007)
switchServer,
// Actions - 2FA
verify2FA,
resendOTP,
verifyBackupCode,
};
});
}