Specs stay at repo root (cross-VM). Move deploy and code into logical projects with README per domain, updated manifest.yaml, and symlinks at legacy paths for VM122 backward compatibility.
380 lines
12 KiB
Python
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
|