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:
+10
-5
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
import bcrypt
|
||||
from quart import Blueprint, render_template, request, redirect, url_for, session
|
||||
from sqlalchemy import select, func
|
||||
from steward.auth.middleware import login_user, logout_user
|
||||
from steward.auth.middleware import login_user, logout_user, safe_next_url
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
auth_bp = Blueprint("auth", __name__)
|
||||
@@ -54,7 +54,8 @@ async def login():
|
||||
return redirect(url_for("auth.setup"))
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
return await render_template("auth/login.html",
|
||||
oidc_enabled=oidc_cfg.get("enabled", False))
|
||||
oidc_enabled=oidc_cfg.get("enabled", False),
|
||||
next_url=safe_next_url(request.args.get("next")))
|
||||
|
||||
|
||||
@auth_bp.post("/login")
|
||||
@@ -65,6 +66,7 @@ async def login_post():
|
||||
username = form.get("username", "").strip()
|
||||
password_str = form.get("password", "")
|
||||
password = password_str.encode()
|
||||
dest = safe_next_url(form.get("next")) or url_for("dashboard.index")
|
||||
|
||||
# Try local auth first
|
||||
user = await get_user_by_username(current_app, username)
|
||||
@@ -72,7 +74,7 @@ async def login_post():
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "local", "role": user.role.value})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return redirect(dest)
|
||||
|
||||
# Try LDAP fallback if enabled
|
||||
ldap_cfg = current_app.config.get("LDAP", {})
|
||||
@@ -86,13 +88,14 @@ async def login_post():
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "ldap", "role": user.role.value})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return redirect(dest)
|
||||
|
||||
oidc_cfg = current_app.config.get("OIDC", {})
|
||||
return await render_template(
|
||||
"auth/login.html",
|
||||
error="Invalid credentials",
|
||||
oidc_enabled=oidc_cfg.get("enabled", False),
|
||||
next_url=safe_next_url(form.get("next")),
|
||||
), 400
|
||||
|
||||
|
||||
@@ -112,6 +115,7 @@ async def login_oidc():
|
||||
nonce = generate_state()
|
||||
session["oidc_state"] = state
|
||||
session["oidc_nonce"] = nonce
|
||||
session["oidc_next"] = safe_next_url(request.args.get("next"))
|
||||
redirect_uri = url_for("auth.login_oidc_callback", _external=True)
|
||||
url = build_authorize_url(
|
||||
doc, oidc_cfg["client_id"], redirect_uri,
|
||||
@@ -170,7 +174,8 @@ async def login_oidc_callback():
|
||||
login_user(user)
|
||||
await log_audit(current_app, user.id, user.username, "auth.login",
|
||||
detail={"method": "oidc", "role": role})
|
||||
return redirect(url_for("dashboard.index"))
|
||||
dest = safe_next_url(session.pop("oidc_next", None)) or url_for("dashboard.index")
|
||||
return redirect(dest)
|
||||
|
||||
|
||||
@auth_bp.get("/logout")
|
||||
|
||||
Reference in New Issue
Block a user