feat(auth): login/logout/change-password routes + pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user