Make auth/admin apps API calls use apiFetch, a new function that asks for permission by iframe if needed. Implement max-age checks for API authz.verify as well along with a custom exception type that carries metadata.
This commit is contained in:
@@ -92,6 +92,7 @@ class SessionContext:
|
||||
user: User
|
||||
org: Org
|
||||
role: Role
|
||||
credential: Credential | None = None
|
||||
permissions: list[Permission] | None = None
|
||||
|
||||
|
||||
|
||||
+11
-3
@@ -1314,19 +1314,23 @@ class DB(DatabaseInterface):
|
||||
Uses efficient JOINs to retrieve all related data in a single database query.
|
||||
"""
|
||||
async with self.session() as session:
|
||||
# Build a query that joins sessions, users, roles, organizations, and role_permissions
|
||||
# Build a query that joins sessions, users, roles, organizations, credentials and role_permissions
|
||||
stmt = (
|
||||
select(
|
||||
SessionModel,
|
||||
UserModel,
|
||||
RoleModel,
|
||||
OrgModel,
|
||||
CredentialModel,
|
||||
PermissionModel,
|
||||
)
|
||||
.select_from(SessionModel)
|
||||
.join(UserModel, SessionModel.user_uuid == UserModel.uuid)
|
||||
.join(RoleModel, UserModel.role_uuid == RoleModel.uuid)
|
||||
.join(OrgModel, RoleModel.org_uuid == OrgModel.uuid)
|
||||
.outerjoin(
|
||||
CredentialModel, SessionModel.credential_uuid == CredentialModel.uuid
|
||||
)
|
||||
.outerjoin(RolePermission, RoleModel.uuid == RolePermission.role_uuid)
|
||||
.outerjoin(
|
||||
PermissionModel, RolePermission.permission_id == PermissionModel.id
|
||||
@@ -1342,7 +1346,7 @@ class DB(DatabaseInterface):
|
||||
|
||||
# Extract the first row to get session and user data
|
||||
first_row = rows[0]
|
||||
session_model, user_model, role_model, org_model, _ = first_row
|
||||
session_model, user_model, role_model, org_model, credential_model, _ = first_row
|
||||
|
||||
# Create the session object
|
||||
if host is not None:
|
||||
@@ -1371,11 +1375,14 @@ class DB(DatabaseInterface):
|
||||
display_name=role_model.display_name,
|
||||
)
|
||||
|
||||
# Create credential object if available
|
||||
credential_obj = credential_model.as_dataclass() if credential_model else None
|
||||
|
||||
# Collect all unique permissions for the role
|
||||
permissions = []
|
||||
seen_permission_ids = set()
|
||||
for row in rows:
|
||||
_, _, _, _, permission_model = row
|
||||
_, _, _, _, _, permission_model = row
|
||||
if permission_model and permission_model.id not in seen_permission_ids:
|
||||
permissions.append(
|
||||
Permission(
|
||||
@@ -1405,5 +1412,6 @@ class DB(DatabaseInterface):
|
||||
user=user_obj,
|
||||
org=organization,
|
||||
role=role,
|
||||
credential=credential_obj,
|
||||
permissions=effective_permissions if effective_permissions else None,
|
||||
)
|
||||
|
||||
@@ -28,6 +28,22 @@ async def value_error_handler(_request, exc: ValueError): # pragma: no cover -
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"detail": exc.detail,
|
||||
"auth": {
|
||||
"mode": exc.mode,
|
||||
"iframe": f"/auth/restricted/?mode={exc.mode}",
|
||||
**exc.metadata,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request, exc: Exception):
|
||||
logging.exception("Unhandled exception in admin app")
|
||||
@@ -157,6 +173,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if ctx.org.uuid == org_uuid:
|
||||
raise ValueError("Cannot delete the organization you belong to")
|
||||
@@ -306,6 +323,7 @@ async def admin_delete_role(
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
role = await db.instance.get_role(role_uuid)
|
||||
if role.org_uuid != org_uuid:
|
||||
@@ -419,12 +437,15 @@ async def admin_create_user_registration_link(
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
# Check if user has existing credentials
|
||||
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
@@ -474,7 +495,9 @@ async def admin_get_user_detail(
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
user = await db.instance.get_user_by_uuid(user_uuid)
|
||||
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
|
||||
creds: list[dict] = []
|
||||
@@ -621,7 +644,9 @@ async def admin_update_user_display_name(
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
@@ -650,12 +675,15 @@ async def admin_delete_user_credential(
|
||||
["auth:admin", f"auth:org:{org_uuid}"],
|
||||
match=permutil.has_any,
|
||||
host=request.headers.get("host"),
|
||||
max_age="5m",
|
||||
)
|
||||
if (
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
await db.instance.delete_credential(credential_uuid, user_uuid)
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -684,7 +712,9 @@ async def admin_delete_user_session(
|
||||
"auth:admin" not in ctx.role.permissions
|
||||
and f"auth:org:{org_uuid}" not in ctx.role.permissions
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
raise authz.AuthException(
|
||||
status_code=403, detail="Insufficient permissions", mode="forbidden"
|
||||
)
|
||||
|
||||
try:
|
||||
target_key = tokens.decode_session_key(session_id)
|
||||
@@ -734,7 +764,11 @@ async def admin_create_permission(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
from ..db import Permission as PermDC
|
||||
|
||||
@@ -806,7 +840,11 @@ async def admin_delete_permission(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
await authz.verify(
|
||||
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
|
||||
auth,
|
||||
["auth:admin"],
|
||||
host=request.headers.get("host"),
|
||||
match=permutil.has_all,
|
||||
max_age="5m",
|
||||
)
|
||||
querysafe.assert_safe(permission_id, field="permission_id")
|
||||
|
||||
|
||||
+52
-11
@@ -57,6 +57,22 @@ async def value_error_handler(_request: Request, exc: ValueError):
|
||||
return JSONResponse(status_code=400, content={"detail": str(exc)})
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"detail": exc.detail,
|
||||
"auth": {
|
||||
"mode": exc.mode,
|
||||
"iframe": f"/auth/restricted/?mode={exc.mode}",
|
||||
**exc.metadata,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def general_exception_handler(_request: Request, exc: Exception):
|
||||
logging.exception("Unhandled exception in API app")
|
||||
@@ -96,7 +112,9 @@ async def validate_token(
|
||||
renewed = True
|
||||
except ValueError:
|
||||
# Session disappeared, e.g. due to concurrent logout; global handler will clear
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
return {
|
||||
"valid": True,
|
||||
"user_uuid": str(ctx.session.user_uuid),
|
||||
@@ -120,8 +138,11 @@ async def forward_authentication(
|
||||
is older than this, user must re-authenticate.
|
||||
|
||||
Success: 204 No Content with Remote-* headers describing the authenticated user.
|
||||
Failure (unauthenticated / unauthorized): 4xx with HTML page for authentication.
|
||||
The HTML includes data attributes for mode and other metadata.
|
||||
Failure (unauthenticated / unauthorized): 4xx response.
|
||||
- If Accept header contains "text/html": HTML page for authentication
|
||||
with data attributes for mode and other metadata.
|
||||
- Otherwise: JSON response with error details and an `iframe` field
|
||||
pointing to /auth/restricted/?mode=... for iframe-based authentication.
|
||||
"""
|
||||
try:
|
||||
ctx = await authz.verify(
|
||||
@@ -154,17 +175,37 @@ async def forward_authentication(
|
||||
}
|
||||
return Response(status_code=204, headers=remote_headers)
|
||||
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=e.status_code, media_type="text/html; charset=UTF-8"
|
||||
)
|
||||
|
||||
# Check Accept header to decide response format
|
||||
accept = request.headers.get("accept", "")
|
||||
wants_html = "text/html" in accept
|
||||
|
||||
if wants_html:
|
||||
# Browser request - 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)
|
||||
return Response(
|
||||
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
|
||||
)
|
||||
else:
|
||||
# API request - return JSON with iframe src link
|
||||
iframe_url = f"/auth/restricted/?mode={e.mode}"
|
||||
return JSONResponse(
|
||||
status_code=e.status_code,
|
||||
content={
|
||||
"detail": e.detail,
|
||||
"auth": {
|
||||
"mode": e.mode,
|
||||
"iframe": iframe_url,
|
||||
**e.metadata,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/settings")
|
||||
|
||||
@@ -63,7 +63,7 @@ async def verify(
|
||||
# Check max_age requirement if specified
|
||||
if max_age:
|
||||
try:
|
||||
if not sessionutil.check_session_age(ctx.session, max_age):
|
||||
if not sessionutil.check_session_age(ctx, max_age):
|
||||
raise AuthException(
|
||||
status_code=401,
|
||||
detail="Additional authentication required",
|
||||
|
||||
+43
-8
@@ -8,6 +8,7 @@ from fastapi import (
|
||||
Request,
|
||||
Response,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from ..authsession import (
|
||||
delete_credential,
|
||||
@@ -17,12 +18,28 @@ from ..authsession import (
|
||||
from ..globals import db
|
||||
from ..util import hostutil, passphrase, tokens
|
||||
from ..util.tokens import decode_session_key, session_key
|
||||
from . import session
|
||||
from . import authz, session
|
||||
from .session import AUTH_COOKIE
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
|
||||
@app.exception_handler(authz.AuthException)
|
||||
async def auth_exception_handler(_request, exc: authz.AuthException):
|
||||
"""Handle AuthException with auth info for UI."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"detail": exc.detail,
|
||||
"auth": {
|
||||
"mode": exc.mode,
|
||||
"iframe": f"/auth/restricted/?mode={exc.mode}",
|
||||
**exc.metadata,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.put("/display-name")
|
||||
async def user_update_display_name(
|
||||
request: Request,
|
||||
@@ -31,11 +48,15 @@ async def user_update_display_name(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail="Session expired") from e
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
new_name = (payload.get("display_name") or "").strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=400, detail="display_name required")
|
||||
@@ -52,7 +73,9 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
)
|
||||
await db.instance.delete_sessions_for_user(s.user_uuid)
|
||||
session.clear_session_cookie(response)
|
||||
return {"message": "Logged out from all hosts"}
|
||||
@@ -66,11 +89,15 @@ async def api_delete_session(
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Authentication Required")
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Authentication Required", mode="login"
|
||||
)
|
||||
try:
|
||||
current_session = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=401, detail="Session expired") from exc
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
target_key = decode_session_key(session_id)
|
||||
@@ -97,10 +124,14 @@ async def api_delete_credential(
|
||||
uuid: UUID,
|
||||
auth: str = AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
await delete_credential(uuid, auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail="Session expired") from e
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
return {"message": "Credential deleted successfully"}
|
||||
|
||||
|
||||
@@ -110,10 +141,14 @@ async def api_create_link(
|
||||
response: Response,
|
||||
auth=AUTH_COOKIE,
|
||||
):
|
||||
# Require recent authentication for sensitive operation
|
||||
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
|
||||
try:
|
||||
s = await get_session(auth, host=request.headers.get("host"))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=401, detail="Session expired") from e
|
||||
raise authz.AuthException(
|
||||
status_code=401, detail="Session expired", mode="login"
|
||||
) from e
|
||||
token = passphrase.generate()
|
||||
expiry = expires()
|
||||
await db.instance.create_reset_token(
|
||||
|
||||
+23
-2
@@ -123,10 +123,26 @@ async def websocket_register_add(
|
||||
|
||||
@app.websocket("/authenticate")
|
||||
@websocket_error_handler
|
||||
async def websocket_authenticate(ws: WebSocket):
|
||||
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
|
||||
origin = ws.headers["origin"]
|
||||
host = origin.split("://", 1)[1]
|
||||
options, challenge = passkey.instance.auth_generate_options()
|
||||
|
||||
# If there's an existing session, restrict to that user's credentials (reauth)
|
||||
session_user_uuid = None
|
||||
credential_ids = None
|
||||
if auth:
|
||||
try:
|
||||
session = await get_session(auth, host=host)
|
||||
session_user_uuid = session.user_uuid
|
||||
credential_ids = await db.instance.get_credentials_by_user_uuid(
|
||||
session_user_uuid
|
||||
)
|
||||
except ValueError:
|
||||
pass # Invalid/expired session - allow normal authentication
|
||||
|
||||
options, challenge = passkey.instance.auth_generate_options(
|
||||
credential_ids=credential_ids
|
||||
)
|
||||
await ws.send_json(options)
|
||||
# Wait for the client to use his authenticator to authenticate
|
||||
credential = passkey.instance.auth_parse(await ws.receive_json())
|
||||
@@ -137,6 +153,11 @@ async def websocket_authenticate(ws: WebSocket):
|
||||
raise ValueError(
|
||||
f"This passkey is no longer registered with {passkey.instance.rp_name}"
|
||||
)
|
||||
|
||||
# If reauth mode, verify the credential belongs to the session's user
|
||||
if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
|
||||
raise ValueError("This passkey belongs to a different account")
|
||||
|
||||
# Verify the credential matches the stored data
|
||||
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
|
||||
# Update both credential and user's last_seen timestamp
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..db import Session
|
||||
from ..db import SessionContext
|
||||
from .timeutil import parse_duration
|
||||
|
||||
|
||||
def check_session_age(session: Session, max_age: str | None) -> bool:
|
||||
def check_session_age(ctx: SessionContext, max_age: str | None) -> bool:
|
||||
"""Check if a session satisfies the max_age requirement.
|
||||
|
||||
Uses the credential's last_used timestamp to determine authentication age,
|
||||
since session renewal can happen without re-authentication.
|
||||
|
||||
Args:
|
||||
session: The session record to check
|
||||
ctx: The session context containing session and credential info
|
||||
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
|
||||
|
||||
Returns:
|
||||
True if session is recent enough or max_age is None, False if too old
|
||||
True if authentication is recent enough or max_age is None, False if too old
|
||||
|
||||
Raises:
|
||||
ValueError: If max_age format is invalid
|
||||
@@ -23,5 +26,12 @@ def check_session_age(session: Session, max_age: str | None) -> bool:
|
||||
return True
|
||||
|
||||
max_age_delta = parse_duration(max_age)
|
||||
time_since_auth = datetime.now(timezone.utc) - session.renewed
|
||||
|
||||
# Use credential's last_used time if available, fall back to session renewed
|
||||
if ctx.credential and ctx.credential.last_used:
|
||||
auth_time = ctx.credential.last_used
|
||||
else:
|
||||
auth_time = ctx.session.renewed
|
||||
|
||||
time_since_auth = datetime.now(timezone.utc) - auth_time
|
||||
return time_since_auth <= max_age_delta
|
||||
|
||||
Reference in New Issue
Block a user