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/ocr_queue/
|
||||||
!backend/data/*/.gitkeep
|
!backend/data/*/.gitkeep
|
||||||
|
|
||||||
|
# Auth trusted devices (conține date sensibile — nu commita!)
|
||||||
|
data/auth/trusted_devices.json
|
||||||
|
|
||||||
# PRD tasks (generated, not tracked)
|
# PRD tasks (generated, not tracked)
|
||||||
tasks/
|
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():
|
async def init_telegram_db():
|
||||||
"""Initialize Telegram SQLite database."""
|
"""Initialize shared app database (trusted devices, backup codes, telegram tables)."""
|
||||||
logger.info("[TELEGRAM] Initializing SQLite database...")
|
logger.info("[TELEGRAM] Initializing shared app database...")
|
||||||
try:
|
try:
|
||||||
from backend.modules.telegram.db import init_database, cleanup_expired_codes, cleanup_expired_sessions, cleanup_expired_email_codes
|
from shared.database.app_db import init_app_db
|
||||||
|
await init_app_db()
|
||||||
await init_database()
|
logger.info("[TELEGRAM] ✅ Shared app database initialized")
|
||||||
logger.info(f"[TELEGRAM] ✅ Database initialized: {settings.telegram_sqlite_database_path}")
|
|
||||||
|
|
||||||
# Cleanup expired data
|
# 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_codes = await cleanup_expired_codes()
|
||||||
expired_sessions = await cleanup_expired_sessions()
|
expired_sessions = await cleanup_expired_sessions()
|
||||||
expired_email_codes = await cleanup_expired_email_codes()
|
expired_email_codes = await cleanup_expired_email_codes()
|
||||||
@@ -544,6 +544,9 @@ app.add_middleware(
|
|||||||
"/", "/docs", "/health", "/redoc", "/openapi.json",
|
"/", "/docs", "/health", "/redoc", "/openapi.json",
|
||||||
"/api/auth/login", "/api/auth/refresh", "/api/auth/check-email",
|
"/api/auth/login", "/api/auth/refresh", "/api/auth/check-email",
|
||||||
"/api/auth/check-identity", # US-013: Dual login support (email + username)
|
"/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/system/auth-mode", # Public endpoint for login mode detection
|
||||||
"/api/telegram/auth/verify-user",
|
"/api/telegram/auth/verify-user",
|
||||||
"/api/telegram/auth/verify-email",
|
"/api/telegram/auth/verify-email",
|
||||||
|
|||||||
@@ -1,182 +1,52 @@
|
|||||||
"""
|
"""
|
||||||
SQLite Database Setup for Telegram Bot
|
SQLite Database Setup for Telegram Bot
|
||||||
|
|
||||||
This module handles database connection, initialization, and schema creation.
|
Delegates to shared/database/app_db.py for unified database.
|
||||||
Uses aiosqlite for async SQLite operations.
|
All tables (telegram, trusted devices, backup codes) live in app.db.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime, timedelta
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Database file location
|
# Re-export DB_PATH for backward compatibility (operations.py imports it)
|
||||||
DB_DIR = Path(__file__).parent.parent.parent / "data"
|
__all__ = [
|
||||||
DB_PATH = DB_DIR / "telegram_bot.db"
|
'get_db_connection',
|
||||||
|
'init_database',
|
||||||
# SQLite busy timeout in milliseconds (wait for locks instead of failing immediately)
|
'cleanup_expired_codes',
|
||||||
SQLITE_BUSY_TIMEOUT_MS = 5000
|
'cleanup_expired_sessions',
|
||||||
|
'cleanup_expired_email_codes',
|
||||||
|
'get_database_stats',
|
||||||
|
'DB_PATH',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def get_db_connection() -> aiosqlite.Connection:
|
async def get_db_connection() -> aiosqlite.Connection:
|
||||||
"""
|
"""
|
||||||
Get a database connection.
|
Get a database connection. Delegates to shared app_db.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
aiosqlite.Connection: Database connection
|
aiosqlite.Connection: Database connection
|
||||||
"""
|
"""
|
||||||
conn = await aiosqlite.connect(DB_PATH)
|
return await _get_app_db()
|
||||||
conn.row_factory = aiosqlite.Row # Enable column access by name
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
async def init_database() -> None:
|
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.
|
Safe to call multiple times - only creates tables if they don't exist.
|
||||||
"""
|
"""
|
||||||
try:
|
await init_app_db()
|
||||||
# Ensure data directory exists
|
logger.info("Database initialized successfully (delegated to app_db)")
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_expired_codes() -> int:
|
async def cleanup_expired_codes() -> int:
|
||||||
"""
|
"""
|
||||||
Delete expired authentication codes from the database.
|
Delete expired authentication codes from the database.
|
||||||
This should be called periodically (e.g., every hour).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Number of expired codes deleted
|
int: Number of expired codes deleted
|
||||||
@@ -205,7 +75,6 @@ async def cleanup_expired_codes() -> int:
|
|||||||
async def cleanup_expired_sessions() -> int:
|
async def cleanup_expired_sessions() -> int:
|
||||||
"""
|
"""
|
||||||
Delete expired sessions from the database.
|
Delete expired sessions from the database.
|
||||||
This should be called periodically (e.g., daily).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Number of expired sessions deleted
|
int: Number of expired sessions deleted
|
||||||
@@ -234,7 +103,6 @@ async def cleanup_expired_sessions() -> int:
|
|||||||
async def cleanup_expired_email_codes() -> int:
|
async def cleanup_expired_email_codes() -> int:
|
||||||
"""
|
"""
|
||||||
Delete expired and old used email codes from the database.
|
Delete expired and old used email codes from the database.
|
||||||
This should be called periodically (e.g., hourly).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: Number of email codes deleted
|
int: Number of email codes deleted
|
||||||
@@ -242,7 +110,6 @@ async def cleanup_expired_email_codes() -> int:
|
|||||||
try:
|
try:
|
||||||
async with aiosqlite.connect(DB_PATH) as db:
|
async with aiosqlite.connect(DB_PATH) as db:
|
||||||
db.row_factory = aiosqlite.Row
|
db.row_factory = aiosqlite.Row
|
||||||
# Delete expired codes or used codes older than 1 day
|
|
||||||
cursor = await db.execute("""
|
cursor = await db.execute("""
|
||||||
DELETE FROM email_auth_codes
|
DELETE FROM email_auth_codes
|
||||||
WHERE expires_at < ?
|
WHERE expires_at < ?
|
||||||
@@ -311,15 +178,3 @@ async def get_database_stats() -> dict:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get database stats: {e}")
|
logger.error(f"Failed to get database stats: {e}")
|
||||||
return {}
|
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)",
|
description="ID-ul serverului Oracle pentru autentificare (opțional în modul single-server)",
|
||||||
example="romfast"
|
example="romfast"
|
||||||
)
|
)
|
||||||
|
trusted_device_token: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Token de trusted device din localStorage (pentru skip 2FA)"
|
||||||
|
)
|
||||||
|
|
||||||
@validator('username')
|
@validator('username')
|
||||||
def username_alphanumeric(cls, v):
|
def username_alphanumeric(cls, v):
|
||||||
@@ -83,17 +87,25 @@ class TokenResponse(BaseModel):
|
|||||||
"""Model pentru răspunsul de autentificare cu token-uri"""
|
"""Model pentru răspunsul de autentificare cu token-uri"""
|
||||||
access_token: str = Field(description="JWT access token")
|
access_token: str = Field(description="JWT access token")
|
||||||
refresh_token: Optional[str] = Field(
|
refresh_token: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="JWT refresh token (opțional)"
|
description="JWT refresh token (opțional)"
|
||||||
)
|
)
|
||||||
token_type: str = Field(
|
token_type: str = Field(
|
||||||
default="bearer",
|
default="bearer",
|
||||||
description="Tipul token-ului (întotdeauna 'bearer')"
|
description="Tipul token-ului (întotdeauna 'bearer')"
|
||||||
)
|
)
|
||||||
expires_in: int = Field(
|
expires_in: int = Field(
|
||||||
description="Timpul de expirare al access token-ului în secunde"
|
description="Timpul de expirare al access token-ului în secunde"
|
||||||
)
|
)
|
||||||
user: 'CurrentUser' = Field(description="Informațiile utilizatorului autentificat")
|
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):
|
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
|
# 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
|
import logging
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, status, Request, Response
|
||||||
@@ -24,7 +24,9 @@ from .models import (
|
|||||||
LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest,
|
LoginRequest, TokenResponse, RefreshTokenRequest, LogoutRequest,
|
||||||
CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse,
|
CurrentUser, UserCompany, CompanyAccessRequest, CompanyAccessResponse,
|
||||||
AuthError, AuthStats, CheckEmailRequest, CheckEmailResponse, ServerInfo,
|
AuthError, AuthStats, CheckEmailRequest, CheckEmailResponse, ServerInfo,
|
||||||
CheckIdentityRequest, CheckIdentityResponse
|
CheckIdentityRequest, CheckIdentityResponse,
|
||||||
|
LoginRequires2FAResponse, Verify2FARequest, Resend2FARequest,
|
||||||
|
VerifyBackupCodeRequest,
|
||||||
)
|
)
|
||||||
from .auth_service import auth_service, AuthenticationError
|
from .auth_service import auth_service, AuthenticationError
|
||||||
from .jwt_handler import jwt_handler
|
from .jwt_handler import jwt_handler
|
||||||
@@ -33,6 +35,16 @@ from .dependencies import (
|
|||||||
security_required, security_optional
|
security_required, security_optional
|
||||||
)
|
)
|
||||||
from .middleware import default_rate_limiter, RateLimiter
|
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__)
|
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
|
# Rate limiter pentru check-identity/check-email: 5 requests per minut per IP
|
||||||
check_identity_rate_limiter = RateLimiter(max_requests=5, time_window=60)
|
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)
|
@router.post("/check-identity", response_model=CheckIdentityResponse, status_code=status.HTTP_200_OK)
|
||||||
async def check_identity(
|
async def check_identity(
|
||||||
check_data: CheckIdentityRequest,
|
check_data: CheckIdentityRequest,
|
||||||
@@ -223,103 +309,346 @@ def create_auth_router(
|
|||||||
detail="Error checking email"
|
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(
|
async def login(
|
||||||
login_data: LoginRequest,
|
login_data: LoginRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response
|
response: Response,
|
||||||
) -> TokenResponse:
|
):
|
||||||
"""
|
"""
|
||||||
Autentifică un utilizator și returnează token-urile JWT
|
Autentifică un utilizator.
|
||||||
|
|
||||||
Acest endpoint:
|
Flow cu 2FA (utilizator are email în Oracle):
|
||||||
- Validează credențialele utilizatorului în Oracle
|
1. Verifică credențialele în Oracle
|
||||||
- Obține firmele la care utilizatorul are acces
|
2. Trimite cod OTP pe email
|
||||||
- Generează access și refresh token-uri JWT
|
3. Returnează {requires_2fa: true, masked_email, email}
|
||||||
- Aplică rate limiting pentru securitate
|
→ Frontend afișează câmpul de cod
|
||||||
- Suportă modul multi-server (server_id opțional)
|
→ Userul introduce codul → POST /auth/verify-2fa-code → JWT
|
||||||
|
|
||||||
Args:
|
Fallback fără 2FA (utilizator fără email):
|
||||||
login_data: Datele de autentificare (username, password, server_id opțional)
|
- Returnează TokenResponse direct (comportament anterior)
|
||||||
request: Request-ul HTTP (pentru rate limiting)
|
|
||||||
response: Response-ul HTTP (pentru header-e)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Token-urile JWT și informațiile utilizatorului
|
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 400: Pentru server_id invalid
|
HTTPException 400: server_id invalid
|
||||||
HTTPException 401: Pentru credențiale invalide
|
HTTPException 401: credențiale invalide
|
||||||
HTTPException 500: Pentru erori de sistem
|
HTTPException 429: rate limit OTP depășit
|
||||||
|
HTTPException 503: email service indisponibil
|
||||||
|
HTTPException 500: eroare internă
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Log tentativa de autentificare
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
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 ""
|
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:
|
if login_data.server_id:
|
||||||
from backend.config import settings
|
from backend.config import settings
|
||||||
from shared.database.oracle_pool import oracle_pool
|
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)
|
server_config = settings.get_oracle_server(login_data.server_id)
|
||||||
if not server_config:
|
if not server_config:
|
||||||
logger.warning(f"Invalid server_id '{login_data.server_id}' in login request")
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Invalid server_id: '{login_data.server_id}'. Server not found in configuration."
|
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):
|
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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=f"Server '{login_data.server_id}' is not available."
|
detail=f"Server '{login_data.server_id}' is not available."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Autentifică și creează token-urile
|
# Pas 1: Rezolvă email → username dacă input conține '@'
|
||||||
success, token_response, error_message = await auth_service.authenticate_and_create_tokens(
|
actual_username = login_data.username
|
||||||
login_data.username,
|
input_email: Optional[str] = None
|
||||||
login_data.password,
|
|
||||||
login_data.server_id
|
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 is_valid:
|
||||||
if not success:
|
logger.warning(f"[LOGIN] Failed credentials for '{actual_username}'{server_info}")
|
||||||
logger.warning(f"Failed login attempt for user {login_data.username}{server_info}: {error_message}")
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
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
|
# Pas 3: Caută emailul utilizatorului (dacă nu îl știm deja din input)
|
||||||
# cu username-ul Oracle rezolvat (nu email-ul) și lista de firme
|
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
|
# Pas 2.5: Verificare trusted device — skip 2FA dacă tokenul e valid
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
if login_data.trusted_device_token and user_email:
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
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:
|
except HTTPException:
|
||||||
# Re-raise HTTP exceptions as-is (e.g., 401 for invalid credentials)
|
|
||||||
raise
|
raise
|
||||||
except AuthenticationError as e:
|
except AuthenticationError as e:
|
||||||
logger.error(f"Authentication error for user {login_data.username}: {str(e)}")
|
logger.error(f"[LOGIN] Authentication error for '{login_data.username}': {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
||||||
detail=str(e)
|
|
||||||
)
|
|
||||||
except Exception as 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(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Internal authentication 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)
|
@router.post("/refresh", response_model=TokenResponse, status_code=status.HTTP_200_OK)
|
||||||
async def refresh_token(refresh_data: RefreshTokenRequest) -> TokenResponse:
|
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) => {
|
const handleServerSwitched = async (newServerId) => {
|
||||||
console.log('[App] Server switched to:', 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)
|
// Reset period store for the new server context (US-010)
|
||||||
periodStore.reset()
|
periodStore.reset()
|
||||||
console.log('[App] Period store reset after server switch')
|
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 login form uses default styling (inherits from global rules above) */
|
||||||
/* Server dropdown in header is styled in header.css to match CompanySelector */
|
/* 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) ===== */
|
/* ===== Server Switch Password Modal (Mobile) ===== */
|
||||||
/* These styles must be global because Dialog is teleported to body */
|
/* 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
|
// Handle server switch completed - reload data for new server
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies()
|
await companyStore.loadCompanies()
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -431,7 +431,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switch completed - reload data for new server
|
// Handle server switch completed - reload data for new server
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -428,7 +428,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switched event from drawer menu
|
// Handle server switched event from drawer menu
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -428,7 +428,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switched event from drawer menu
|
// Handle server switched event from drawer menu
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -791,7 +791,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switched from drawer menu (password already verified in modal)
|
// Handle server switched from drawer menu (password already verified in modal)
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload companies and periods for the new server
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -768,7 +768,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switch completed - reload data for new server
|
// Handle server switch completed - reload data for new server
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies()
|
await companyStore.loadCompanies()
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -442,7 +442,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switched event from drawer menu
|
// Handle server switched event from drawer menu
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -172,7 +172,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switch completed - reload data for new server
|
// Handle server switch completed - reload data for new server
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies()
|
await companyStore.loadCompanies()
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -92,10 +92,12 @@ import { useRouter } from 'vue-router'
|
|||||||
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
||||||
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
||||||
import MobileDrawerMenu from '@shared/components/mobile/MobileDrawerMenu.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 router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
const companyStore = useCompanyStore()
|
||||||
|
const periodStore = useAccountingPeriodStore()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const showDrawer = ref(false)
|
const showDrawer = ref(false)
|
||||||
@@ -117,8 +119,9 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switch completed
|
// Handle server switch completed
|
||||||
const handleServerSwitched = async (newServerId) => {
|
const handleServerSwitched = async (newServerId) => {
|
||||||
// Server switch already completed in MobileDrawerMenu modal
|
// Clear selected company and period FIRST to prevent stale data from old server
|
||||||
// SettingsHubView doesn't need to reload data - it's just a navigation hub
|
companyStore.clearSelectedCompany()
|
||||||
|
periodStore.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// US-307: Removed custom mobileNavItems - using MobileBottomNav defaults
|
// US-307: Removed custom mobileNavItems - using MobileBottomNav defaults
|
||||||
|
|||||||
@@ -409,7 +409,10 @@ const handleLogout = async () => {
|
|||||||
|
|
||||||
// Handle server switch completed - reload data for new server
|
// Handle server switch completed - reload data for new server
|
||||||
const handleServerSwitched = async (newServerId) => {
|
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
|
// Reload data for the new server
|
||||||
await companyStore.loadCompanies();
|
await companyStore.loadCompanies();
|
||||||
if (companyStore.selectedCompany?.id_firma) {
|
if (companyStore.selectedCompany?.id_firma) {
|
||||||
|
|||||||
@@ -17,7 +17,81 @@
|
|||||||
<p>Se încarcă...</p>
|
<p>Se încarcă...</p>
|
||||||
</div>
|
</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
|
<form
|
||||||
v-else
|
v-else
|
||||||
class="login-form"
|
class="login-form"
|
||||||
@@ -107,6 +181,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -115,6 +221,8 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { useToast } from "primevue/usetoast";
|
import { useToast } from "primevue/usetoast";
|
||||||
|
import Checkbox from "primevue/checkbox";
|
||||||
|
import Dialog from "primevue/dialog";
|
||||||
import Dropdown from "primevue/dropdown";
|
import Dropdown from "primevue/dropdown";
|
||||||
import Password from "primevue/password";
|
import Password from "primevue/password";
|
||||||
|
|
||||||
@@ -146,7 +254,7 @@ const router = useRouter();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
// Form data
|
// Form data — login normal
|
||||||
const identity = ref("");
|
const identity = ref("");
|
||||||
const identityError = ref("");
|
const identityError = ref("");
|
||||||
const selectedServer = ref(null);
|
const selectedServer = ref(null);
|
||||||
@@ -154,6 +262,16 @@ const serverError = ref("");
|
|||||||
const password = ref("");
|
const password = ref("");
|
||||||
const passwordError = 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
|
// Internal state for server loading
|
||||||
const isIdentityVerified = ref(false);
|
const isIdentityVerified = ref(false);
|
||||||
|
|
||||||
@@ -176,7 +294,9 @@ const canSubmit = computed(() => {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Methods
|
// ============================================================================
|
||||||
|
// LOGIN FORM HANDLERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
const clearPasswordError = () => {
|
const clearPasswordError = () => {
|
||||||
passwordError.value = "";
|
passwordError.value = "";
|
||||||
@@ -273,6 +393,17 @@ const handleLogin = async () => {
|
|||||||
|
|
||||||
const result = await props.authStore.login(credentials);
|
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) {
|
if (result.success) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: "success",
|
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
|
// Clear errors on mount
|
||||||
const clearErrors = () => {
|
const clearErrors = () => {
|
||||||
props.authStore.clearError();
|
props.authStore.clearError();
|
||||||
identityError.value = "";
|
identityError.value = "";
|
||||||
passwordError.value = "";
|
passwordError.value = "";
|
||||||
serverError.value = "";
|
serverError.value = "";
|
||||||
|
unifiedCode.value = "";
|
||||||
|
otpError.value = "";
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for selectedServerId changes from store (pre-selection from localStorage)
|
// Watch for selectedServerId changes from store (pre-selection from localStorage)
|
||||||
@@ -386,4 +627,144 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* Server dropdown - use normal styling like other form inputs */
|
/* Server dropdown - use normal styling like other form inputs */
|
||||||
/* No special overrides needed - inherits from primevue-overrides.css */
|
/* 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>
|
</style>
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Server Switch Password Modal (US-009) -->
|
<!-- Server Switch Modal - Password + 2FA steps (US-009) -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="showPasswordModal"
|
v-model:visible="showPasswordModal"
|
||||||
:header="`Schimbare server: ${targetServerName}`"
|
:header="`Schimbare server: ${targetServerName}`"
|
||||||
@@ -80,19 +80,75 @@
|
|||||||
class="server-switch-modal"
|
class="server-switch-modal"
|
||||||
>
|
>
|
||||||
<div class="server-switch-modal-content">
|
<div class="server-switch-modal-content">
|
||||||
<div class="form-field">
|
<!-- Step 1: Password -->
|
||||||
<Password
|
<template v-if="switchStep === 'password'">
|
||||||
id="switch-password"
|
<div class="form-field">
|
||||||
v-model="switchPassword"
|
<Password
|
||||||
:feedback="false"
|
id="switch-password"
|
||||||
:toggleMask="true"
|
v-model="switchPassword"
|
||||||
inputClass="w-full"
|
:feedback="false"
|
||||||
class="w-full"
|
:toggleMask="true"
|
||||||
:disabled="isSwitching"
|
inputClass="w-full"
|
||||||
@keyup.enter="confirmServerSwitch"
|
:inputStyle="passwordInputStyle"
|
||||||
autofocus
|
class="w-full"
|
||||||
/>
|
:disabled="isSwitching"
|
||||||
</div>
|
@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">
|
<div v-if="switchError" class="switch-error">
|
||||||
<i class="pi pi-exclamation-circle"></i>
|
<i class="pi pi-exclamation-circle"></i>
|
||||||
@@ -101,18 +157,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button
|
<!-- Password step footer -->
|
||||||
label="Anulează"
|
<template v-if="switchStep === 'password'">
|
||||||
severity="secondary"
|
<Button
|
||||||
:disabled="isSwitching"
|
label="Anulează"
|
||||||
@click="cancelServerSwitch"
|
severity="secondary"
|
||||||
/>
|
:disabled="isSwitching"
|
||||||
<Button
|
@click="cancelServerSwitch"
|
||||||
label="Confirma"
|
/>
|
||||||
:loading="isSwitching"
|
<Button
|
||||||
:disabled="!switchPassword"
|
label="Confirmă"
|
||||||
@click="confirmServerSwitch"
|
: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>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</header>
|
</header>
|
||||||
@@ -126,6 +200,8 @@ import ServerSelector from "../ServerSelector.vue";
|
|||||||
import Dialog from "primevue/dialog";
|
import Dialog from "primevue/dialog";
|
||||||
import Password from "primevue/password";
|
import Password from "primevue/password";
|
||||||
import Button from "primevue/button";
|
import Button from "primevue/button";
|
||||||
|
import InputText from "primevue/inputtext";
|
||||||
|
import Checkbox from "primevue/checkbox";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "AppHeader",
|
name: "AppHeader",
|
||||||
@@ -136,6 +212,8 @@ export default {
|
|||||||
Dialog,
|
Dialog,
|
||||||
Password,
|
Password,
|
||||||
Button,
|
Button,
|
||||||
|
InputText,
|
||||||
|
Checkbox,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
// Header title/brand text
|
// Header title/brand text
|
||||||
@@ -226,6 +304,21 @@ export default {
|
|||||||
return server?.name || serverId;
|
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)
|
// Password confirmation modal state (US-009)
|
||||||
const showPasswordModal = ref(false);
|
const showPasswordModal = ref(false);
|
||||||
const switchPassword = ref("");
|
const switchPassword = ref("");
|
||||||
@@ -234,6 +327,16 @@ export default {
|
|||||||
const isSwitching = ref(false);
|
const isSwitching = ref(false);
|
||||||
const switchError = ref("");
|
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 onServerChange = (event) => {
|
||||||
const newServerId = event.value;
|
const newServerId = event.value;
|
||||||
// Don't process if same server selected
|
// Don't process if same server selected
|
||||||
@@ -261,8 +364,33 @@ export default {
|
|||||||
showPasswordModal.value = false;
|
showPasswordModal.value = false;
|
||||||
switchPassword.value = "";
|
switchPassword.value = "";
|
||||||
switchError.value = "";
|
switchError.value = "";
|
||||||
|
switchOtpCode.value = "";
|
||||||
|
switchTrustDevice.value = false;
|
||||||
|
switchMaskedEmail.value = "";
|
||||||
|
switchStep.value = "password";
|
||||||
|
showSwitchBackupInput.value = false;
|
||||||
|
switchBackupCode.value = "";
|
||||||
targetServerId.value = null;
|
targetServerId.value = null;
|
||||||
targetServerName.value = "";
|
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 () => {
|
const confirmServerSwitch = async () => {
|
||||||
@@ -283,16 +411,13 @@ export default {
|
|||||||
try {
|
try {
|
||||||
const result = await props.authStore.switchServer(targetServerId.value, switchPassword.value);
|
const result = await props.authStore.switchServer(targetServerId.value, switchPassword.value);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.requires_2fa) {
|
||||||
// Close modal
|
// Show 2FA step — don't close modal
|
||||||
showPasswordModal.value = false;
|
switchStep.value = "2fa";
|
||||||
switchPassword.value = "";
|
switchMaskedEmail.value = result.masked_email || "";
|
||||||
|
switchError.value = "";
|
||||||
// Update local state to reflect new server
|
} else if (result.success) {
|
||||||
currentServerId.value = targetServerId.value;
|
finishServerSwitch();
|
||||||
|
|
||||||
// Emit event for parent to reload data (companies, periods)
|
|
||||||
emit("server-switched", targetServerId.value);
|
|
||||||
} else {
|
} else {
|
||||||
// Show error in modal
|
// Show error in modal
|
||||||
switchError.value = result.error || "Autentificare eșuată";
|
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) => {
|
const onCompanyChanged = (company) => {
|
||||||
emit("company-changed", company);
|
emit("company-changed", company);
|
||||||
};
|
};
|
||||||
@@ -373,7 +544,81 @@ export default {
|
|||||||
switchError,
|
switchError,
|
||||||
cancelServerSwitch,
|
cancelServerSwitch,
|
||||||
confirmServerSwitch,
|
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>
|
</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>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- Server Switch Password Modal -->
|
<!-- Server Switch Modal - Password + 2FA steps -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="showServerPasswordModal"
|
v-model:visible="showServerPasswordModal"
|
||||||
:header="`Schimbare server: ${targetServerName}`"
|
:header="`Schimbare server: ${targetServerName}`"
|
||||||
@@ -259,16 +259,49 @@
|
|||||||
class="mobile-server-switch-modal"
|
class="mobile-server-switch-modal"
|
||||||
>
|
>
|
||||||
<div class="server-switch-modal-content">
|
<div class="server-switch-modal-content">
|
||||||
<Password
|
<!-- Step 1: Password -->
|
||||||
v-model="serverSwitchPassword"
|
<template v-if="switchStep === 'password'">
|
||||||
:feedback="false"
|
<Password
|
||||||
toggleMask
|
v-model="serverSwitchPassword"
|
||||||
inputClass="w-full"
|
:feedback="false"
|
||||||
class="w-full"
|
toggleMask
|
||||||
:disabled="isSwitchingServer"
|
inputClass="w-full"
|
||||||
@keyup.enter="confirmServerSwitch"
|
class="w-full"
|
||||||
autofocus
|
: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">
|
<div v-if="serverSwitchError" class="switch-error">
|
||||||
<i class="pi pi-exclamation-circle"></i>
|
<i class="pi pi-exclamation-circle"></i>
|
||||||
@@ -277,18 +310,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button
|
<!-- Password step footer -->
|
||||||
label="Anulează"
|
<template v-if="switchStep === 'password'">
|
||||||
severity="secondary"
|
<Button
|
||||||
:disabled="isSwitchingServer"
|
label="Anulează"
|
||||||
@click="cancelServerSwitch"
|
severity="secondary"
|
||||||
/>
|
:disabled="isSwitchingServer"
|
||||||
<Button
|
@click="cancelServerSwitch"
|
||||||
label="Confirmă"
|
/>
|
||||||
:loading="isSwitchingServer"
|
<Button
|
||||||
:disabled="!serverSwitchPassword"
|
label="Confirmă"
|
||||||
@click="confirmServerSwitch"
|
: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>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -300,6 +351,8 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import Dialog from 'primevue/dialog'
|
import Dialog from 'primevue/dialog'
|
||||||
import Password from 'primevue/password'
|
import Password from 'primevue/password'
|
||||||
import Button from 'primevue/button'
|
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)
|
* MobileDrawerMenu - Material Design 3 inspired navigation drawer for mobile (v4)
|
||||||
@@ -430,6 +483,12 @@ const targetServerName = ref('')
|
|||||||
const isSwitchingServer = ref(false)
|
const isSwitchingServer = ref(false)
|
||||||
const serverSwitchError = ref('')
|
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
|
// US-608: Removed collapsible state management - using direct dropdowns now
|
||||||
|
|
||||||
// Computed properties for company selector
|
// Computed properties for company selector
|
||||||
@@ -540,6 +599,27 @@ const cancelServerSwitch = () => {
|
|||||||
showServerPasswordModal.value = false
|
showServerPasswordModal.value = false
|
||||||
serverSwitchPassword.value = ''
|
serverSwitchPassword.value = ''
|
||||||
serverSwitchError.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 () => {
|
const confirmServerSwitch = async () => {
|
||||||
@@ -559,16 +639,13 @@ const confirmServerSwitch = async () => {
|
|||||||
try {
|
try {
|
||||||
const result = await props.authStore.switchServer(targetServerId.value, serverSwitchPassword.value)
|
const result = await props.authStore.switchServer(targetServerId.value, serverSwitchPassword.value)
|
||||||
|
|
||||||
if (result.success) {
|
if (result.requires_2fa) {
|
||||||
// Close modal and drawer
|
// Show 2FA step — don't close modal
|
||||||
showServerPasswordModal.value = false
|
switchStep.value = '2fa'
|
||||||
serverSwitchPassword.value = ''
|
switchMaskedEmail.value = result.masked_email || ''
|
||||||
|
serverSwitchError.value = ''
|
||||||
// Emit event for parent to reload data
|
} else if (result.success) {
|
||||||
emit('server-switched', targetServerId.value)
|
finishServerSwitch()
|
||||||
|
|
||||||
// Close the drawer after successful switch
|
|
||||||
close()
|
|
||||||
} else {
|
} else {
|
||||||
serverSwitchError.value = result.error || 'Autentificare eșuată'
|
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
|
// Close dropdowns when drawer closes
|
||||||
watch(() => props.modelValue, (isOpen) => {
|
watch(() => props.modelValue, (isOpen) => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
@@ -1634,6 +1749,47 @@ onMounted(() => {
|
|||||||
width: 100%;
|
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 {
|
.switch-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -28,6 +28,31 @@ const STORAGE_KEYS = {
|
|||||||
AUTH_MODE: "auth_mode",
|
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
|
* Factory function to create an auth store with the provided API service
|
||||||
* @param {Object} apiService - Axios instance configured for the app's API
|
* @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)
|
// Allows URL bookmark to pre-select a server (e.g., /login?server=romfast)
|
||||||
const preselectedServerId = ref(null);
|
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
|
// Getters
|
||||||
const isAuthenticated = computed(() => !!accessToken.value);
|
const isAuthenticated = computed(() => !!accessToken.value);
|
||||||
const currentUser = computed(() => user.value);
|
const currentUser = computed(() => user.value);
|
||||||
@@ -217,8 +254,30 @@ export function createAuthStore(apiService, options = {}) {
|
|||||||
payload.server_id = credentials.server_id;
|
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 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
|
// IMPORTANT: Update selectedServerId BEFORE user.value to ensure
|
||||||
// the companies store watch uses the correct server ID for localStorage key
|
// the companies store watch uses the correct server ID for localStorage key
|
||||||
@@ -273,6 +332,13 @@ export function createAuthStore(apiService, options = {}) {
|
|||||||
availableServers.value = [];
|
availableServers.value = [];
|
||||||
// Note: Don't clear selectedServerId - keep it for next login pre-selection
|
// 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.ACCESS_TOKEN);
|
||||||
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
|
||||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||||
@@ -342,6 +408,13 @@ export function createAuthStore(apiService, options = {}) {
|
|||||||
availableServers.value = [];
|
availableServers.value = [];
|
||||||
error.value = null;
|
error.value = null;
|
||||||
// Keep selectedServerId from localStorage for pre-selection
|
// 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;
|
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)
|
* Switch to a different server without full logout (US-007)
|
||||||
* Re-authenticates the current user on the new server.
|
* 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
|
isAuthenticating, // Flag pentru a preveni 401 redirect în timpul login/server-switch
|
||||||
preselectedServerId, // US-004: URL bookmark pre-selection
|
preselectedServerId, // US-004: URL bookmark pre-selection
|
||||||
|
|
||||||
|
// State - 2FA
|
||||||
|
otpEmail,
|
||||||
|
otpMaskedEmail,
|
||||||
|
is2FALoading,
|
||||||
|
resendCountdown,
|
||||||
|
pendingServerId,
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
currentUser,
|
currentUser,
|
||||||
@@ -454,6 +716,11 @@ export function createAuthStore(apiService, options = {}) {
|
|||||||
|
|
||||||
// Actions - Server switch (US-007)
|
// Actions - Server switch (US-007)
|
||||||
switchServer,
|
switchServer,
|
||||||
|
|
||||||
|
// Actions - 2FA
|
||||||
|
verify2FA,
|
||||||
|
resendOTP,
|
||||||
|
verifyBackupCode,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user