e8fe4845aa
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
# 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
|