diff --git a/.gitignore b/.gitignore index 16d96fe..1cf6b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/backend/data/auth/trusted_devices.json b/backend/data/auth/trusted_devices.json new file mode 100644 index 0000000..68c0878 --- /dev/null +++ b/backend/data/auth/trusted_devices.json @@ -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" + } +} \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 51e5f3c..c856f0e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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", diff --git a/backend/modules/telegram/db/database.py b/backend/modules/telegram/db/database.py index 9fd7a35..1b1277a 100644 --- a/backend/modules/telegram/db/database.py +++ b/backend/modules/telegram/db/database.py @@ -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', -] diff --git a/shared/auth/backup_codes_service.py b/shared/auth/backup_codes_service.py new file mode 100644 index 0000000..a83bd45 --- /dev/null +++ b/shared/auth/backup_codes_service.py @@ -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() diff --git a/shared/auth/models.py b/shared/auth/models.py index f47333a..09454eb 100644 --- a/shared/auth/models.py +++ b/shared/auth/models.py @@ -56,6 +56,10 @@ class LoginRequest(BaseModel): description="ID-ul serverului Oracle pentru autentificare (opțional în modul single-server)", example="romfast" ) + trusted_device_token: Optional[str] = Field( + default=None, + description="Token de trusted device din localStorage (pentru skip 2FA)" + ) @validator('username') def username_alphanumeric(cls, v): @@ -83,17 +87,25 @@ class TokenResponse(BaseModel): """Model pentru răspunsul de autentificare cu token-uri""" access_token: str = Field(description="JWT access token") refresh_token: Optional[str] = Field( - default=None, + default=None, description="JWT refresh token (opțional)" ) token_type: str = Field( - default="bearer", + default="bearer", description="Tipul token-ului (întotdeauna 'bearer')" ) expires_in: int = Field( description="Timpul de expirare al access token-ului în secunde" ) user: 'CurrentUser' = Field(description="Informațiile utilizatorului autentificat") + trusted_device_token: Optional[str] = Field( + default=None, + description="Token de stocat în localStorage (prezent doar dacă trust_device=True)" + ) + backup_codes: Optional[list[str]] = Field( + default=None, + description="Coduri de backup generate la primul 2FA reușit (afișați utilizatorului o singură dată!)" + ) class RefreshTokenRequest(BaseModel): @@ -340,5 +352,70 @@ class CheckEmailResponse(BaseModel): ) +class VerifyBackupCodeRequest(BaseModel): + """Request pentru POST /auth/verify-backup-code""" + code: str = Field(..., min_length=6, max_length=12, description="Codul de recuperare (ex: AB3K9PQR)") + email: str = Field(..., description="Email sau username") + server_id: Optional[str] = Field(default=None, description="ID server Oracle") + trust_device: bool = Field(default=False, description="Ține minte dispozitivul 30 de zile") + + # Update la forward references pentru TokenResponse -TokenResponse.model_rebuild() \ No newline at end of file +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)" + ) \ No newline at end of file diff --git a/shared/auth/otp_service.py b/shared/auth/otp_service.py new file mode 100644 index 0000000..b8dcc4e --- /dev/null +++ b/shared/auth/otp_service.py @@ -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 diff --git a/shared/auth/routes.py b/shared/auth/routes.py index 5b62501..b2af2f3 100644 --- a/shared/auth/routes.py +++ b/shared/auth/routes.py @@ -14,7 +14,7 @@ Endpoints disponibile: """ import logging -from typing import List, Optional +from typing import Any, Dict, List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, Request, Response @@ -24,7 +24,9 @@ from .models import ( LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest, CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse, AuthError, AuthStats, CheckEmailRequest, CheckEmailResponse, ServerInfo, - CheckIdentityRequest, CheckIdentityResponse + CheckIdentityRequest, CheckIdentityResponse, + LoginRequires2FAResponse, Verify2FARequest, Resend2FARequest, + VerifyBackupCodeRequest, ) from .auth_service import auth_service, AuthenticationError from .jwt_handler import jwt_handler @@ -33,6 +35,16 @@ from .dependencies import ( security_required, security_optional ) from .middleware import default_rate_limiter, RateLimiter +from .otp_service import ( + create_otp, verify_otp, get_otp_entry, _mask_email +) +from .trusted_device_service import ( + create_trusted_device_token, verify_trusted_device_token +) +from .backup_codes_service import ( + generate_backup_codes, verify_backup_code, + has_backup_codes, get_remaining_count +) logger = logging.getLogger(__name__) @@ -58,6 +70,80 @@ def create_auth_router( # Rate limiter pentru check-identity/check-email: 5 requests per minut per IP check_identity_rate_limiter = RateLimiter(max_requests=5, time_window=60) + # Rate limitere pentru 2FA + verify_2fa_rate_limiter = RateLimiter(max_requests=10, time_window=300) # 10 req / 5 min per IP + resend_2fa_rate_limiter = RateLimiter(max_requests=3, time_window=600) # 3 req / 10 min per IP + + # ------------------------------------------------------------------------- + # HELPER FUNCTIONS (private, în scope-ul create_auth_router) + # ------------------------------------------------------------------------- + + async def _get_email_for_username(username: str, server_id: Optional[str]) -> Optional[str]: + """ + Caută emailul unui utilizator în Oracle după username. + Returnează emailul lowercase sau None dacă nu există / nu e setat. + """ + try: + from shared.database.oracle_pool import oracle_pool + async with oracle_pool.get_connection(server_id) as connection: + with connection.cursor() as cursor: + cursor.execute(""" + SELECT LOWER(TRIM(EMAIL)) + FROM CONTAFIN_ORACLE.UTILIZATORI + WHERE UPPER(UTILIZATOR) = :username + AND INACTIV = 0 + AND STERS = 0 + AND EMAIL IS NOT NULL + AND TRIM(EMAIL) IS NOT NULL + """, {"username": username.upper()}) + row = cursor.fetchone() + if row and row[0] and "@" in row[0]: + return row[0].strip() + return None + except Exception as e: + logger.error(f"[2FA] Error getting email for username '{username}': {e}") + return None + + async def _create_token_response_for_user( + username: str, + server_id: Optional[str], + response: Response + ) -> TokenResponse: + """ + Creează TokenResponse complet pentru un utilizator deja verificat. + Folosit după verificarea OTP (pasul 2 al 2FA) — fără re-verificare parolă. + """ + companies = await auth_service.get_user_companies(username, server_id) + permissions = await auth_service.get_user_permissions( + username, companies[0] if companies else "", server_id + ) + + jwt_tokens = jwt_handler.create_token_response( + username=username, + companies=companies, + user_id=None, + permissions=permissions, + server_id=server_id, + ) + + current_user = CurrentUser( + username=username, + user_id=None, + companies=companies, + permissions=permissions, + ) + + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + + return TokenResponse( + access_token=jwt_tokens.access_token, + refresh_token=jwt_tokens.refresh_token, + token_type=jwt_tokens.token_type, + expires_in=jwt_tokens.expires_in, + user=current_user, + ) + @router.post("/check-identity", response_model=CheckIdentityResponse, status_code=status.HTTP_200_OK) async def check_identity( check_data: CheckIdentityRequest, @@ -223,103 +309,346 @@ def create_auth_router( detail="Error checking email" ) - @router.post("/login", response_model=TokenResponse, status_code=status.HTTP_200_OK) + @router.post("/login", status_code=status.HTTP_200_OK) async def login( login_data: LoginRequest, request: Request, - response: Response - ) -> TokenResponse: + response: Response, + ): """ - Autentifică un utilizator și returnează token-urile JWT + Autentifică un utilizator. - Acest endpoint: - - Validează credențialele utilizatorului în Oracle - - Obține firmele la care utilizatorul are acces - - Generează access și refresh token-uri JWT - - Aplică rate limiting pentru securitate - - Suportă modul multi-server (server_id opțional) + Flow cu 2FA (utilizator are email în Oracle): + 1. Verifică credențialele în Oracle + 2. Trimite cod OTP pe email + 3. Returnează {requires_2fa: true, masked_email, email} + → Frontend afișează câmpul de cod + → Userul introduce codul → POST /auth/verify-2fa-code → JWT - Args: - login_data: Datele de autentificare (username, password, server_id opțional) - request: Request-ul HTTP (pentru rate limiting) - response: Response-ul HTTP (pentru header-e) - - Returns: - Token-urile JWT și informațiile utilizatorului + Fallback fără 2FA (utilizator fără email): + - Returnează TokenResponse direct (comportament anterior) Raises: - HTTPException 400: Pentru server_id invalid - HTTPException 401: Pentru credențiale invalide - HTTPException 500: Pentru erori de sistem + HTTPException 400: server_id invalid + HTTPException 401: credențiale invalide + HTTPException 429: rate limit OTP depășit + HTTPException 503: email service indisponibil + HTTPException 500: eroare internă """ try: - # Log tentativa de autentificare client_ip = request.client.host if request.client else "unknown" server_info = f" on server {login_data.server_id}" if login_data.server_id else "" - logger.info(f"Login attempt for user {login_data.username}{server_info} from IP {client_ip}") + logger.info(f"[LOGIN] Attempt for '{login_data.username}'{server_info} from IP {client_ip}") - # Validare server_id dacă specificat (multi-server mode) + # Validare server_id (cod existent, păstrat intact) if login_data.server_id: from backend.config import settings from shared.database.oracle_pool import oracle_pool - # Verifică dacă serverul există în configurație server_config = settings.get_oracle_server(login_data.server_id) if not server_config: - logger.warning(f"Invalid server_id '{login_data.server_id}' in login request") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid server_id: '{login_data.server_id}'. Server not found in configuration." ) - - # Verifică dacă serverul este înregistrat în pool if not oracle_pool.is_server_registered(login_data.server_id): - logger.warning(f"Server '{login_data.server_id}' not registered in pool") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Server '{login_data.server_id}' is not available." ) - # Autentifică și creează token-urile - success, token_response, error_message = await auth_service.authenticate_and_create_tokens( - login_data.username, - login_data.password, - login_data.server_id + # Pas 1: Rezolvă email → username dacă input conține '@' + actual_username = login_data.username + input_email: Optional[str] = None + + if "@" in login_data.username: + input_email = login_data.username.lower().strip() + resolved = await auth_service.get_username_by_email(input_email, login_data.server_id) + if not resolved: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid username or password" + ) + actual_username = resolved + logger.info(f"[LOGIN] Email '{input_email}' resolved to username '{actual_username}'") + + # Pas 2: Verifică credențialele Oracle + is_valid = await auth_service.verify_user_credentials( + actual_username, login_data.password, login_data.server_id ) - - if not success: - logger.warning(f"Failed login attempt for user {login_data.username}{server_info}: {error_message}") + if not is_valid: + logger.warning(f"[LOGIN] Failed credentials for '{actual_username}'{server_info}") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail=error_message or "Authentication failed" + detail="Invalid username or password" ) - # token_response.user este deja populat corect de auth_service.authenticate_and_create_tokens - # cu username-ul Oracle rezolvat (nu email-ul) și lista de firme + # Pas 3: Caută emailul utilizatorului (dacă nu îl știm deja din input) + user_email = input_email + if not user_email: + user_email = await _get_email_for_username(actual_username, login_data.server_id) - # Header-e de securitate - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-Frame-Options"] = "DENY" + # Pas 2.5: Verificare trusted device — skip 2FA dacă tokenul e valid + if login_data.trusted_device_token and user_email: + is_trusted = await verify_trusted_device_token( + login_data.trusted_device_token, + actual_username, + login_data.server_id, + ) + if is_trusted: + logger.info( + f"[TRUSTED_DEVICE] Device known for '{actual_username}' — skip 2FA" + ) + return await _create_token_response_for_user( + actual_username, login_data.server_id, response + ) + # Invalid/expirat → fail silently, continuă cu 2FA normal + + # Pas 4: Dacă are email → trimitem OTP (2FA) + if user_email: + code = await create_otp(user_email, actual_username, login_data.server_id) + + if code is None: + # Rate limited + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Prea multe cereri de cod. Așteptați 10 minute și încercați din nou." + ) + + # Trimitem emailul + try: + from backend.modules.telegram.utils.email_service import get_email_service + email_service = get_email_service() + email_sent = await email_service.send_auth_code(user_email, code, actual_username) + + if not email_sent: + logger.error(f"[2FA] Failed to send OTP email to {user_email[:3]}***") + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Nu s-a putut trimite codul de verificare. Încercați din nou." + ) + + logger.info(f"[2FA] OTP sent to {user_email[:3]}*** for user '{actual_username}'") + + except ImportError: + # Email service nu e disponibil — fallback la login direct + logger.warning("[2FA] Email service not available, falling back to direct login") + user_email = None + + # Pas 5: Dacă 2FA activ → returnăm cerere de cod + if user_email: + return LoginRequires2FAResponse( + requires_2fa=True, + masked_email=_mask_email(user_email), + email=user_email, + ) + + # Pas 6: Fallback — fără email → JWT direct (comportament anterior) + logger.info(f"[LOGIN] No email for '{actual_username}', issuing JWT directly (no 2FA)") + return await _create_token_response_for_user(actual_username, login_data.server_id, response) - logger.info(f"Successful login for user {login_data.username}{server_info}") - return token_response - except HTTPException: - # Re-raise HTTP exceptions as-is (e.g., 401 for invalid credentials) raise except AuthenticationError as e: - logger.error(f"Authentication error for user {login_data.username}: {str(e)}") - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(e) - ) + logger.error(f"[LOGIN] Authentication error for '{login_data.username}': {e}") + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) except Exception as e: - logger.error(f"Unexpected error during login for user {login_data.username}: {str(e)}") + logger.error(f"[LOGIN] Unexpected error for '{login_data.username}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Internal authentication error" ) + + @router.post("/verify-2fa-code", response_model=TokenResponse, status_code=status.HTTP_200_OK) + async def verify_2fa_code( + verify_data: Verify2FARequest, + request: Request, + response: Response, + ) -> TokenResponse: + """ + Verifică codul OTP și emite JWT tokens (pasul 2 al 2FA). + + Args: + verify_data: {code: "483921", email: "marius@romfast.ro", server_id: "romfast"} + + Returns: + TokenResponse cu JWT tokens + + Raises: + HTTPException 400: cod invalid, expirat sau prea multe încercări + HTTPException 429: rate limit depășit (IP) + HTTPException 500: eroare internă + """ + client_ip = request.client.host if request.client else "unknown" + + # Rate limiting per IP + if not verify_2fa_rate_limiter.is_allowed(client_ip): + reset_time = verify_2fa_rate_limiter.get_reset_time(client_ip) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Prea multe cereri. Încercați din nou mai târziu.", + headers={ + "X-RateLimit-Limit": "10", + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(reset_time), + }, + ) + + result = verify_otp(verify_data.email, verify_data.code) + + if not result["success"]: + error_code = result.get("error_code", "OTP_ERROR") + http_status = ( + status.HTTP_429_TOO_MANY_REQUESTS + if error_code == "OTP_MAX_ATTEMPTS" + else status.HTTP_400_BAD_REQUEST + ) + raise HTTPException(status_code=http_status, detail=result["error"]) + + # OTP valid — creăm JWT + username = result["username"] + server_id = result.get("server_id") or verify_data.server_id + + logger.info(f"[2FA] OTP verified OK for '{username}' from IP {client_ip}") + + token_response = await _create_token_response_for_user(username, server_id, response) + + # Dacă utilizatorul a bifat "Ține minte acest dispozitiv" + if verify_data.trust_device: + trusted_token = await create_trusted_device_token(username, server_id) + token_response.trusted_device_token = trusted_token + logger.info(f"[TRUSTED_DEVICE] Token generated for '{username}' (server={server_id})") + + # Generăm backup codes dacă nu există deja + if not await has_backup_codes(username, server_id): + codes = await generate_backup_codes(username, server_id) + token_response.backup_codes = codes + logger.info(f"[BACKUP_CODES] Generated {len(codes)} backup codes for '{username}'") + + return token_response + + @router.post("/resend-2fa-code", status_code=status.HTTP_200_OK) + async def resend_2fa_code( + resend_data: Resend2FARequest, + request: Request, + ) -> Dict[str, Any]: + """ + Retrimite codul OTP pe email (butonul "Retrimite codul" din frontend). + + Verifică că există o sesiune OTP activă pentru email înainte de a retrimite. + + Returns: + {"message": "Codul a fost retrimis", "masked_email": "m***@romfast.ro"} + + Raises: + HTTPException 404: sesiunea OTP nu mai există (expirată sau deja verificată) + HTTPException 429: rate limit depășit + HTTPException 503: email service indisponibil + """ + client_ip = request.client.host if request.client else "unknown" + + # Rate limiting per IP + if not resend_2fa_rate_limiter.is_allowed(client_ip): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Prea multe cereri de retrimis. Așteptați și încercați din nou.", + ) + + email = resend_data.email.lower().strip() + + # Verificăm că există sesiune OTP activă pentru email + entry = get_otp_entry(email) + if not entry: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Sesiunea de autentificare a expirat. Reîncepeti procesul de login.", + ) + + username = entry["username"] + server_id = entry.get("server_id") or resend_data.server_id + + # Creăm cod nou (cu rate limiting) + code = await create_otp(email, username, server_id) + if code is None: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Prea multe cereri de cod. Așteptați 10 minute și încercați din nou.", + ) + + # Trimitem emailul + try: + from backend.modules.telegram.utils.email_service import get_email_service + email_service = get_email_service() + sent = await email_service.send_auth_code(email, code, username) + + if not sent: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Nu s-a putut retrimite codul. Încercați din nou.", + ) + + logger.info(f"[2FA] OTP resent to {email[:3]}*** for user '{username}'") + return { + "message": "Codul a fost retrimis", + "masked_email": _mask_email(email), + } + + except ImportError: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Serviciul de email nu este disponibil.", + ) + # ------------------------------------------------------------------------- + # BACKUP CODES ENDPOINT + # ------------------------------------------------------------------------- + backup_code_rate_limiter = RateLimiter(max_requests=5, time_window=300) # 5 req / 5 min + + @router.post("/verify-backup-code", response_model=TokenResponse, status_code=status.HTTP_200_OK) + async def verify_backup_code_endpoint( + verify_data: VerifyBackupCodeRequest, + request: Request, + response: Response, + ) -> TokenResponse: + """ + Verifică un cod de recuperare (backup code) și emite JWT tokens. + + Fallback pentru cazul când emailul OTP nu sosește. + + Raises: + HTTPException 400: cod invalid sau deja folosit + HTTPException 429: rate limit depășit + """ + client_ip = request.client.host if request.client else "unknown" + if not backup_code_rate_limiter.is_allowed(client_ip): + raise HTTPException( + status_code=429, + detail="Prea multe cereri. Încercați din nou mai târziu." + ) + + email = verify_data.email.lower().strip() + # Rezolvă username din email (dacă e email) sau e direct username + if "@" in email: + actual_username = await auth_service.get_username_by_email(email, verify_data.server_id) + if not actual_username: + raise HTTPException(status_code=400, detail="Email invalid") + else: + actual_username = email.upper() + + is_valid = await verify_backup_code(actual_username, verify_data.server_id, verify_data.code) + if not is_valid: + raise HTTPException(status_code=400, detail="Cod de recuperare invalid sau deja folosit") + + logger.info(f"[BACKUP_CODE] Used backup code for '{actual_username}' from {client_ip}") + token_response = await _create_token_response_for_user(actual_username, verify_data.server_id, response) + + if verify_data.trust_device: + trusted_token = await create_trusted_device_token(actual_username, verify_data.server_id) + token_response.trusted_device_token = trusted_token + logger.info(f"[TRUSTED_DEVICE] Token generated via backup code for '{actual_username}'") + + return token_response + @router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK) async def refresh_token(refresh_data: RefreshTokenRequest) -> TokenResponse: """ diff --git a/shared/auth/trusted_device_service.py b/shared/auth/trusted_device_service.py new file mode 100644 index 0000000..13d0d7d --- /dev/null +++ b/shared/auth/trusted_device_service.py @@ -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 diff --git a/shared/database/app_db.py b/shared/database/app_db.py new file mode 100644 index 0000000..9084418 --- /dev/null +++ b/shared/database/app_db.py @@ -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") diff --git a/src/App.vue b/src/App.vue index bdf9e56..5a361a9 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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') diff --git a/src/assets/css/vendor/primevue-overrides.css b/src/assets/css/vendor/primevue-overrides.css index 994f05d..23eb599 100644 --- a/src/assets/css/vendor/primevue-overrides.css +++ b/src/assets/css/vendor/primevue-overrides.css @@ -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 */ diff --git a/src/modules/data-entry/views/receipts/ReceiptsListView.vue b/src/modules/data-entry/views/receipts/ReceiptsListView.vue index 17a7365..e2b91de 100644 --- a/src/modules/data-entry/views/receipts/ReceiptsListView.vue +++ b/src/modules/data-entry/views/receipts/ReceiptsListView.vue @@ -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) { diff --git a/src/modules/reports/views/BankCashRegisterView.vue b/src/modules/reports/views/BankCashRegisterView.vue index 38b17ce..988af87 100644 --- a/src/modules/reports/views/BankCashRegisterView.vue +++ b/src/modules/reports/views/BankCashRegisterView.vue @@ -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) { diff --git a/src/modules/reports/views/BankView.vue b/src/modules/reports/views/BankView.vue index cc224f1..93d86aa 100644 --- a/src/modules/reports/views/BankView.vue +++ b/src/modules/reports/views/BankView.vue @@ -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) { diff --git a/src/modules/reports/views/CashView.vue b/src/modules/reports/views/CashView.vue index 0c547d1..99c615f 100644 --- a/src/modules/reports/views/CashView.vue +++ b/src/modules/reports/views/CashView.vue @@ -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) { diff --git a/src/modules/reports/views/DashboardView.vue b/src/modules/reports/views/DashboardView.vue index fce3d64..8a6ffe4 100644 --- a/src/modules/reports/views/DashboardView.vue +++ b/src/modules/reports/views/DashboardView.vue @@ -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) { diff --git a/src/modules/reports/views/DetailedInvoicesView.vue b/src/modules/reports/views/DetailedInvoicesView.vue index 7d13251..d9e059e 100644 --- a/src/modules/reports/views/DetailedInvoicesView.vue +++ b/src/modules/reports/views/DetailedInvoicesView.vue @@ -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) { diff --git a/src/modules/reports/views/InvoicesView.vue b/src/modules/reports/views/InvoicesView.vue index f6af5c9..fa815a5 100644 --- a/src/modules/reports/views/InvoicesView.vue +++ b/src/modules/reports/views/InvoicesView.vue @@ -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) { diff --git a/src/modules/reports/views/MaturityAnalysisView.vue b/src/modules/reports/views/MaturityAnalysisView.vue index 2bb8220..4a77b96 100644 --- a/src/modules/reports/views/MaturityAnalysisView.vue +++ b/src/modules/reports/views/MaturityAnalysisView.vue @@ -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) { diff --git a/src/modules/reports/views/SettingsHubView.vue b/src/modules/reports/views/SettingsHubView.vue index dda75d1..fe64eac 100644 --- a/src/modules/reports/views/SettingsHubView.vue +++ b/src/modules/reports/views/SettingsHubView.vue @@ -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 diff --git a/src/modules/reports/views/TrialBalanceView.vue b/src/modules/reports/views/TrialBalanceView.vue index c2bf8b7..3c02ecb 100644 --- a/src/modules/reports/views/TrialBalanceView.vue +++ b/src/modules/reports/views/TrialBalanceView.vue @@ -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) { diff --git a/src/shared/components/LoginView.vue b/src/shared/components/LoginView.vue index 11cdb13..a7563af 100644 --- a/src/shared/components/LoginView.vue +++ b/src/shared/components/LoginView.vue @@ -17,7 +17,81 @@

