feat(auth): add 2FA with OTP, backup codes and trusted devices
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||
|
||||
|
||||
20
backend/data/auth/trusted_devices.json
Normal file
20
backend/data/auth/trusted_devices.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"47002b97f50e2efa46e269ca5361d10fe1e0731b80d1f74b2be731d584ec01b0": {
|
||||
"username": "MARIUS M",
|
||||
"server_id": "romfast",
|
||||
"expires_at": "2026-03-26T13:13:05.664076",
|
||||
"created_at": "2026-02-24T13:13:05.664076"
|
||||
},
|
||||
"00d05404386fa39ef3dfb85a283e4514954f8823f48e72032ce323918fd01982": {
|
||||
"username": "MARIUS M",
|
||||
"server_id": "romfast",
|
||||
"expires_at": "2026-03-26T13:55:14.773910",
|
||||
"created_at": "2026-02-24T13:55:14.773910"
|
||||
},
|
||||
"7b3a0379d6f199d6ee808a7114c328c05e9a462358c89f54fce63ca2bb01298c": {
|
||||
"username": "MARIUS M",
|
||||
"server_id": "romfast",
|
||||
"expires_at": "2026-03-26T14:01:30.254613",
|
||||
"created_at": "2026-02-24T14:01:30.254613"
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
114
shared/auth/backup_codes_service.py
Normal file
114
shared/auth/backup_codes_service.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Backup Codes Service - fallback 2FA când emailul nu sosește."""
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
BACKUP_CODE_COUNT = 10
|
||||
BACKUP_CODE_EXPIRE_DAYS = 365 # 1 an
|
||||
|
||||
# Alphabet for backup codes: uppercase letters + digits (no ambiguous chars like 0/O, 1/I/L)
|
||||
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
return hashlib.sha256(code.upper().strip().encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
async def generate_backup_codes(username: str, server_id: Optional[str]) -> list[str]:
|
||||
"""Generează 10 coduri de backup, stochează hash-uri în DB, returnează codurile plain."""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
expires_at = (datetime.now() + timedelta(days=BACKUP_CODE_EXPIRE_DAYS)).isoformat()
|
||||
now = datetime.now().isoformat()
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
# Șterge codurile vechi ale userului
|
||||
await db.execute(
|
||||
"DELETE FROM backup_codes WHERE UPPER(username) = ? AND (server_id = ? OR (server_id IS NULL AND ? IS NULL))",
|
||||
(username_upper, server_id, server_id)
|
||||
)
|
||||
|
||||
# Generează 10 coduri noi (8 chars uppercase alfanumeric)
|
||||
codes: list[str] = []
|
||||
for _ in range(BACKUP_CODE_COUNT):
|
||||
code = "".join(secrets.choice(_ALPHABET) for _ in range(8))
|
||||
codes.append(code)
|
||||
code_hash = _hash_code(code)
|
||||
await db.execute(
|
||||
"""INSERT INTO backup_codes (username, server_id, code_hash, used, created_at)
|
||||
VALUES (?, ?, ?, 0, ?)""",
|
||||
(username_upper, server_id, code_hash, now)
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
logger.info(f"[BACKUP_CODES] Generated {len(codes)} codes for '{username}' (server={server_id})")
|
||||
return codes
|
||||
|
||||
|
||||
async def verify_backup_code(username: str, server_id: Optional[str], code: str) -> bool:
|
||||
"""Verifică și marchează codul ca folosit. False dacă invalid/deja folosit."""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
code_hash = _hash_code(code)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"""SELECT id FROM backup_codes
|
||||
WHERE UPPER(username) = ? AND code_hash = ? AND used = 0
|
||||
AND (server_id = ? OR (server_id IS NULL AND ? IS NULL))""",
|
||||
(username_upper, code_hash, server_id, server_id)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.warning(f"[BACKUP_CODES] Invalid or used code for '{username}'")
|
||||
return False
|
||||
|
||||
# Marchează ca folosit
|
||||
await db.execute(
|
||||
"UPDATE backup_codes SET used = 1, used_at = ? WHERE id = ?",
|
||||
(datetime.now().isoformat(), row["id"])
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
logger.info(f"[BACKUP_CODES] Code used for '{username}' (server={server_id})")
|
||||
return True
|
||||
|
||||
|
||||
async def has_backup_codes(username: str, server_id: Optional[str]) -> bool:
|
||||
"""Verifică dacă userul are coduri de backup active (nefolosite)."""
|
||||
count = await get_remaining_count(username, server_id)
|
||||
return count > 0
|
||||
|
||||
|
||||
async def get_remaining_count(username: str, server_id: Optional[str]) -> int:
|
||||
"""Numără codurile nefolosite."""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"""SELECT COUNT(*) as cnt FROM backup_codes
|
||||
WHERE UPPER(username) = ? AND used = 0
|
||||
AND (server_id = ? OR (server_id IS NULL AND ? IS NULL))""",
|
||||
(username_upper, server_id, server_id)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
finally:
|
||||
await db.close()
|
||||
@@ -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()
|
||||
TokenResponse.model_rebuild()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# MODELE 2FA WEB LOGIN
|
||||
# ============================================================================
|
||||
|
||||
class LoginRequires2FAResponse(BaseModel):
|
||||
"""
|
||||
Răspuns returnat de POST /auth/login când 2FA este necesar.
|
||||
|
||||
Frontend-ul detectează câmpul requires_2fa=True și afișează pasul de cod.
|
||||
Email-ul complet se trimite la /auth/verify-2fa-code.
|
||||
"""
|
||||
requires_2fa: bool = Field(
|
||||
default=True,
|
||||
description="Întotdeauna True când se solicită 2FA"
|
||||
)
|
||||
masked_email: str = Field(
|
||||
description="Emailul mascat pentru afișare (ex: m***@romfast.ro)"
|
||||
)
|
||||
email: str = Field(
|
||||
description="Emailul complet — de trimis la /auth/verify-2fa-code"
|
||||
)
|
||||
|
||||
|
||||
class Verify2FARequest(BaseModel):
|
||||
"""Request pentru POST /auth/verify-2fa-code"""
|
||||
code: str = Field(
|
||||
...,
|
||||
min_length=6,
|
||||
max_length=6,
|
||||
description="Codul OTP de 6 cifre primit pe email"
|
||||
)
|
||||
email: str = Field(
|
||||
...,
|
||||
description="Emailul primit în răspunsul de la /auth/login (câmpul 'email')"
|
||||
)
|
||||
server_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID-ul serverului Oracle (pentru multi-server mode)"
|
||||
)
|
||||
trust_device: bool = Field(
|
||||
default=False,
|
||||
description="Dacă utilizatorul vrea să fie ținut minte pe acest dispozitiv"
|
||||
)
|
||||
|
||||
|
||||
class Resend2FARequest(BaseModel):
|
||||
"""Request pentru POST /auth/resend-2fa-code"""
|
||||
email: str = Field(
|
||||
...,
|
||||
description="Emailul unde se retrimite codul OTP"
|
||||
)
|
||||
server_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="ID-ul serverului Oracle (pentru multi-server mode)"
|
||||
)
|
||||
281
shared/auth/otp_service.py
Normal file
281
shared/auth/otp_service.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""
|
||||
OTP (One-Time Password) Service pentru Web 2FA
|
||||
|
||||
Stochează OTP-urile în memorie (dict singleton).
|
||||
Nu e nevoie de SQLite — OTP-urile expiră în 5 minute și backend-ul
|
||||
rulează cu --workers 1 (single-worker garantat).
|
||||
|
||||
Utilizare:
|
||||
code = await create_otp(email, username, server_id)
|
||||
result = verify_otp(email, code)
|
||||
# result: {"success": True, "username": ..., "server_id": ...}
|
||||
# result: {"success": False, "error": ..., "error_code": ...}
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from backend.modules.telegram.auth.email_auth import generate_email_code
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTE
|
||||
# ============================================================================
|
||||
|
||||
OTP_EXPIRY_MINUTES = 5 # Codul expiră după 5 minute
|
||||
OTP_MAX_ATTEMPTS = 5 # Max încercări greșite per cod
|
||||
OTP_MAX_SENDS_PER_WINDOW = 3 # Max coduri trimise în fereastra de rate limit
|
||||
OTP_RATE_WINDOW_MINUTES = 10 # Fereastra de rate limiting (minute)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# IN-MEMORY STORE
|
||||
# ============================================================================
|
||||
|
||||
# Structura entry:
|
||||
# {
|
||||
# "email@domeniu.ro": {
|
||||
# "code": "483921",
|
||||
# "username": "MARIUS M",
|
||||
# "server_id": "romfast" | None,
|
||||
# "expires_at": datetime,
|
||||
# "attempts": 0,
|
||||
# "send_count": 1,
|
||||
# "created_at": datetime, # pentru rate limiting (fereastra de 10 min)
|
||||
# }
|
||||
# }
|
||||
_otp_store: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FUNCȚII UTILITARE
|
||||
# ============================================================================
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
"""
|
||||
Maschează emailul pentru afișare sigură.
|
||||
|
||||
Exemple:
|
||||
"marius@romfast.ro" → "m***@romfast.ro"
|
||||
"ab@domeniu.ro" → "a***@domeniu.ro"
|
||||
"x@y.com" → "x***@y.com"
|
||||
"@invalid" → "***"
|
||||
"""
|
||||
if "@" not in email:
|
||||
return "***"
|
||||
local, domain = email.split("@", 1)
|
||||
masked_local = (local[0] + "***") if local else "***"
|
||||
return f"{masked_local}@{domain}"
|
||||
|
||||
|
||||
def _is_rate_limited(email: str) -> bool:
|
||||
"""
|
||||
Verifică dacă emailul a depășit limita de trimiteri OTP.
|
||||
|
||||
Limita: OTP_MAX_SENDS_PER_WINDOW trimiteri în OTP_RATE_WINDOW_MINUTES minute.
|
||||
Returnează True dacă este rate limited (nu mai poate trimite cod).
|
||||
"""
|
||||
entry = _otp_store.get(email)
|
||||
if not entry:
|
||||
return False
|
||||
|
||||
# Dacă entry-ul a expirat, îl ștergem și permitem un nou OTP
|
||||
if datetime.now() > entry["expires_at"]:
|
||||
del _otp_store[email]
|
||||
return False
|
||||
|
||||
# Verificăm fereastra de rate limit (separată de expiry-ul codului)
|
||||
window_end = entry["created_at"] + timedelta(minutes=OTP_RATE_WINDOW_MINUTES)
|
||||
if datetime.now() > window_end:
|
||||
# Fereastra de rate limit a expirat — permitem trimitere nouă
|
||||
return False
|
||||
|
||||
return entry.get("send_count", 0) >= OTP_MAX_SENDS_PER_WINDOW
|
||||
|
||||
|
||||
def _cleanup_expired() -> None:
|
||||
"""Curăță OTP-urile expirate din store. Apelat automat la create_otp."""
|
||||
now = datetime.now()
|
||||
expired = [email for email, entry in _otp_store.items() if now > entry["expires_at"]]
|
||||
for email in expired:
|
||||
del _otp_store[email]
|
||||
logger.debug(f"[OTP] Cleaned up expired OTP for {email}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PUBLICĂ
|
||||
# ============================================================================
|
||||
|
||||
async def create_otp(
|
||||
email: str,
|
||||
username: str,
|
||||
server_id: Optional[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generează și stochează un OTP nou pentru email.
|
||||
|
||||
Args:
|
||||
email: Adresa de email a utilizatorului (lowercase)
|
||||
username: Username Oracle (uppercase) — salvat pentru JWT la verificare
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Codul generat (str, 6 cifre) sau None dacă este rate limited
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
|
||||
# Curățăm OTP-urile expirate periodic
|
||||
_cleanup_expired()
|
||||
|
||||
# Verificăm rate limiting
|
||||
if _is_rate_limited(email):
|
||||
logger.warning(f"[OTP] Rate limit exceeded for {email[:3]}*** (max {OTP_MAX_SENDS_PER_WINDOW} sends/{OTP_RATE_WINDOW_MINUTES}min)")
|
||||
return None
|
||||
|
||||
# Generăm cod crypto-secure (6 cifre)
|
||||
code = generate_email_code()
|
||||
|
||||
now = datetime.now()
|
||||
existing = _otp_store.get(email)
|
||||
|
||||
# Păstrăm created_at și send_count din entry-ul anterior (pentru rate limiting corect)
|
||||
if existing:
|
||||
send_count = existing.get("send_count", 0) + 1
|
||||
created_at = existing.get("created_at", now)
|
||||
else:
|
||||
send_count = 1
|
||||
created_at = now
|
||||
|
||||
_otp_store[email] = {
|
||||
"code": code,
|
||||
"username": username,
|
||||
"server_id": server_id,
|
||||
"expires_at": now + timedelta(minutes=OTP_EXPIRY_MINUTES),
|
||||
"attempts": 0,
|
||||
"send_count": send_count,
|
||||
"created_at": created_at,
|
||||
}
|
||||
|
||||
logger.info(f"[OTP] Created OTP for {email[:3]}*** (username={username}, server={server_id}, send_count={send_count})")
|
||||
return code
|
||||
|
||||
|
||||
def verify_otp(email: str, code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Verifică OTP-ul introdus de utilizator.
|
||||
|
||||
Args:
|
||||
email: Adresa de email
|
||||
code: Codul de 6 cifre introdus de utilizator
|
||||
|
||||
Returns:
|
||||
Succes: {"success": True, "username": "MARIUS M", "server_id": "romfast"}
|
||||
Eroare: {"success": False, "error": "...", "error_code": "..."}
|
||||
|
||||
Error codes:
|
||||
OTP_NOT_FOUND — nu există OTP activ pentru email
|
||||
OTP_EXPIRED — OTP-ul a expirat (5 minute)
|
||||
OTP_MAX_ATTEMPTS — prea multe încercări greșite (5)
|
||||
OTP_INVALID — codul este greșit
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
entry = _otp_store.get(email)
|
||||
|
||||
# OTP inexistent
|
||||
if not entry:
|
||||
logger.warning(f"[OTP] No active OTP for {email[:3]}***")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Cod invalid sau expirat. Solicitați un cod nou.",
|
||||
"error_code": "OTP_NOT_FOUND",
|
||||
}
|
||||
|
||||
# OTP expirat
|
||||
if datetime.now() > entry["expires_at"]:
|
||||
delete_otp(email)
|
||||
logger.info(f"[OTP] Expired OTP attempt for {email[:3]}***")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Codul a expirat. Solicitați un cod nou.",
|
||||
"error_code": "OTP_EXPIRED",
|
||||
}
|
||||
|
||||
# Prea multe încercări greșite
|
||||
if entry["attempts"] >= OTP_MAX_ATTEMPTS:
|
||||
delete_otp(email)
|
||||
logger.warning(f"[OTP] Max attempts reached for {email[:3]}***")
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Prea multe încercări greșite. Solicitați un cod nou.",
|
||||
"error_code": "OTP_MAX_ATTEMPTS",
|
||||
}
|
||||
|
||||
# Incrementăm contorul de încercări ÎNAINTE de verificare
|
||||
entry["attempts"] += 1
|
||||
|
||||
# Cod greșit
|
||||
if entry["code"] != code.strip():
|
||||
remaining = OTP_MAX_ATTEMPTS - entry["attempts"]
|
||||
logger.warning(f"[OTP] Invalid code for {email[:3]}*** (attempt {entry['attempts']}, {remaining} remaining)")
|
||||
|
||||
# La ultima încercare permisă, blocăm imediat și ștergem OTP-ul
|
||||
if remaining <= 0:
|
||||
delete_otp(email)
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Prea multe încercări greșite. Solicitați un cod nou.",
|
||||
"error_code": "OTP_MAX_ATTEMPTS",
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Cod incorect. Mai aveți {remaining} {'încercare' if remaining == 1 else 'încercări'}.",
|
||||
"error_code": "OTP_INVALID",
|
||||
}
|
||||
|
||||
# Cod corect — ștergem din store și returnăm datele salvate
|
||||
username = entry["username"]
|
||||
server_id = entry["server_id"]
|
||||
delete_otp(email)
|
||||
|
||||
logger.info(f"[OTP] OTP verified successfully for {email[:3]}*** (username={username})")
|
||||
return {
|
||||
"success": True,
|
||||
"username": username,
|
||||
"server_id": server_id,
|
||||
}
|
||||
|
||||
|
||||
def delete_otp(email: str) -> None:
|
||||
"""
|
||||
Șterge OTP-ul pentru email din store.
|
||||
Apelat după verificare reușită sau când utilizatorul renunță.
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
if email in _otp_store:
|
||||
del _otp_store[email]
|
||||
logger.debug(f"[OTP] Deleted OTP for {email[:3]}***")
|
||||
|
||||
|
||||
def get_otp_entry(email: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Returnează entry-ul OTP pentru email (read-only).
|
||||
Folosit de endpoint-ul /resend-2fa-code pentru a verifica că sesiunea există.
|
||||
|
||||
Returns:
|
||||
Dict cu datele OTP sau None dacă nu există / a expirat
|
||||
"""
|
||||
email = email.lower().strip()
|
||||
entry = _otp_store.get(email)
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
# Verificăm că nu e expirat
|
||||
if datetime.now() > entry["expires_at"]:
|
||||
delete_otp(email)
|
||||
return None
|
||||
|
||||
return entry
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
195
shared/auth/trusted_device_service.py
Normal file
195
shared/auth/trusted_device_service.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Trusted Device Service pentru Web 2FA — async SQLite version
|
||||
|
||||
Permite utilizatorilor să „țină minte" un dispozitiv timp de 30 de zile.
|
||||
Dacă tokenul de dispozitiv de încredere este valid, pasul OTP este sărit.
|
||||
|
||||
Securitate critică:
|
||||
- Tokenul brut (64 hex chars) este returnat clientului și stocat în localStorage
|
||||
- Pe server se stochează DOAR sha256(token), niciodată tokenul brut
|
||||
- Skip-uiește DOAR OTP-ul, nu verificarea Oracle (user+parolă rămân obligatorii)
|
||||
- Expiră automat după TRUSTED_DEVICE_EXPIRE_DAYS zile
|
||||
|
||||
Storage: SQLite (shared/database/app_db.py) — tabelul trusted_devices.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================================
|
||||
# CONSTANTE
|
||||
# ============================================================================
|
||||
|
||||
TRUSTED_DEVICE_EXPIRE_DAYS = int(os.environ.get("TRUSTED_DEVICE_EXPIRE_DAYS", "30"))
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""Calculează sha256 al tokenului brut."""
|
||||
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# API PUBLICĂ (async)
|
||||
# ============================================================================
|
||||
|
||||
async def create_trusted_device_token(username: str, server_id: Optional[str]) -> str:
|
||||
"""
|
||||
Generează un token de dispozitiv de încredere și îl stochează (ca hash).
|
||||
|
||||
Args:
|
||||
username: Username Oracle (uppercase)
|
||||
server_id: ID-ul serverului Oracle (pentru multi-server mode)
|
||||
|
||||
Returns:
|
||||
Tokenul brut (64 hex chars) — de returnat clientului pentru localStorage
|
||||
"""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
raw_token = secrets.token_hex(64)
|
||||
token_hash = _hash_token(raw_token)
|
||||
|
||||
now = datetime.now()
|
||||
expires_at = now + timedelta(days=TRUSTED_DEVICE_EXPIRE_DAYS)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
# Cleanup expired tokens for this user
|
||||
await db.execute(
|
||||
"DELETE FROM trusted_devices WHERE expires_at < ?",
|
||||
(now.isoformat(),)
|
||||
)
|
||||
|
||||
# Insert new token
|
||||
await db.execute(
|
||||
"""INSERT INTO trusted_devices (token_hash, username, server_id, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(token_hash, username.upper(), server_id, expires_at.isoformat(), now.isoformat())
|
||||
)
|
||||
await db.commit()
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Token created for '{username}' "
|
||||
f"(server={server_id}, expires in {TRUSTED_DEVICE_EXPIRE_DAYS}d)"
|
||||
)
|
||||
return raw_token
|
||||
|
||||
|
||||
async def verify_trusted_device_token(
|
||||
raw_token: str,
|
||||
username: str,
|
||||
server_id: Optional[str]
|
||||
) -> bool:
|
||||
"""
|
||||
Verifică dacă tokenul de dispozitiv de încredere este valid.
|
||||
|
||||
Verifică: hash există, username corespunde, nu a expirat.
|
||||
Fail silently — returnează False fără a ridica excepție.
|
||||
|
||||
Args:
|
||||
raw_token: Tokenul brut din localStorage
|
||||
username: Username Oracle (uppercase)
|
||||
server_id: ID-ul serverului Oracle
|
||||
|
||||
Returns:
|
||||
True dacă dispozitivul este de încredere și autentificarea poate sări OTP-ul
|
||||
"""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
try:
|
||||
token_hash = _hash_token(raw_token)
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
cursor = await db.execute(
|
||||
"SELECT username, server_id, expires_at FROM trusted_devices WHERE token_hash = ?",
|
||||
(token_hash,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
logger.debug(f"[TRUSTED_DEVICE] Token not found for '{username}'")
|
||||
return False
|
||||
|
||||
stored_username = row["username"]
|
||||
stored_server = row["server_id"]
|
||||
expires_at_str = row["expires_at"]
|
||||
|
||||
# Verifică username (case-insensitive)
|
||||
if stored_username.upper() != username.upper():
|
||||
logger.warning(
|
||||
f"[TRUSTED_DEVICE] Username mismatch: "
|
||||
f"expected '{username}', got '{stored_username}'"
|
||||
)
|
||||
return False
|
||||
|
||||
# Cross-server trust — log but allow
|
||||
if stored_server != server_id:
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Cross-server trust for '{username}': "
|
||||
f"token from '{stored_server}', logging into '{server_id}' — allowing"
|
||||
)
|
||||
|
||||
# Verifică expirarea
|
||||
if expires_at_str < datetime.now().isoformat():
|
||||
logger.info(f"[TRUSTED_DEVICE] Token expired for '{username}'")
|
||||
await db.execute("DELETE FROM trusted_devices WHERE token_hash = ?", (token_hash,))
|
||||
await db.commit()
|
||||
return False
|
||||
|
||||
logger.info(f"[TRUSTED_DEVICE] Token valid for '{username}' (server={server_id})")
|
||||
return True
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[TRUSTED_DEVICE] Unexpected error during verification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def revoke_all_for_user(username: str, server_id: Optional[str] = None) -> int:
|
||||
"""
|
||||
Revocă toate token-urile de dispozitiv de încredere ale unui utilizator.
|
||||
|
||||
Args:
|
||||
username: Username Oracle (uppercase)
|
||||
server_id: Dacă specificat, revocă doar pentru serverul respectiv
|
||||
|
||||
Returns:
|
||||
Numărul de token-uri revocate
|
||||
"""
|
||||
from shared.database.app_db import get_db
|
||||
|
||||
username_upper = username.upper()
|
||||
|
||||
db = await get_db()
|
||||
try:
|
||||
if server_id is not None:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM trusted_devices WHERE UPPER(username) = ? AND server_id = ?",
|
||||
(username_upper, server_id)
|
||||
)
|
||||
else:
|
||||
cursor = await db.execute(
|
||||
"DELETE FROM trusted_devices WHERE UPPER(username) = ?",
|
||||
(username_upper,)
|
||||
)
|
||||
await db.commit()
|
||||
deleted = cursor.rowcount
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
if deleted:
|
||||
logger.info(
|
||||
f"[TRUSTED_DEVICE] Revoked {deleted} tokens for '{username}'"
|
||||
+ (f" (server={server_id})" if server_id else "")
|
||||
)
|
||||
|
||||
return deleted
|
||||
101
shared/database/app_db.py
Normal file
101
shared/database/app_db.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Shared SQLite database pentru toate datele auth-related (trusted devices, backup codes, email cache)."""
|
||||
import aiosqlite
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_DIR = Path(__file__).parent.parent.parent / "backend" / "data"
|
||||
DB_PATH = DB_DIR / "app.db"
|
||||
|
||||
|
||||
async def get_db() -> aiosqlite.Connection:
|
||||
conn = await aiosqlite.connect(DB_PATH)
|
||||
conn.row_factory = aiosqlite.Row
|
||||
return conn
|
||||
|
||||
|
||||
async def init_app_db():
|
||||
"""Create all auth-related tables. Safe to call multiple times."""
|
||||
DB_DIR.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
await db.execute("PRAGMA journal_mode=WAL")
|
||||
await db.execute("PRAGMA busy_timeout=5000")
|
||||
await db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Telegram tables (delegate init from telegram/db/database.py)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS telegram_users (
|
||||
telegram_user_id INTEGER PRIMARY KEY,
|
||||
username TEXT, first_name TEXT NOT NULL, last_name TEXT,
|
||||
oracle_username TEXT, jwt_token TEXT, jwt_refresh_token TEXT,
|
||||
token_expires_at TIMESTAMP, linked_at TIMESTAMP,
|
||||
last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, is_active BOOLEAN DEFAULT 1
|
||||
)""")
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS telegram_auth_codes (
|
||||
code TEXT PRIMARY KEY, telegram_user_id INTEGER, oracle_username TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL,
|
||||
used BOOLEAN DEFAULT 0, used_at TIMESTAMP, server_id TEXT,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)""")
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS telegram_sessions (
|
||||
session_id TEXT PRIMARY KEY, telegram_user_id INTEGER NOT NULL,
|
||||
conversation_state TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)""")
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS email_auth_codes (
|
||||
code TEXT PRIMARY KEY, email TEXT NOT NULL, oracle_username TEXT NOT NULL,
|
||||
telegram_user_id INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL, used INTEGER DEFAULT 0, used_at TIMESTAMP,
|
||||
failed_attempts INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (telegram_user_id) REFERENCES telegram_users(telegram_user_id)
|
||||
)""")
|
||||
|
||||
# Trusted devices (migrated from JSON)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS trusted_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)""")
|
||||
|
||||
# Backup codes (new)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS backup_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
server_id TEXT,
|
||||
code_hash TEXT NOT NULL,
|
||||
used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP
|
||||
)""")
|
||||
|
||||
# Email-server cache (migrated from in-memory)
|
||||
await db.execute("""CREATE TABLE IF NOT EXISTS email_server_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(email, server_id)
|
||||
)""")
|
||||
|
||||
# Indexes for telegram
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_auth_codes_telegram_user ON telegram_auth_codes(telegram_user_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON telegram_auth_codes(expires_at)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sessions_telegram_user ON telegram_sessions(telegram_user_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_sessions_expires ON telegram_sessions(expires_at)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_auth_email ON email_auth_codes(email)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_auth_telegram_user ON email_auth_codes(telegram_user_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_auth_expires ON email_auth_codes(expires_at)")
|
||||
|
||||
# Indexes for new tables
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_trusted_devices_user ON trusted_devices(username, server_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_trusted_devices_expires ON trusted_devices(expires_at)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_backup_codes_user ON backup_codes(username, server_id)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_cache_email ON email_server_cache(email)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_email_cache_server ON email_server_cache(server_id)")
|
||||
|
||||
await db.commit()
|
||||
logger.info("[APP_DB] Database initialized successfully")
|
||||
@@ -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')
|
||||
|
||||
29
src/assets/css/vendor/primevue-overrides.css
vendored
29
src/assets/css/vendor/primevue-overrides.css
vendored
@@ -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 */
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -17,7 +17,81 @@
|
||||
<p>Se încarcă...</p>
|
||||
</div>
|
||||
|
||||
<!-- Simplified Login Form -->
|
||||
<!-- 2FA Step — verificare cod -->
|
||||
<div v-else-if="authStore.loginStep === '2fa'" class="login-2fa">
|
||||
<div class="twofa-header">
|
||||
<i class="pi pi-envelope text-primary twofa-icon"></i>
|
||||
<p class="twofa-info">
|
||||
Cod trimis la <strong>{{ authStore.otpMaskedEmail }}</strong>
|
||||
</p>
|
||||
<p class="twofa-subinfo">Introduceți codul email sau un cod de recuperare</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<InputText
|
||||
id="otp-code"
|
||||
v-model="unifiedCode"
|
||||
:inputmode="isBackupCodeMode ? 'text' : 'numeric'"
|
||||
:placeholder="isBackupCodeMode ? 'Cod de recuperare' : '_ _ _ _ _ _'"
|
||||
maxlength="8"
|
||||
class="w-full otp-input"
|
||||
:class="{ invalid: otpError, 'backup-code-mode': isBackupCodeMode }"
|
||||
autocomplete="one-time-code"
|
||||
@input="handleUnifiedInput"
|
||||
@keyup.enter="handleVerifyUnified"
|
||||
/>
|
||||
<span v-if="otpError" class="form-error">{{ otpError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Eroare din store -->
|
||||
<div v-if="authStore.error" class="login-error-message">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span>{{ authStore.error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Buton verificare -->
|
||||
<Button
|
||||
label="Verifică"
|
||||
class="w-full login-button"
|
||||
:loading="authStore.is2FALoading"
|
||||
:disabled="unifiedCode.length < 6 || authStore.is2FALoading"
|
||||
icon="pi pi-check"
|
||||
icon-pos="right"
|
||||
@click="handleVerifyUnified"
|
||||
/>
|
||||
|
||||
<!-- Checkbox "Ține minte dispozitivul" -->
|
||||
<div class="twofa-trust-device">
|
||||
<Checkbox v-model="trustDevice" inputId="trust-device" :binary="true" />
|
||||
<label for="trust-device" class="trust-device-label">
|
||||
Ține minte acest dispozitiv 30 de zile
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Retrimite codul (doar în modul OTP) -->
|
||||
<div v-if="!isBackupCodeMode" class="twofa-resend">
|
||||
<span v-if="authStore.resendCountdown > 0" class="resend-countdown">
|
||||
Retrimite codul în {{ authStore.resendCountdown }}s
|
||||
</span>
|
||||
<a
|
||||
v-else
|
||||
href="#"
|
||||
class="resend-link"
|
||||
@click.prevent="handleResendOTP"
|
||||
>
|
||||
Retrimite codul
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Înapoi la login -->
|
||||
<div class="twofa-back">
|
||||
<a href="#" class="back-link" @click.prevent="handleBackFromOTP">
|
||||
<i class="pi pi-arrow-left"></i> Înapoi la login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Form (username + parolă) -->
|
||||
<form
|
||||
v-else
|
||||
class="login-form"
|
||||
@@ -107,6 +181,38 @@
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Modal: backup codes generate la primul 2FA -->
|
||||
<Dialog
|
||||
v-model:visible="showBackupCodesModal"
|
||||
header="Coduri de recuperare generate"
|
||||
:modal="true"
|
||||
:closable="false"
|
||||
:style="{ width: '400px' }"
|
||||
>
|
||||
<div class="backup-codes-modal-content">
|
||||
<p class="backup-codes-info">
|
||||
<strong>Important:</strong> Salvați aceste coduri în siguranță! Ele se afișează o singură dată.
|
||||
Folosiți-le dacă nu primiți codul pe email.
|
||||
</p>
|
||||
<div class="backup-codes-grid">
|
||||
<code v-for="code in backupCodesToShow" :key="code" class="backup-code-item">{{ code }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Am salvat codurile"
|
||||
icon="pi pi-check"
|
||||
@click="showBackupCodesModal = false; router.push(props.redirectPath)"
|
||||
/>
|
||||
<Button
|
||||
label="Copiază toate"
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
@click="copyBackupCodes"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -115,6 +221,8 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
import Dialog from "primevue/dialog";
|
||||
import Dropdown from "primevue/dropdown";
|
||||
import Password from "primevue/password";
|
||||
|
||||
@@ -146,7 +254,7 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
|
||||
// Form data
|
||||
// Form data — login normal
|
||||
const identity = ref("");
|
||||
const identityError = ref("");
|
||||
const selectedServer = ref(null);
|
||||
@@ -154,6 +262,16 @@ const serverError = ref("");
|
||||
const password = ref("");
|
||||
const passwordError = ref("");
|
||||
|
||||
// Form data — 2FA (unified: acceptă atât cod OTP cât și cod de recuperare)
|
||||
const unifiedCode = ref("");
|
||||
const otpError = ref("");
|
||||
const trustDevice = ref(false);
|
||||
const backupCodesToShow = ref([]);
|
||||
const showBackupCodesModal = ref(false);
|
||||
|
||||
// true când input-ul conține litere → mod cod de recuperare
|
||||
const isBackupCodeMode = computed(() => /[A-Z]/.test(unifiedCode.value));
|
||||
|
||||
// Internal state for server loading
|
||||
const isIdentityVerified = ref(false);
|
||||
|
||||
@@ -176,7 +294,9 @@ const canSubmit = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Methods
|
||||
// ============================================================================
|
||||
// LOGIN FORM HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
const clearPasswordError = () => {
|
||||
passwordError.value = "";
|
||||
@@ -273,6 +393,17 @@ const handleLogin = async () => {
|
||||
|
||||
const result = await props.authStore.login(credentials);
|
||||
|
||||
if (result.success && result.requires_2fa) {
|
||||
// Backend cere verificare OTP — focus pe input-ul de cod
|
||||
unifiedCode.value = "";
|
||||
otpError.value = "";
|
||||
setTimeout(() => {
|
||||
const otpInput = document.getElementById("otp-code");
|
||||
if (otpInput) otpInput.focus();
|
||||
}, 100);
|
||||
return; // Nu redirectăm — așteptăm codul
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: "success",
|
||||
@@ -320,12 +451,122 @@ const handleLogin = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 2FA HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
// Input unificat: filtrează și limitează lungimea în funcție de mod
|
||||
const handleUnifiedInput = (event) => {
|
||||
otpError.value = "";
|
||||
props.authStore.clearError();
|
||||
|
||||
let val = event.target.value.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
||||
// OTP: doar cifre → maxim 6; backup code: are litere → maxim 8
|
||||
if (/^[0-9]*$/.test(val)) {
|
||||
val = val.slice(0, 6);
|
||||
} else {
|
||||
val = val.slice(0, 8);
|
||||
}
|
||||
unifiedCode.value = val;
|
||||
|
||||
// Auto-submit la 6 cifre (OTP complet)
|
||||
if (/^\d{6}$/.test(val)) {
|
||||
handleVerifyUnified();
|
||||
}
|
||||
};
|
||||
|
||||
// Verificare unificată — detectează tipul codului și apelează handler-ul corect
|
||||
const handleVerifyUnified = async () => {
|
||||
if (unifiedCode.value.length < 6) return;
|
||||
|
||||
otpError.value = "";
|
||||
|
||||
if (isBackupCodeMode.value) {
|
||||
// Cod de recuperare (alfanumeric)
|
||||
const result = await props.authStore.verifyBackupCode({
|
||||
code: unifiedCode.value,
|
||||
trustDevice: trustDevice.value,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "Autentificare reușită",
|
||||
detail: "Ați folosit un cod de recuperare",
|
||||
life: 3000,
|
||||
});
|
||||
router.push(props.redirectPath);
|
||||
}
|
||||
} else {
|
||||
// Cod OTP (6 cifre)
|
||||
if (unifiedCode.value.length !== 6) {
|
||||
otpError.value = "Codul trebuie să aibă exact 6 cifre";
|
||||
return;
|
||||
}
|
||||
const result = await props.authStore.verify2FA({
|
||||
code: unifiedCode.value,
|
||||
trustDevice: trustDevice.value,
|
||||
});
|
||||
if (result.success) {
|
||||
if (result.backup_codes && result.backup_codes.length > 0) {
|
||||
backupCodesToShow.value = result.backup_codes;
|
||||
showBackupCodesModal.value = true;
|
||||
} else {
|
||||
toast.add({
|
||||
severity: "success",
|
||||
summary: "Autentificare reușită",
|
||||
detail: `Bine ați venit, ${props.authStore.user?.username || ""}!`,
|
||||
life: 3000,
|
||||
});
|
||||
router.push(props.redirectPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Retrimite cod OTP
|
||||
const handleResendOTP = async () => {
|
||||
const result = await props.authStore.resendOTP();
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: "info",
|
||||
summary: "Cod retrimis",
|
||||
detail: `Un cod nou a fost trimis la ${props.authStore.otpMaskedEmail}`,
|
||||
life: 4000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Înapoi din 2FA — resetează la formularul de login
|
||||
const handleBackFromOTP = () => {
|
||||
unifiedCode.value = "";
|
||||
otpError.value = "";
|
||||
trustDevice.value = false;
|
||||
props.authStore.clearError();
|
||||
props.authStore.resetLoginFlow();
|
||||
};
|
||||
|
||||
// Copiază backup codes în clipboard
|
||||
const copyBackupCodes = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(backupCodesToShow.value.join('\n'));
|
||||
toast.add({ severity: "success", summary: "Copiat!", life: 2000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: "warn", summary: "Nu s-a putut copia automat", life: 2000 });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SHARED
|
||||
// ============================================================================
|
||||
|
||||
// Clear errors on mount
|
||||
const clearErrors = () => {
|
||||
props.authStore.clearError();
|
||||
identityError.value = "";
|
||||
passwordError.value = "";
|
||||
serverError.value = "";
|
||||
unifiedCode.value = "";
|
||||
otpError.value = "";
|
||||
};
|
||||
|
||||
// Watch for selectedServerId changes from store (pre-selection from localStorage)
|
||||
@@ -386,4 +627,144 @@ onUnmounted(() => {
|
||||
|
||||
/* Server dropdown - use normal styling like other form inputs */
|
||||
/* No special overrides needed - inherits from primevue-overrides.css */
|
||||
|
||||
/* ============================================================
|
||||
2FA STEP
|
||||
============================================================ */
|
||||
|
||||
.login-2fa {
|
||||
padding: 0 var(--space-lg) var(--space-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.twofa-header {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.twofa-icon {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.twofa-info {
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.twofa-subinfo {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Input OTP — font mare, centrat, cifre spațiate */
|
||||
.otp-input {
|
||||
text-align: center !important;
|
||||
font-size: 1.75rem !important;
|
||||
letter-spacing: 0.6em !important;
|
||||
font-weight: var(--font-semibold) !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Mod cod de recuperare — font monospace, fără spacing exagerat */
|
||||
.otp-input.backup-code-mode,
|
||||
.otp-input.backup-code-mode input {
|
||||
font-size: 1.375rem !important;
|
||||
letter-spacing: 0.05em !important;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.twofa-resend {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resend-countdown {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.resend-link {
|
||||
color: var(--primary-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.resend-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.twofa-back {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Checkbox "Ține minte dispozitivul" */
|
||||
.twofa-trust-device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.trust-device-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
/* Touch target minim 44px */
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Backup codes modal */
|
||||
.backup-codes-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.backup-codes-info {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.backup-codes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.backup-code-item {
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
font-family: monospace;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: var(--font-semibold);
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Server Switch Password Modal (US-009) -->
|
||||
<!-- Server Switch Modal - Password + 2FA steps (US-009) -->
|
||||
<Dialog
|
||||
v-model:visible="showPasswordModal"
|
||||
:header="`Schimbare server: ${targetServerName}`"
|
||||
@@ -80,19 +80,75 @@
|
||||
class="server-switch-modal"
|
||||
>
|
||||
<div class="server-switch-modal-content">
|
||||
<div class="form-field">
|
||||
<Password
|
||||
id="switch-password"
|
||||
v-model="switchPassword"
|
||||
:feedback="false"
|
||||
:toggleMask="true"
|
||||
inputClass="w-full"
|
||||
class="w-full"
|
||||
:disabled="isSwitching"
|
||||
@keyup.enter="confirmServerSwitch"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<!-- Step 1: Password -->
|
||||
<template v-if="switchStep === 'password'">
|
||||
<div class="form-field">
|
||||
<Password
|
||||
id="switch-password"
|
||||
v-model="switchPassword"
|
||||
:feedback="false"
|
||||
:toggleMask="true"
|
||||
inputClass="w-full"
|
||||
:inputStyle="passwordInputStyle"
|
||||
class="w-full"
|
||||
:disabled="isSwitching"
|
||||
@keyup.enter="confirmServerSwitch"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 2FA OTP -->
|
||||
<template v-else-if="switchStep === '2fa'">
|
||||
<p v-if="switchMaskedEmail" class="otp-info">
|
||||
Cod trimis la <strong>{{ switchMaskedEmail }}</strong>
|
||||
</p>
|
||||
<div class="form-field">
|
||||
<InputText
|
||||
v-model="switchOtpCode"
|
||||
placeholder="000000"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="w-full otp-input"
|
||||
:disabled="isSwitching"
|
||||
@input="handleSwitchOtpInput"
|
||||
@keyup.enter="confirmSwitch2FA"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-field-inline">
|
||||
<Checkbox v-model="switchTrustDevice" inputId="switch-trust" :binary="true" />
|
||||
<label for="switch-trust" class="trust-label">Ține minte acest dispozitiv 30 zile</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="resend-link"
|
||||
:disabled="authStore?.resendCountdown > 0"
|
||||
@click="handleSwitchResendOTP"
|
||||
>
|
||||
{{ authStore?.resendCountdown > 0 ? `Retrimite (${authStore.resendCountdown}s)` : 'Retrimite codul' }}
|
||||
</button>
|
||||
<!-- Backup code fallback pentru server switch -->
|
||||
<div class="backup-switch-toggle" style="margin-top: var(--space-sm); text-align: center;">
|
||||
<button
|
||||
v-if="!showSwitchBackupInput"
|
||||
type="button"
|
||||
class="resend-link"
|
||||
@click="showSwitchBackupInput = true"
|
||||
>
|
||||
Folosește cod de recuperare
|
||||
</button>
|
||||
<div v-else>
|
||||
<InputText
|
||||
v-model="switchBackupCode"
|
||||
placeholder="Cod recuperare"
|
||||
class="w-full"
|
||||
style="margin-bottom: var(--space-sm);"
|
||||
@input="switchBackupCode = switchBackupCode.toUpperCase().replace(/[^A-Z0-9]/g, '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="switchError" class="switch-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
@@ -101,18 +157,36 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Anulează"
|
||||
severity="secondary"
|
||||
:disabled="isSwitching"
|
||||
@click="cancelServerSwitch"
|
||||
/>
|
||||
<Button
|
||||
label="Confirma"
|
||||
:loading="isSwitching"
|
||||
:disabled="!switchPassword"
|
||||
@click="confirmServerSwitch"
|
||||
/>
|
||||
<!-- Password step footer -->
|
||||
<template v-if="switchStep === 'password'">
|
||||
<Button
|
||||
label="Anulează"
|
||||
severity="secondary"
|
||||
:disabled="isSwitching"
|
||||
@click="cancelServerSwitch"
|
||||
/>
|
||||
<Button
|
||||
label="Confirmă"
|
||||
:loading="isSwitching"
|
||||
:disabled="!switchPassword"
|
||||
@click="confirmServerSwitch"
|
||||
/>
|
||||
</template>
|
||||
<!-- 2FA step footer -->
|
||||
<template v-else-if="switchStep === '2fa'">
|
||||
<Button
|
||||
label="Înapoi"
|
||||
severity="secondary"
|
||||
:disabled="isSwitching"
|
||||
@click="switchStep = 'password'"
|
||||
/>
|
||||
<Button
|
||||
label="Verifică"
|
||||
:loading="isSwitching"
|
||||
:disabled="!switchOtpCode || switchOtpCode.length < 6"
|
||||
@click="confirmSwitch2FA"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</Dialog>
|
||||
</header>
|
||||
@@ -126,6 +200,8 @@ import ServerSelector from "../ServerSelector.vue";
|
||||
import Dialog from "primevue/dialog";
|
||||
import Password from "primevue/password";
|
||||
import Button from "primevue/button";
|
||||
import InputText from "primevue/inputtext";
|
||||
import Checkbox from "primevue/checkbox";
|
||||
|
||||
export default {
|
||||
name: "AppHeader",
|
||||
@@ -136,6 +212,8 @@ export default {
|
||||
Dialog,
|
||||
Password,
|
||||
Button,
|
||||
InputText,
|
||||
Checkbox,
|
||||
},
|
||||
props: {
|
||||
// Header title/brand text
|
||||
@@ -226,6 +304,21 @@ export default {
|
||||
return server?.name || serverId;
|
||||
};
|
||||
|
||||
// Dark mode detection for password input styling
|
||||
const isDarkMode = computed(() => {
|
||||
const attr = document.documentElement.getAttribute('data-theme');
|
||||
if (attr === 'dark') return true;
|
||||
if (attr === 'light') return false;
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
});
|
||||
|
||||
const passwordInputStyle = computed(() => isDarkMode.value ? {
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text)',
|
||||
webkitTextFillColor: 'var(--color-text)',
|
||||
borderColor: 'var(--color-border)',
|
||||
} : {});
|
||||
|
||||
// Password confirmation modal state (US-009)
|
||||
const showPasswordModal = ref(false);
|
||||
const switchPassword = ref("");
|
||||
@@ -234,6 +327,16 @@ export default {
|
||||
const isSwitching = ref(false);
|
||||
const switchError = ref("");
|
||||
|
||||
// 2FA step state for server switch
|
||||
const switchStep = ref("password"); // 'password' | '2fa'
|
||||
const switchOtpCode = ref("");
|
||||
const switchTrustDevice = ref(false);
|
||||
const switchMaskedEmail = ref("");
|
||||
|
||||
// Backup code state for server switch
|
||||
const showSwitchBackupInput = ref(false);
|
||||
const switchBackupCode = ref("");
|
||||
|
||||
const onServerChange = (event) => {
|
||||
const newServerId = event.value;
|
||||
// Don't process if same server selected
|
||||
@@ -261,8 +364,33 @@ export default {
|
||||
showPasswordModal.value = false;
|
||||
switchPassword.value = "";
|
||||
switchError.value = "";
|
||||
switchOtpCode.value = "";
|
||||
switchTrustDevice.value = false;
|
||||
switchMaskedEmail.value = "";
|
||||
switchStep.value = "password";
|
||||
showSwitchBackupInput.value = false;
|
||||
switchBackupCode.value = "";
|
||||
targetServerId.value = null;
|
||||
targetServerName.value = "";
|
||||
// Clear pending server in auth store
|
||||
if (props.authStore) {
|
||||
props.authStore.pendingServerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finishServerSwitch = () => {
|
||||
showPasswordModal.value = false;
|
||||
switchPassword.value = "";
|
||||
switchOtpCode.value = "";
|
||||
switchTrustDevice.value = false;
|
||||
switchMaskedEmail.value = "";
|
||||
switchStep.value = "password";
|
||||
showSwitchBackupInput.value = false;
|
||||
switchBackupCode.value = "";
|
||||
// Update local state to reflect new server
|
||||
currentServerId.value = targetServerId.value;
|
||||
// Emit event for parent to reload data (companies, periods)
|
||||
emit("server-switched", targetServerId.value);
|
||||
};
|
||||
|
||||
const confirmServerSwitch = async () => {
|
||||
@@ -283,16 +411,13 @@ export default {
|
||||
try {
|
||||
const result = await props.authStore.switchServer(targetServerId.value, switchPassword.value);
|
||||
|
||||
if (result.success) {
|
||||
// Close modal
|
||||
showPasswordModal.value = false;
|
||||
switchPassword.value = "";
|
||||
|
||||
// Update local state to reflect new server
|
||||
currentServerId.value = targetServerId.value;
|
||||
|
||||
// Emit event for parent to reload data (companies, periods)
|
||||
emit("server-switched", targetServerId.value);
|
||||
if (result.requires_2fa) {
|
||||
// Show 2FA step — don't close modal
|
||||
switchStep.value = "2fa";
|
||||
switchMaskedEmail.value = result.masked_email || "";
|
||||
switchError.value = "";
|
||||
} else if (result.success) {
|
||||
finishServerSwitch();
|
||||
} else {
|
||||
// Show error in modal
|
||||
switchError.value = result.error || "Autentificare eșuată";
|
||||
@@ -304,6 +429,52 @@ export default {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSwitch2FA = async () => {
|
||||
isSwitching.value = true;
|
||||
switchError.value = "";
|
||||
|
||||
try {
|
||||
let result;
|
||||
if (showSwitchBackupInput.value && switchBackupCode.value) {
|
||||
// Verifică cu backup code
|
||||
result = await props.authStore.verifyBackupCode({
|
||||
code: switchBackupCode.value,
|
||||
});
|
||||
} else {
|
||||
if (!switchOtpCode.value || switchOtpCode.value.trim().length === 0) {
|
||||
switchError.value = "Introduceți codul OTP";
|
||||
isSwitching.value = false;
|
||||
return;
|
||||
}
|
||||
result = await props.authStore.verify2FA({
|
||||
code: switchOtpCode.value,
|
||||
trustDevice: switchTrustDevice.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
finishServerSwitch();
|
||||
} else {
|
||||
switchError.value = result.error || "Cod invalid";
|
||||
}
|
||||
} catch (err) {
|
||||
switchError.value = err.message || "Eroare la verificare";
|
||||
} finally {
|
||||
isSwitching.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchResendOTP = async () => {
|
||||
const result = await props.authStore.resendOTP();
|
||||
if (!result.success) {
|
||||
switchError.value = result.error || "Nu s-a putut retrimite codul";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchOtpInput = (event) => {
|
||||
switchOtpCode.value = event.target.value.replace(/\D/g, "").slice(0, 6);
|
||||
};
|
||||
|
||||
const onCompanyChanged = (company) => {
|
||||
emit("company-changed", company);
|
||||
};
|
||||
@@ -373,7 +544,81 @@ export default {
|
||||
switchError,
|
||||
cancelServerSwitch,
|
||||
confirmServerSwitch,
|
||||
// 2FA step in server switch modal
|
||||
switchStep,
|
||||
switchOtpCode,
|
||||
switchTrustDevice,
|
||||
switchMaskedEmail,
|
||||
finishServerSwitch,
|
||||
confirmSwitch2FA,
|
||||
handleSwitchResendOTP,
|
||||
handleSwitchOtpInput,
|
||||
// Backup code in server switch
|
||||
showSwitchBackupInput,
|
||||
switchBackupCode,
|
||||
// Dark mode password input
|
||||
passwordInputStyle,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Server switch modal - 2FA step styles */
|
||||
.otp-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-field {
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-field-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.trust-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.otp-input {
|
||||
letter-spacing: 0.3em;
|
||||
font-size: var(--text-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.resend-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.resend-link:disabled {
|
||||
color: var(--text-color-secondary);
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.switch-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--red-50);
|
||||
color: var(--red-600);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -249,7 +249,7 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Server Switch Password Modal -->
|
||||
<!-- Server Switch Modal - Password + 2FA steps -->
|
||||
<Dialog
|
||||
v-model:visible="showServerPasswordModal"
|
||||
:header="`Schimbare server: ${targetServerName}`"
|
||||
@@ -259,16 +259,49 @@
|
||||
class="mobile-server-switch-modal"
|
||||
>
|
||||
<div class="server-switch-modal-content">
|
||||
<Password
|
||||
v-model="serverSwitchPassword"
|
||||
:feedback="false"
|
||||
toggleMask
|
||||
inputClass="w-full"
|
||||
class="w-full"
|
||||
:disabled="isSwitchingServer"
|
||||
@keyup.enter="confirmServerSwitch"
|
||||
autofocus
|
||||
/>
|
||||
<!-- Step 1: Password -->
|
||||
<template v-if="switchStep === 'password'">
|
||||
<Password
|
||||
v-model="serverSwitchPassword"
|
||||
:feedback="false"
|
||||
toggleMask
|
||||
inputClass="w-full"
|
||||
class="w-full"
|
||||
:disabled="isSwitchingServer"
|
||||
@keyup.enter="confirmServerSwitch"
|
||||
autofocus
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 2FA OTP -->
|
||||
<template v-else-if="switchStep === '2fa'">
|
||||
<p v-if="switchMaskedEmail" class="otp-info">
|
||||
Cod trimis la <strong>{{ switchMaskedEmail }}</strong>
|
||||
</p>
|
||||
<InputText
|
||||
v-model="switchOtpCode"
|
||||
placeholder="000000"
|
||||
inputmode="numeric"
|
||||
maxlength="6"
|
||||
class="w-full otp-input"
|
||||
:disabled="isSwitchingServer"
|
||||
@input="handleSwitchOtpInput"
|
||||
@keyup.enter="confirmSwitch2FA"
|
||||
autofocus
|
||||
/>
|
||||
<div class="form-field-inline">
|
||||
<Checkbox v-model="switchTrustDevice" inputId="mobile-switch-trust" :binary="true" />
|
||||
<label for="mobile-switch-trust" class="trust-label">Ține minte acest dispozitiv 30 zile</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="resend-link"
|
||||
:disabled="props.authStore?.resendCountdown > 0"
|
||||
@click="handleSwitchResendOTP"
|
||||
>
|
||||
{{ props.authStore?.resendCountdown > 0 ? `Retrimite (${props.authStore.resendCountdown}s)` : 'Retrimite codul' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div v-if="serverSwitchError" class="switch-error">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
@@ -277,18 +310,36 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Anulează"
|
||||
severity="secondary"
|
||||
:disabled="isSwitchingServer"
|
||||
@click="cancelServerSwitch"
|
||||
/>
|
||||
<Button
|
||||
label="Confirmă"
|
||||
:loading="isSwitchingServer"
|
||||
:disabled="!serverSwitchPassword"
|
||||
@click="confirmServerSwitch"
|
||||
/>
|
||||
<!-- Password step footer -->
|
||||
<template v-if="switchStep === 'password'">
|
||||
<Button
|
||||
label="Anulează"
|
||||
severity="secondary"
|
||||
:disabled="isSwitchingServer"
|
||||
@click="cancelServerSwitch"
|
||||
/>
|
||||
<Button
|
||||
label="Confirmă"
|
||||
:loading="isSwitchingServer"
|
||||
:disabled="!serverSwitchPassword"
|
||||
@click="confirmServerSwitch"
|
||||
/>
|
||||
</template>
|
||||
<!-- 2FA step footer -->
|
||||
<template v-else-if="switchStep === '2fa'">
|
||||
<Button
|
||||
label="Înapoi"
|
||||
severity="secondary"
|
||||
:disabled="isSwitchingServer"
|
||||
@click="switchStep = 'password'"
|
||||
/>
|
||||
<Button
|
||||
label="Verifică"
|
||||
:loading="isSwitchingServer"
|
||||
:disabled="!switchOtpCode || switchOtpCode.length < 6"
|
||||
@click="confirmSwitch2FA"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</Dialog>
|
||||
</Teleport>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user