68161298a4
- operator_users router now depends on _require_auth_enabled, which raises 404 when OPERATOR_AUTH_ENABLED is false — prevents world-open pre-seeding of a superadmin while the flag is off (the default). Flag is read as a live module attribute (operator_auth.OPERATOR_AUTH_ENABLED) so monkeypatching in tests and a runtime flip both take effect. - operator_gate passes OPTIONS requests through immediately before the exempt- path check, so CORS preflight reaches CORSMiddleware rather than being 303/401'd by the gate. - Two new tests: test_admin_surface_404s_when_flag_off (test_operator_users) and test_options_preflight_passes_through_gate (test_operator_gate). Full suite: 90 passed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
232 lines
9.1 KiB
Python
232 lines
9.1 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 urllib.parse import quote
|
|
|
|
from fastapi import Request, HTTPException
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
|
|
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
|
|
from backend.database import SessionLocal
|
|
|
|
# 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
|
|
|
|
|
|
# Routes reachable with no login. A new route added next year is gated by default.
|
|
_EXEMPT_EXACT = {
|
|
"/login", "/logout", "/health",
|
|
"/manifest.json", "/sw.js", "/favicon.ico", "/offline-db.js",
|
|
"/portal", # portal home (its own auth)
|
|
# machine endpoints — LAN-only, automated, no human (watchers/heartbeats):
|
|
"/emitters/report", "/api/series3/heartbeat", "/api/series4/heartbeat",
|
|
}
|
|
_EXEMPT_PREFIX = ("/static/", "/portal/")
|
|
|
|
|
|
def _is_exempt(path: str) -> bool:
|
|
return path in _EXEMPT_EXACT or path.startswith(_EXEMPT_PREFIX)
|
|
|
|
|
|
async def operator_gate(request: Request, call_next):
|
|
"""Deny-by-default gate. Flag off → pass through (app as today). Flag on →
|
|
exempt paths pass; otherwise require a valid operator session, stash it on
|
|
request.state.operator, and force a password change when pending."""
|
|
if not OPERATOR_AUTH_ENABLED:
|
|
return await call_next(request)
|
|
|
|
# CORS preflight carries no auth and must reach CORSMiddleware, not the gate.
|
|
if request.method == "OPTIONS":
|
|
return await call_next(request)
|
|
|
|
path = request.url.path
|
|
if _is_exempt(path):
|
|
return await call_next(request)
|
|
|
|
db = SessionLocal()
|
|
try:
|
|
user = current_operator(request, db)
|
|
if user is not None:
|
|
db.expunge(user) # detach a fully-loaded row so we can close now
|
|
finally:
|
|
db.close()
|
|
|
|
if user is None:
|
|
if path.startswith("/api/"):
|
|
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
|
|
return RedirectResponse(f"/login?next={quote(path)}", status_code=303)
|
|
|
|
if user.must_change_password and path not in ("/change-password", "/logout"):
|
|
if path.startswith("/api/"):
|
|
return JSONResponse({"detail": "Password change required"}, status_code=403)
|
|
return RedirectResponse("/change-password", status_code=303)
|
|
|
|
request.state.operator = user
|
|
return await call_next(request)
|
|
|
|
|
|
def require_role(minimum: str):
|
|
"""Dependency factory: require a logged-in operator ranked >= `minimum`.
|
|
Respects the flag (off → pass through). When on, the middleware has already
|
|
set request.state.operator before this runs."""
|
|
def _dep(request: Request):
|
|
if not OPERATOR_AUTH_ENABLED:
|
|
return None
|
|
user = getattr(request.state, "operator", None)
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
if not role_at_least(user.role, minimum):
|
|
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
return user
|
|
return _dep
|