feat(auth): operator session cookie + current_operator DB re-validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 19:11:41 +00:00
parent 4abfcbc293
commit a6e1cb4f87
2 changed files with 91 additions and 0 deletions
+28
View File
@@ -12,6 +12,7 @@ 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.
@@ -33,3 +34,30 @@ def role_at_least(role: str, minimum: str) -> bool:
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