Se încarcă...

- + +
+
+ +

+ Cod trimis la {{ authStore.otpMaskedEmail }} +

+

Introduceți codul email sau un cod de recuperare

+
+ +
+ + {{ otpError }} +
+ + +
+ + {{ authStore.error }} +
+ + +
+ +
+ + + +
+

+ Important: Salvați aceste coduri în siguranță! Ele se afișează o singură dată. + Folosiți-le dacă nu primiți codul pe email. +

+
+ {{ code }} +
+
+ +
@@ -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; +} diff --git a/src/shared/components/layout/AppHeader.vue b/src/shared/components/layout/AppHeader.vue index d36e38f..76a74d1 100644 --- a/src/shared/components/layout/AppHeader.vue +++ b/src/shared/components/layout/AppHeader.vue @@ -70,7 +70,7 @@ - +
-
- -
+ + + + +
@@ -101,18 +157,36 @@
@@ -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, }; }, }; + + diff --git a/src/shared/components/mobile/MobileDrawerMenu.vue b/src/shared/components/mobile/MobileDrawerMenu.vue index d2330de..9f00f47 100644 --- a/src/shared/components/mobile/MobileDrawerMenu.vue +++ b/src/shared/components/mobile/MobileDrawerMenu.vue @@ -249,7 +249,7 @@ - +
- + + + + +
@@ -277,18 +310,36 @@
@@ -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; diff --git a/src/shared/stores/auth.js b/src/shared/stores/auth.js index 3b0b939..52e7f8a 100644 --- a/src/shared/stores/auth.js +++ b/src/shared/stores/auth.js @@ -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, }; }); }