diff --git a/src/Server.py b/src/Server.py index 3950531..3678660 100644 --- a/src/Server.py +++ b/src/Server.py @@ -10,6 +10,8 @@ import asyncio import secrets import hashlib import hmac +import base64 +import urllib.parse import time import html import warnings @@ -276,6 +278,89 @@ def change_user_password(username: str, new_password: str, db_path: str = DEFAUL return changed +# --- Two-Factor Authentication (TOTP / RFC 6238) --- + +def generate_totp_secret(length: int = 20) -> str: + """Generates a cryptographically random Base32-encoded TOTP secret key.""" + return base64.b32encode(secrets.token_bytes(length)).decode("utf-8").replace("=", "") + + +def get_totp_token(secret: str, intervals_no: Optional[int] = None) -> str: + """Calculates a 6-digit TOTP code for the given counter interval according to RFC 6238.""" + if intervals_no is None: + intervals_no = int(time.time()) // 30 + key = base64.b32decode(secret + "=" * ((8 - len(secret) % 8) % 8), casefold=True) + msg = struct.pack(">Q", intervals_no) + h = hmac.new(key, msg, hashlib.sha1).digest() + o = h[19] & 15 + h_int = (struct.unpack(">I", h[o:o+4])[0] & 0x7fffffff) % 1000000 + return f"{h_int:06d}" + + +def verify_totp_code(secret: str, code: str, window: int = 1) -> bool: + """Verifies a 6-digit TOTP code with standard drift window (+/- 30s).""" + if not secret or not code: + return False + code = str(code).strip().replace(" ", "") + if len(code) != 6 or not code.isdigit(): + return False + now = int(time.time()) + current_step = now // 30 + for offset in range(-window, window + 1): + if hmac.compare_digest(get_totp_token(secret, current_step + offset), code): + return True + return False + + +def get_totp_uri(username: str, secret: str, issuer: str = "LOGAR Server Hub") -> str: + """Generates standard otpauth URI for QR code generation.""" + user_enc = urllib.parse.quote(username) + issuer_enc = urllib.parse.quote(issuer) + return f"otpauth://totp/{issuer_enc}:{user_enc}?secret={secret}&issuer={issuer_enc}&algorithm=SHA1&digits=6&period=30" + + +def get_user_totp_info(username: str, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: + """Retrieves TOTP configuration for a user from the SQLite database.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("SELECT totp_secret, totp_enabled FROM users WHERE username = ?", (username,)) + row = c.fetchone() + if row: + return {"secret": row[0] or "", "enabled": bool(row[1])} + except Exception: + pass + finally: + conn.close() + return {"secret": "", "enabled": False} + + +def enable_user_totp(username: str, secret: str, db_path: str = DEFAULT_DB_FILE) -> bool: + """Enables and activates TOTP for a user in the SQLite database.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("UPDATE users SET totp_secret = ?, totp_enabled = 1 WHERE username = ?", (secret, username)) + changed = c.rowcount > 0 + conn.commit() + return changed + finally: + conn.close() + + +def disable_user_totp(username: str, db_path: str = DEFAULT_DB_FILE) -> bool: + """Disables TOTP for a user in the SQLite database.""" + conn = sqlite3.connect(db_path) + c = conn.cursor() + try: + c.execute("UPDATE users SET totp_secret = NULL, totp_enabled = 0 WHERE username = ?", (username,)) + changed = c.rowcount > 0 + conn.commit() + return changed + finally: + conn.close() + + # --- Hermes Token Management --- def create_hermes_token(name: str, token: Optional[str] = None, db_path: str = DEFAULT_DB_FILE) -> Dict[str, Any]: @@ -765,10 +850,22 @@ def init_db(db_path: str, enrollment_secret: Optional[str] = None, max_seats: in password_hash TEXT NOT NULL, salt TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'admin', - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + totp_secret TEXT, + totp_enabled INTEGER DEFAULT 0 ) """) + # Migrations for users table (2FA) + for col, col_def in [ + ("totp_secret", "TEXT"), + ("totp_enabled", "INTEGER DEFAULT 0") + ]: + try: + conn.execute(f"ALTER TABLE users ADD COLUMN {col} {col_def}") + except sqlite3.OperationalError: + pass + # Ensure default admin user exists c = conn.cursor() c.execute("SELECT COUNT(*) FROM users") @@ -1342,14 +1439,19 @@ def login_page(request: Request):