ligbox-ops-platform/api/app/auth.py
Ligbox Spec Hub 3a2c64834b Initial import: ligbox-ops-platform + specs + LAPTOP + obsidian merge (CT130)
Source: VM122 /opt + obsidian-infra + LAPTOP
Hub: CT130 spec-hub 10.10.10.130
2026-06-19 17:26:41 +00:00

380 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
from passlib.context import CryptContext
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", "")
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
_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)
conn.row_factory = sqlite3.Row
return conn
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
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()
with db() as conn:
conn.execute(
"UPDATE desk_users SET last_login_at = ?, updated_at = ? WHERE username = ?",
(now, now, username),
)
conn.commit()
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