"""Authentication and JWT for Ligbox Ops Desk.""" from __future__ import annotations import os import secrets import sqlite3 import time from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from fastapi import Depends, Header, HTTPException, Request from jose import JWTError, jwt import bcrypt from app.permissions import HUMAN_ROLES from app.totp_util import verify_code as verify_totp_code DB_PATH = Path(os.getenv("SQLITE_PATH", "/data/ops.db")) JWT_SECRET = os.getenv("JWT_SECRET", "ligbox-ops-change-me-in-production") JWT_ALGORITHM = "HS256" JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "8")) DESK_AUTH_ENABLED = os.getenv("DESK_AUTH_ENABLED", "true").lower() in ("1", "true", "yes") DESK_BOOTSTRAP_PASSWORD = os.getenv("DESK_BOOTSTRAP_PASSWORD", "805353") AUTH_LOGIN_RATE_LIMIT = int(os.getenv("AUTH_LOGIN_RATE_LIMIT", "5")) OPS_INTERNAL_TOKEN = os.getenv("OPS_INTERNAL_TOKEN", "") _login_attempts: dict[str, list[float]] = {} _mfa_pending: dict[str, tuple[str, float]] = {} MFA_TOKEN_TTL_SEC = 300 SEED_USERS = ( ("root", "super_admin", "Roger"), ("admin", "ops_lead", "Chefe Ops"), ("mini", "technician", "Suporte"), ("noc", "noc", "NOC"), ) @dataclass class DeskUser: username: str role: str display_name: str | None = None active: bool = True @property def is_authenticated(self) -> bool: return bool(self.username) def db() -> sqlite3.Connection: DB_PATH.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH, timeout=30.0, check_same_thread=False) conn.row_factory = sqlite3.Row conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA busy_timeout=60000") return conn def hash_password(password: str) -> str: return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") def verify_password(plain: str, hashed: str) -> bool: if not plain or not hashed: return False try: return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8")) except (ValueError, TypeError): return False def init_auth_schema(conn: sqlite3.Connection) -> None: from app import backup_codes, mfa_recovery_store, registration_store conn.execute( """ CREATE TABLE IF NOT EXISTS desk_users ( id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, role TEXT NOT NULL, display_name TEXT, active INTEGER NOT NULL DEFAULT 1, last_login_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) """ ) registration_store.init_registration_schema(conn) backup_codes.init_backup_schema(conn) mfa_recovery_store.init_recovery_schema(conn) cols = {row[1] for row in conn.execute("PRAGMA table_info(tickets)").fetchall()} if "assigned_to" not in cols: conn.execute("ALTER TABLE tickets ADD COLUMN assigned_to TEXT") if "assigned_at" not in cols: conn.execute("ALTER TABLE tickets ADD COLUMN assigned_at TEXT") count = conn.execute("SELECT COUNT(*) c FROM desk_users").fetchone()["c"] if count == 0: now = datetime.now(timezone.utc).isoformat() for username, role, display_name in SEED_USERS: conn.execute( """ INSERT INTO desk_users (username, password_hash, role, display_name, active, created_at, updated_at) VALUES (?, ?, ?, ?, 1, ?, ?) """, (username, hash_password(DESK_BOOTSTRAP_PASSWORD), role, display_name, now, now), ) def create_access_token(user: DeskUser) -> tuple[str, int]: expires = timedelta(hours=JWT_EXPIRE_HOURS) expire_at = datetime.now(timezone.utc) + expires payload = { "sub": user.username, "role": user.role, "exp": expire_at, "iat": datetime.now(timezone.utc), } token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) return token, int(expires.total_seconds()) def decode_token(token: str) -> DeskUser: try: payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) except JWTError as exc: raise HTTPException(401, "invalid or expired token") from exc username = payload.get("sub") role = payload.get("role") if not username or role not in HUMAN_ROLES: raise HTTPException(401, "invalid token claims") with db() as conn: row = conn.execute( "SELECT username, role, display_name, active FROM desk_users WHERE username = ?", (username,), ).fetchone() if not row or not row["active"]: raise HTTPException(401, "user inactive or not found") return DeskUser( username=row["username"], role=row["role"], display_name=row["display_name"], active=bool(row["active"]), ) def _normalize_login(username: str) -> str: u = username.strip() if u.lower() == "root": return "root" if "@" in u: return u.lower() return u.lower() def _user_row(login: str) -> sqlite3.Row | None: with db() as conn: return conn.execute( """ SELECT username, password_hash, role, display_name, active, totp_secret, totp_enabled FROM desk_users WHERE username = ? OR email = ? """, (login, login), ).fetchone() def check_credentials(username: str, password: str) -> tuple[DeskUser | None, sqlite3.Row | None]: login = _normalize_login(username) row = _user_row(login) if not row or not row["active"]: return None, None if not verify_password(password, row["password_hash"]): return None, None user = DeskUser( username=row["username"], role=row["role"], display_name=row["display_name"], active=True, ) return user, row def touch_last_login(username: str) -> None: now = datetime.now(timezone.utc).isoformat() for attempt in range(8): try: with db() as conn: conn.execute( "UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?", (now, now, username), ) conn.commit() return except sqlite3.OperationalError as exc: if "locked" not in str(exc).lower() or attempt >= 7: raise time.sleep(0.25 * (attempt + 1)) def authenticate_user(username: str, password: str) -> DeskUser | None: user, _row = check_credentials(username, password) if not user: return None touch_last_login(user.username) return user def user_requires_totp(row: sqlite3.Row | None) -> bool: if not row: return False return bool(row["totp_enabled"] and row["totp_secret"]) def create_mfa_token(username: str) -> str: token = secrets.token_urlsafe(32) _mfa_pending[token] = (username, time.time() + MFA_TOKEN_TTL_SEC) return token def peek_mfa_token(token: str) -> str | None: entry = _mfa_pending.get(token) if not entry: return None username, expires = entry if time.time() > expires: _mfa_pending.pop(token, None) return None return username def consume_mfa_token(token: str) -> str | None: entry = _mfa_pending.pop(token, None) if not entry: return None username, expires = entry if time.time() > expires: return None return username def verify_user_totp(username: str, code: str) -> bool: row = _user_row(username) if not row or not row["totp_secret"]: return False return verify_totp_code(row["totp_secret"], code) def check_login_rate_limit(client_ip: str) -> None: now = time.time() window = 60.0 attempts = _login_attempts.setdefault(client_ip, []) attempts[:] = [t for t in attempts if now - t < window] if len(attempts) >= AUTH_LOGIN_RATE_LIMIT: raise HTTPException(429, "too many login attempts") attempts.append(now) def _extract_bearer(authorization: str | None) -> str | None: if not authorization: return None parts = authorization.split(" ", 1) if len(parts) != 2 or parts[0].lower() != "bearer": return None return parts[1].strip() or None def get_current_user_optional( authorization: str | None = Header(default=None), ) -> DeskUser | None: if not DESK_AUTH_ENABLED: return DeskUser(username="system", role="super_admin", display_name="Auth disabled") token = _extract_bearer(authorization) if not token: return None return decode_token(token) def get_current_user(user: DeskUser | None = Depends(get_current_user_optional)) -> DeskUser: if not DESK_AUTH_ENABLED: return DeskUser(username="system", role="super_admin", display_name="Auth disabled") if user is None: raise HTTPException(401, "not authenticated") return user def require_internal_or_user( request: Request, x_ops_internal_token: str | None = Header(default=None), user: DeskUser | None = Depends(get_current_user_optional), ) -> DeskUser: if OPS_INTERNAL_TOKEN and x_ops_internal_token == OPS_INTERNAL_TOKEN: return DeskUser(username="worker", role="super_admin", display_name="Internal") if not DESK_AUTH_ENABLED: return DeskUser(username="system", role="super_admin", display_name="Auth disabled") if user is None: raise HTTPException(401, "not authenticated") return user def require_roles(*roles: str): allowed = frozenset(roles) def dependency(user: DeskUser = Depends(get_current_user)) -> DeskUser: if user.role not in allowed: raise HTTPException(403, "insufficient permissions") return user return dependency def mask_value(value: Any) -> Any: if value is None: return None if isinstance(value, str): return "***" if value else value return "***" def mask_company_profile(profile: dict | None) -> dict | None: if not profile: return profile masked = dict(profile) for key in ("tax_id", "email_billing", "email_legal", "phone_landline", "phone_mobile", "contact_phone"): if key in masked: masked[key] = "***" if "address" in masked: masked["address"] = {} return masked def mask_ticket(ticket: dict) -> dict: out = dict(ticket) out["company_profile"] = mask_company_profile(out.get("company_profile")) out.pop("billing_state", None) payload = out.get("payload") if isinstance(payload, dict): payload = dict(payload) payload.pop("billing_state", None) if payload.get("company_profile"): payload["company_profile"] = mask_company_profile(payload["company_profile"]) notes = payload.get("funnel_notes") if isinstance(notes, list): payload["funnel_notes"] = [ { **note, "data": { **(note.get("data") or {}), "company_profile": mask_company_profile((note.get("data") or {}).get("company_profile")), }, } for note in notes ] out["payload"] = payload out.pop("email", None) return out def mask_summary_for_noc(summary: dict) -> dict: out = dict(summary) out["recent_tickets"] = [mask_ticket(t) for t in out.get("recent_tickets", [])] return out def user_public_dict(row: sqlite3.Row | dict) -> dict: if isinstance(row, sqlite3.Row): row = dict(row) out = { "username": row["username"], "role": row["role"], "display_name": row.get("display_name"), "active": bool(row.get("active", 1)), "last_login_at": row.get("last_login_at"), "created_at": row.get("created_at"), "updated_at": row.get("updated_at"), } if row.get("email"): out["email"] = row["email"] if row.get("phone"): out["phone"] = row["phone"] if "mfa_enabled" in row: out["mfa_enabled"] = bool(row.get("mfa_enabled")) if "totp_enabled" in row: out["totp_enabled"] = bool(row.get("totp_enabled")) if "backup_codes_remaining" in row: out["backup_codes_remaining"] = int(row.get("backup_codes_remaining") or 0) return out