feat(auth): return to the original view after an auth-expiry redirect
When a session has expired, require_role now bounces to /login?next=<path> and sends the user back there after re-login. - middleware: safe_next_url() (same-site relative path/query only; rejects off-site, protocol-relative, javascript:, and the auth pages — no open redirect). _login_redirect() builds the next param; for HTMX requests it uses HX-Current-URL (the page, not the fragment) and HX-Redirect so the whole browser navigates instead of swapping the login page into a fragment. - login GET/POST carry `next` (hidden field), validated, used on success for local + LDAP; OIDC stashes it in session across the IdP round-trip. - login.html: hidden next field + next on the SSO link. - tests for safe_next_url. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,65 @@
|
||||
from __future__ import annotations
|
||||
import functools
|
||||
from quart import session, redirect, url_for, abort, g
|
||||
from urllib.parse import quote, urlsplit
|
||||
from quart import session, redirect, url_for, abort, g, request, Response
|
||||
from steward.models.users import UserRole
|
||||
|
||||
_ROLE_ORDER = [UserRole.viewer, UserRole.operator, UserRole.admin]
|
||||
|
||||
# Paths we never bounce back to after login (would loop or make no sense).
|
||||
_NO_RETURN = {"/login", "/logout", "/setup"}
|
||||
|
||||
|
||||
def role_meets(user_role: UserRole, minimum_role: UserRole) -> bool:
|
||||
"""True when user_role is at least minimum_role in the viewer<operator<admin order."""
|
||||
return _ROLE_ORDER.index(user_role) >= _ROLE_ORDER.index(minimum_role)
|
||||
|
||||
|
||||
def safe_next_url(raw: str | None) -> str | None:
|
||||
"""Return a safe same-site relative path+query from `raw`, else None.
|
||||
|
||||
Accepts a relative path or a full URL (e.g. HTMX's HX-Current-URL) and
|
||||
reduces it to path[?query]. Rejects off-site/protocol-relative targets and
|
||||
the auth pages themselves, so it can't be abused as an open redirect.
|
||||
"""
|
||||
if not raw:
|
||||
return None
|
||||
parts = urlsplit(raw)
|
||||
# Protocol-relative (//host/…) has a netloc but no scheme — reject it; a full
|
||||
# same-site URL (HX-Current-URL) has a scheme and we keep only its path.
|
||||
if parts.netloc and not parts.scheme:
|
||||
return None
|
||||
path = parts.path
|
||||
if parts.query:
|
||||
path += "?" + parts.query
|
||||
if not path.startswith("/") or path.startswith("//") or path.startswith("/\\"):
|
||||
return None
|
||||
if path.split("?", 1)[0] in _NO_RETURN:
|
||||
return None
|
||||
return path
|
||||
|
||||
|
||||
def _login_redirect():
|
||||
"""Redirect an unauthenticated request to /login, preserving where to return.
|
||||
|
||||
For HTMX requests the destination is the page the user is on (HX-Current-URL)
|
||||
and we use HX-Redirect so the whole browser navigates (not a fragment swap);
|
||||
for normal requests it's the requested path.
|
||||
"""
|
||||
is_htmx = request.headers.get("HX-Request") == "true"
|
||||
nxt = safe_next_url(request.headers.get("HX-Current-URL")) if is_htmx else None
|
||||
if nxt is None:
|
||||
nxt = safe_next_url(request.full_path)
|
||||
login_url = url_for("auth.login")
|
||||
if nxt:
|
||||
login_url = f"{login_url}?next={quote(nxt, safe='/')}"
|
||||
if is_htmx:
|
||||
resp = Response("", status=401)
|
||||
resp.headers["HX-Redirect"] = login_url
|
||||
return resp
|
||||
return redirect(login_url)
|
||||
|
||||
|
||||
def require_role(minimum_role: UserRole):
|
||||
"""Decorator: requires authenticated user with at least minimum_role.
|
||||
Also allows access for validated share-token requests (viewer level only).
|
||||
@@ -23,7 +72,7 @@ def require_role(minimum_role: UserRole):
|
||||
return await f(*args, **kwargs)
|
||||
user_id = session.get("user_id")
|
||||
if not user_id:
|
||||
return redirect(url_for("auth.login"))
|
||||
return _login_redirect()
|
||||
user_role = UserRole(session.get("user_role", "viewer"))
|
||||
if _ROLE_ORDER.index(user_role) < _ROLE_ORDER.index(minimum_role):
|
||||
abort(403)
|
||||
|
||||
Reference in New Issue
Block a user