ligbox-ops-platform/projects/ops-desk/api/app/auth.py
Ligbox Spec Hub d066586023 Serialize agentic ticks and retry SQLite writes under concurrent load.
Redis lock prevents overlapping worker ticks; auth login retries on DB locked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-19 23:36:41 +00:00

393 lines
12 KiB
Python

"""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
import time
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)
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 {"super_admin", "ops_lead", "technician", "noc"}:
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