Enable WAL/busy_timeout, retry writes, reject duplicate running jobs with HTTP 409, use bcrypt directly instead of broken passlib 1.7.4 + bcrypt 4.x, and improve UI errors.
385 lines
12 KiB
Python
385 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
|
|
|
|
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=30000")
|
|
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()
|
|
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
|