obsidian-vault/ligbox-ops-platform/api/app/registration_store.py
2026-06-19 17:26:42 +00:00

372 lines
12 KiB
Python

"""Registration requests for Desk administrators."""
from __future__ import annotations
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from app import auth
from app import backup_codes
from app.permissions import ROLES
from app.totp_util import generate_secret, ntfy_topic, verify_code
STATUSES = frozenset({"pending", "approved", "rejected", "active"})
REQUIRED_FACTORS = 2
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _otp_expires(minutes: int = 10) -> str:
return (datetime.now(timezone.utc) + timedelta(minutes=minutes)).isoformat()
def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if column not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")
def init_registration_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS desk_registration_requests (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
status TEXT NOT NULL DEFAULT 'pending',
role TEXT,
activation_token TEXT UNIQUE,
phone TEXT,
email_otp TEXT,
email_otp_expires TEXT,
phone_otp TEXT,
phone_otp_expires TEXT,
approved_by TEXT,
rejected_by TEXT,
rejection_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
approved_at TEXT
)
"""
)
for col, ddl in [
("totp_secret_pending", "totp_secret_pending TEXT"),
("ntfy_topic", "ntfy_topic TEXT"),
("email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"),
("phone_verified", "phone_verified INTEGER NOT NULL DEFAULT 0"),
("totp_verified", "totp_verified INTEGER NOT NULL DEFAULT 0"),
]:
_ensure_column(conn, "desk_registration_requests", col, ddl)
for col, ddl in [
("email", "email TEXT"),
("phone", "phone TEXT"),
("mfa_enabled", "mfa_enabled INTEGER NOT NULL DEFAULT 0"),
("totp_secret", "totp_secret TEXT"),
("totp_enabled", "totp_enabled INTEGER NOT NULL DEFAULT 0"),
]:
_ensure_column(conn, "desk_users", col, ddl)
def normalize_email(email: str) -> str:
return email.strip().lower()
def create_request(conn: sqlite3.Connection, email: str, password: str, display_name: str | None) -> dict:
email = normalize_email(email)
existing = conn.execute(
"SELECT id FROM desk_users WHERE username = ? OR email = ?",
(email, email),
).fetchone()
if existing:
raise ValueError("e-mail já cadastrado")
pending = conn.execute(
"SELECT id FROM desk_registration_requests WHERE email = ? AND status IN ('pending', 'approved')",
(email,),
).fetchone()
if pending:
raise ValueError("já existe pedido pendente para este e-mail")
now = _now()
cur = conn.execute(
"""
INSERT INTO desk_registration_requests
(email, password_hash, display_name, status, created_at, updated_at)
VALUES (?, ?, ?, 'pending', ?, ?)
""",
(email, auth.hash_password(password), display_name, now, now),
)
conn.commit()
return get_request(conn, int(cur.lastrowid))
def get_request(conn: sqlite3.Connection, request_id: int) -> dict | None:
row = conn.execute(
"SELECT * FROM desk_registration_requests WHERE id = ?",
(request_id,),
).fetchone()
return dict(row) if row else None
def get_request_by_token(conn: sqlite3.Connection, token: str) -> dict | None:
row = conn.execute(
"SELECT * FROM desk_registration_requests WHERE activation_token = ?",
(token,),
).fetchone()
return dict(row) if row else None
def list_requests(conn: sqlite3.Connection, status: str | None = None) -> list[dict]:
if status:
rows = conn.execute(
"SELECT * FROM desk_registration_requests WHERE status = ? ORDER BY created_at DESC",
(status,),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM desk_registration_requests ORDER BY created_at DESC"
).fetchall()
return [public_request(dict(r)) for r in rows]
def factor_status(row: dict) -> dict:
return {
"email": bool(row.get("email_verified")),
"phone": bool(row.get("phone_verified")),
"totp": bool(row.get("totp_verified")),
"verified_count": sum(
1 for k in ("email_verified", "phone_verified", "totp_verified") if row.get(k)
),
"required": REQUIRED_FACTORS,
"ready": sum(1 for k in ("email_verified", "phone_verified", "totp_verified") if row.get(k))
>= REQUIRED_FACTORS,
}
def public_request(row: dict) -> dict:
return {
"id": row["id"],
"email": row["email"],
"display_name": row.get("display_name"),
"status": row["status"],
"role": row.get("role"),
"phone": row.get("phone"),
"approved_by": row.get("approved_by"),
"rejected_by": row.get("rejected_by"),
"rejection_reason": row.get("rejection_reason"),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
"approved_at": row.get("approved_at"),
"factors": factor_status(row),
}
def ensure_activation_secrets(conn: sqlite3.Connection, request_id: int) -> dict:
row = get_request(conn, request_id)
if not row:
raise ValueError("request not found")
secret = row.get("totp_secret_pending") or generate_secret()
topic = row.get("ntfy_topic") or ntfy_topic(row["email"], request_id)
if not row.get("totp_secret_pending") or not row.get("ntfy_topic"):
conn.execute(
"""
UPDATE desk_registration_requests
SET totp_secret_pending = ?, ntfy_topic = ?, updated_at = ?
WHERE id = ?
""",
(secret, topic, _now(), request_id),
)
conn.commit()
row = get_request(conn, request_id)
return row
def approve_request(conn: sqlite3.Connection, request_id: int, role: str, approved_by: str) -> dict:
if role not in ROLES or role == "super_admin":
raise ValueError("invalid role for new registration")
row = get_request(conn, request_id)
if not row:
raise ValueError("request not found")
if row["status"] != "pending":
raise ValueError(f"cannot approve status {row['status']}")
token = secrets.token_urlsafe(32)
secret = generate_secret()
topic = ntfy_topic(row["email"], request_id)
now = _now()
conn.execute(
"""
UPDATE desk_registration_requests
SET status = 'approved', role = ?, activation_token = ?,
approved_by = ?, approved_at = ?, updated_at = ?,
totp_secret_pending = ?, ntfy_topic = ?,
email_verified = 0, phone_verified = 0, totp_verified = 0
WHERE id = ?
""",
(role, token, approved_by, now, now, secret, topic, request_id),
)
conn.commit()
return get_request(conn, request_id)
def reject_request(
conn: sqlite3.Connection, request_id: int, rejected_by: str, reason: str | None = None
) -> dict:
row = get_request(conn, request_id)
if not row:
raise ValueError("request not found")
if row["status"] != "pending":
raise ValueError(f"cannot reject status {row['status']}")
now = _now()
conn.execute(
"""
UPDATE desk_registration_requests
SET status = 'rejected', rejected_by = ?, rejection_reason = ?, updated_at = ?
WHERE id = ?
""",
(rejected_by, reason, now, request_id),
)
conn.commit()
return get_request(conn, request_id)
def set_email_otp(conn: sqlite3.Connection, request_id: int) -> tuple[str, dict]:
code = f"{secrets.randbelow(1_000_000):06d}"
conn.execute(
"""
UPDATE desk_registration_requests
SET email_otp = ?, email_otp_expires = ?, updated_at = ?
WHERE id = ?
""",
(code, _otp_expires(), _now(), request_id),
)
conn.commit()
return code, get_request(conn, request_id)
def set_phone_otp(conn: sqlite3.Connection, request_id: int, phone: str) -> tuple[str, dict]:
code = f"{secrets.randbelow(1_000_000):06d}"
conn.execute(
"""
UPDATE desk_registration_requests
SET phone = ?, phone_otp = ?, phone_otp_expires = ?, updated_at = ?
WHERE id = ?
""",
(phone.strip(), code, _otp_expires(), _now(), request_id),
)
conn.commit()
return code, get_request(conn, request_id)
def _otp_valid(stored: str | None, expires: str | None, provided: str) -> bool:
if not stored or not expires or not provided:
return False
if stored.strip() != provided.strip():
return False
try:
exp = datetime.fromisoformat(expires)
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
except ValueError:
return False
return datetime.now(timezone.utc) <= exp
def _count_verified(row: dict) -> int:
return sum(1 for k in ("email_verified", "phone_verified", "totp_verified") if row.get(k))
def complete_activation(
conn: sqlite3.Connection,
request_id: int,
email_otp: str | None = None,
phone_otp: str | None = None,
totp_code: str | None = None,
) -> dict:
row = get_request(conn, request_id)
if not row:
raise ValueError("request not found")
if row["status"] != "approved":
raise ValueError("request not approved")
email_verified = bool(row.get("email_verified"))
phone_verified = bool(row.get("phone_verified"))
totp_verified = bool(row.get("totp_verified"))
if email_otp and not email_verified:
if _otp_valid(row.get("email_otp"), row.get("email_otp_expires"), email_otp):
email_verified = True
if phone_otp and not phone_verified:
if row.get("phone") and _otp_valid(row.get("phone_otp"), row.get("phone_otp_expires"), phone_otp):
phone_verified = True
if totp_code and not totp_verified:
secret = row.get("totp_secret_pending")
if secret and verify_code(secret, totp_code):
totp_verified = True
verified_count = sum([email_verified, phone_verified, totp_verified])
if verified_count < REQUIRED_FACTORS:
conn.execute(
"""
UPDATE desk_registration_requests
SET email_verified = ?, phone_verified = ?, totp_verified = ?, updated_at = ?
WHERE id = ?
""",
(int(email_verified), int(phone_verified), int(totp_verified), _now(), request_id),
)
conn.commit()
raise ValueError(f"valide pelo menos {REQUIRED_FACTORS} fatores ({verified_count}/{REQUIRED_FACTORS})")
email = row["email"]
role = row["role"]
if not role:
raise ValueError("role not set")
now = _now()
display = row.get("display_name") or email.split("@")[0]
totp_secret = row.get("totp_secret_pending") if totp_verified else None
totp_enabled = 1 if totp_verified else 0
phone = row.get("phone") if phone_verified else None
conn.execute(
"""
INSERT INTO desk_users
(username, password_hash, role, display_name, email, phone,
mfa_enabled, totp_secret, totp_enabled, active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, 1, ?, ?)
""",
(
email,
row["password_hash"],
role,
display,
email,
phone,
totp_secret,
totp_enabled,
now,
now,
),
)
conn.execute(
"""
UPDATE desk_registration_requests
SET status = 'active', email_verified = ?, phone_verified = ?, totp_verified = ?,
updated_at = ?
WHERE id = ?
""",
(int(email_verified), int(phone_verified), int(totp_verified), now, request_id),
)
conn.commit()
result = get_request(conn, request_id)
if totp_enabled and totp_secret:
codes = backup_codes.generate_backup_codes()
backup_codes.store_backup_codes(conn, email, codes)
conn.commit()
result = dict(result)
result["backup_codes"] = codes
return result