feat(auth): add 2FA with OTP, backup codes and trusted devices

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-02-24 17:25:00 +00:00
parent b001b94e37
commit 1839285ac3
26 changed files with 2402 additions and 312 deletions

View File

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

View File

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

View File

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