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
+