"""Operator login / logout / change-password. These routes intentionally work regardless of OPERATOR_AUTH_ENABLED (you log in while the flag is still off during rollout). /login and /logout are on the gate's exempt list; /change-password requires a session (the gate sets request.state.operator).""" from fastapi import APIRouter, Request, Depends, Form from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from backend.database import get_db from backend.models import OperatorUser from backend.templates_config import templates from backend.operator_auth import ( authenticate, current_operator, change_own_password, make_operator_cookie, COOKIE_NAME, COOKIE_MAX_AGE, ) from backend.auth_cookies import COOKIE_SECURE from backend.auth_passwords import verify_password router = APIRouter(tags=["operator-auth"]) def _safe_next(next_url: str) -> str: """Only allow same-site relative redirects (an open-redirect guard). Rejects `//host` and `/\\host` — browsers treat a backslash as `/` in the authority position, so both escape to an external site.""" if next_url and next_url.startswith("/") and not next_url.startswith(("//", "/\\")): return next_url return "/" @router.get("/login") async def login_page(request: Request, next: str = "", error: str = ""): return templates.TemplateResponse("login.html", {"request": request, "next": next, "error": error}) @router.post("/login") async def login_submit(request: Request, next: str = "", email: str = Form(...), password: str = Form(...), db: Session = Depends(get_db)): user, status = authenticate(db, email, password) if status == "locked": return templates.TemplateResponse( "login.html", {"request": request, "next": next, "error": "Too many attempts — try again in 15 minutes."}, status_code=200) if user is None: return templates.TemplateResponse( "login.html", {"request": request, "next": next, "error": "Invalid email or password."}, status_code=200) dest = "/change-password" if user.must_change_password else _safe_next(next) resp = RedirectResponse(url=dest, status_code=303) resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id), max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) return resp @router.get("/logout") async def logout(request: Request): resp = RedirectResponse(url="/login", status_code=303) resp.delete_cookie(COOKIE_NAME) return resp @router.get("/change-password") async def change_password_page(request: Request, db: Session = Depends(get_db)): user = getattr(request.state, "operator", None) or current_operator(request, db) if user is None: return RedirectResponse(url="/login", status_code=303) return templates.TemplateResponse( "change_password.html", {"request": request, "must_change": user.must_change_password, "error": ""}) @router.post("/change-password") async def change_password_submit(request: Request, current_password: str = Form(...), new_password: str = Form(...), confirm_password: str = Form(...), db: Session = Depends(get_db)): _user_ref = getattr(request.state, "operator", None) or current_operator(request, db) if _user_ref is None: return RedirectResponse(url="/login", status_code=303) # Re-fetch a session-bound copy so mutations via `db` will be committed. # request.state.operator may be expunged (detached) from the gate's own # SessionLocal; operating on a detached object against a different session # would silently drop the UPDATE. user = db.query(OperatorUser).filter_by(id=_user_ref.id).first() if user is None: return RedirectResponse(url="/login", status_code=303) def _err(msg): return templates.TemplateResponse( "change_password.html", {"request": request, "must_change": user.must_change_password, "error": msg}, status_code=200) if not verify_password(current_password, user.password_hash): return _err("Current password is incorrect.") if len(new_password) < 8: return _err("New password must be at least 8 characters.") if new_password != confirm_password: return _err("New passwords do not match.") new_iat = change_own_password(db, user, new_password) resp = RedirectResponse(url="/", status_code=303) resp.set_cookie(COOKIE_NAME, make_operator_cookie(user.id, iat=new_iat), max_age=COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=COOKIE_SECURE) return resp