Implement metadata for RestrictedForward, set by /auth/api/forward endpoint when returning the app. Use this to implement support for time-based reauth requirement.
This commit is contained in:
+20
-10
@@ -24,7 +24,7 @@ from ..authsession import (
|
||||
)
|
||||
from ..globals import db
|
||||
from ..globals import passkey as global_passkey
|
||||
from ..util import hostutil, passphrase, userinfo
|
||||
from ..util import hostutil, htmlutil, passphrase, userinfo
|
||||
from ..util.tokens import session_key
|
||||
from . import authz, session, user
|
||||
from .session import AUTH_COOKIE
|
||||
@@ -109,18 +109,24 @@ async def forward_authentication(
|
||||
request: Request,
|
||||
response: Response,
|
||||
perm: list[str] = Query([]),
|
||||
max_age: str | None = Query(None),
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
"""Forward auth validation for Caddy/Nginx.
|
||||
|
||||
Query Params:
|
||||
- perm: repeated permission IDs the authenticated user must possess (ALL required).
|
||||
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
|
||||
is older than this, user must re-authenticate.
|
||||
|
||||
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
||||
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
|
||||
Failure (unauthenticated / unauthorized): 4xx with HTML page for authentication.
|
||||
The HTML includes data attributes for mode and other metadata.
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
|
||||
ctx = await authz.verify(
|
||||
auth, perm, host=request.headers.get("host"), max_age=max_age
|
||||
)
|
||||
role_permissions = set(ctx.role.permissions or [])
|
||||
if ctx.permissions:
|
||||
role_permissions.update(permission.id for permission in ctx.permissions)
|
||||
@@ -147,14 +153,18 @@ async def forward_authentication(
|
||||
"Remote-Credential": str(ctx.session.credential_uuid),
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
except HTTPException as e:
|
||||
# Let global handler clear cookie; still return HTML surface instead of JSON
|
||||
html = frontend.file("int", "restricted", "index.html").read_bytes()
|
||||
status = e.status_code
|
||||
# If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML)
|
||||
if status == 401:
|
||||
except authz.AuthException as e:
|
||||
# Authentication/authorization failed - return HTML with metadata
|
||||
html = frontend.file("int", "forward", "index.html").read_bytes()
|
||||
# Inject mode and any additional metadata
|
||||
data_attrs = {"mode": e.mode, **e.metadata}
|
||||
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
|
||||
# Clear cookie only if session is invalid (not for reauth)
|
||||
if e.clear_session:
|
||||
session.clear_session_cookie(response)
|
||||
return Response(html, status_code=status, media_type="text/html")
|
||||
return Response(
|
||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
|
||||
@@ -2,31 +2,76 @@ import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from ..util import permutil
|
||||
from ..util import permutil, sessionutil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthException(HTTPException):
|
||||
"""Exception raised during authentication/authorization with metadata for the UI.
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP status code (401 for auth, 403 for authz)
|
||||
detail: Error message
|
||||
mode: UI mode ('login' or 'reauth')
|
||||
clear_session: Whether to clear the session cookie (True for invalid sessions)
|
||||
metadata: Additional data to pass to the frontend
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
detail: str,
|
||||
mode: str,
|
||||
clear_session: bool = False,
|
||||
**metadata,
|
||||
):
|
||||
super().__init__(status_code=status_code, detail=detail)
|
||||
self.mode = mode
|
||||
self.clear_session = clear_session
|
||||
self.metadata = metadata
|
||||
|
||||
|
||||
async def verify(
|
||||
auth: str | None,
|
||||
perm: list[str],
|
||||
match=permutil.has_all,
|
||||
host: str | None = None,
|
||||
max_age: str | None = None,
|
||||
):
|
||||
"""Validate session token and optional list of required permissions.
|
||||
|
||||
Returns the session context.
|
||||
|
||||
Raises HTTPException on failure:
|
||||
401: unauthenticated / invalid session
|
||||
403: required permissions missing
|
||||
Raises AuthException on failure with metadata for UI rendering.
|
||||
"""
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Authentication required",
|
||||
mode="login",
|
||||
)
|
||||
|
||||
ctx = await permutil.session_context(auth, host)
|
||||
if not ctx:
|
||||
raise HTTPException(status_code=401, detail="Session not found")
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Your session has expired. Please sign in again.",
|
||||
mode="login",
|
||||
clear_session=True,
|
||||
)
|
||||
# Check max_age requirement if specified
|
||||
if max_age:
|
||||
try:
|
||||
if not sessionutil.check_session_age(ctx.session, max_age):
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Additional authentication required",
|
||||
mode="reauth",
|
||||
)
|
||||
except ValueError as e:
|
||||
# Invalid max_age format - log but don't fail the request
|
||||
logger.warning(f"Invalid max_age format '{max_age}': {e}")
|
||||
|
||||
if not match(ctx, perm):
|
||||
# Determine which permissions are missing for clearer diagnostics
|
||||
@@ -39,6 +84,8 @@ async def verify(
|
||||
perm,
|
||||
ctx.role.permissions,
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="Permission required")
|
||||
raise AuthException(
|
||||
status_code=403, mode="forbidden", detail="Permission required"
|
||||
)
|
||||
|
||||
return ctx
|
||||
|
||||
@@ -59,6 +59,11 @@ app.mount(
|
||||
StaticFiles(directory=frontend.file("auth", "assets")),
|
||||
name="assets",
|
||||
)
|
||||
app.mount(
|
||||
"/int/",
|
||||
StaticFiles(directory=frontend.file("int"), html=True),
|
||||
name="int",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/auth/restricted/")
|
||||
|
||||
Reference in New Issue
Block a user