# backend/operator_auth.py """Operator authentication: the deny-by-default gate, session cookie, login + lockout, and the small data helpers shared by the routes and the CLI. Reuses the argon2 hasher (auth_passwords) and the HMAC signer (auth_cookies). The flag and SessionLocal are read as module globals at call time so tests can monkeypatch them.""" import os import time import uuid from datetime import datetime, timedelta from backend.models import OperatorUser from backend.auth_passwords import hash_password, verify_password, generate_password from backend.auth_cookies import sign, read, COOKIE_SECURE # Feature flag — OFF by default. When off, the gate and require_role both pass # everything through and the app behaves exactly as it does today. OPERATOR_AUTH_ENABLED = os.getenv("OPERATOR_AUTH_ENABLED", "false").lower() in ("1", "true", "yes") COOKIE_NAME = "tv_session" COOKIE_MAX_AGE = 60 * 60 * 24 * 30 # 30 days ("remember this device") MAX_LOGIN_FAILURES = 5 LOCK_MINUTES = 15 # Role ladder — a rank map so checks read naturally and 'operator' slots in later. _ROLE_RANK = {"operator": 10, "admin": 20, "superadmin": 30} # A throwaway hash used only to equalize verify time on the unknown-email path, # so a missing account can't be distinguished from a wrong password by timing # (no user-enumeration). The value never authenticates anything. _DUMMY_PASSWORD_HASH = hash_password("operator-auth-timing-equalizer") def role_at_least(role: str, minimum: str) -> bool: """True iff `role` ranks at or above `minimum`. Unknown roles rank as 0.""" return _ROLE_RANK.get(role, 0) >= _ROLE_RANK[minimum] def _norm_email(email: str) -> str: return (email or "").strip().lower() def make_operator_cookie(uid: str, iat: int = None) -> str: """Sign a tv_session value for a user id. iat defaults to now; pass an explicit iat when you bump sessions_valid_from to that same instant (change-password).""" return sign({"uid": uid, "iat": int(iat if iat is not None else time.time())}) def current_operator(request, db): """Resolve the OperatorUser for a request's tv_session cookie, or None. Re-validated against the DB every call: a disabled / locked / password-changed user drops on the next request. Used by the gate middleware (with its own session) — does not raise.""" data = read(request.cookies.get(COOKIE_NAME), COOKIE_MAX_AGE) if not data: return None uid, iat = data.get("uid"), data.get("iat") if not uid or not isinstance(iat, (int, float)): return None user = db.query(OperatorUser).filter_by(id=uid).first() if not user or not user.active: return None if user.locked_until and user.locked_until > datetime.utcnow(): return None if user.sessions_valid_from and datetime.utcfromtimestamp(int(iat)) < user.sessions_valid_from: return None return user def register_login_failure(db, user) -> None: """Increment a user's failure counter and lock them out past the threshold.""" user.failed_login_count = (user.failed_login_count or 0) + 1 if user.failed_login_count >= MAX_LOGIN_FAILURES: user.locked_until = datetime.utcnow() + timedelta(minutes=LOCK_MINUTES) db.commit() def authenticate(db, email, password): """Return (user, "ok") on success, (None, "locked") if locked out, else (None, "bad"). Never reveals whether the email exists: an unknown email runs the same argon2 verify (against a dummy hash) as a wrong password, so neither the response text nor its timing distinguishes the two.""" user = db.query(OperatorUser).filter_by(email=_norm_email(email)).first() if user and user.locked_until and user.locked_until > datetime.utcnow(): return None, "locked" password_ok = verify_password(password, user.password_hash if user else _DUMMY_PASSWORD_HASH) if not user or not user.active or not password_ok: if user: register_login_failure(db, user) return None, "bad" user.failed_login_count = 0 user.locked_until = None user.last_login_at = datetime.utcnow() db.commit() return user, "ok" def create_operator(db, email, name, role, password=None, must_change=None): """Create an operator. With no password, generate a temp one and force a change (must_change defaults True). With a password, must_change defaults False. Returns (user, raw_password_to_show_once). Raises ValueError on dup/bad role.""" email = _norm_email(email) if role not in _ROLE_RANK: raise ValueError(f"unknown role {role!r}") if db.query(OperatorUser).filter_by(email=email).first(): raise ValueError(f"operator {email} already exists") if password is None: password = generate_password() if must_change is None: must_change = True elif must_change is None: must_change = False user = OperatorUser(id=str(uuid.uuid4()), email=email, display_name=name, password_hash=hash_password(password), role=role, active=True, must_change_password=must_change) db.add(user) db.commit() return user, password def reset_operator_password(db, user) -> str: """Generate a fresh temp password, force a change, log the user out everywhere. Returns the raw password to show once.""" raw = generate_password() user.password_hash = hash_password(raw) user.must_change_password = True user.failed_login_count = 0 user.locked_until = None user.sessions_valid_from = datetime.utcnow().replace(microsecond=0) db.commit() return raw def change_own_password(db, user, new_password) -> int: """Set a user's own new password, clear the forced-change flag, and bump sessions_valid_from to the returned iat — the caller mints the replacement cookie with that exact iat so it stays valid while older cookies die.""" new_iat = int(time.time()) user.password_hash = hash_password(new_password) user.must_change_password = False user.sessions_valid_from = datetime.utcfromtimestamp(new_iat) db.commit() return new_iat def set_operator_active(db, user, active: bool): user.active = bool(active) db.commit() return user def set_operator_role(db, user, role: str): if role not in _ROLE_RANK: raise ValueError(f"unknown role {role!r}") user.role = role db.commit() return user