A major refactoring for more consistent and stricter flows.

- Force using the dedicated authentication site configured via auth-host
- Stricter host validation
- Using the restricted app consistently for all access control (instead of the old loginview).
This commit is contained in:
Leo Vasanko
2025-10-04 15:55:11 -06:00
parent 389e05730b
commit bfb11cc20f
16 changed files with 366 additions and 272 deletions

View File

@@ -85,10 +85,14 @@ async def get_session(token: str, host: str | None = None) -> Session:
normalized_host = hostutil.normalize_host(host)
if not normalized_host:
raise ValueError("Invalid host")
if session.host is None:
current = session.host
if current is None:
# First time binding: store exact host:port (or IPv6 form) now.
await db.instance.set_session_host(session.key, normalized_host)
session.host = normalized_host
elif session.host != normalized_host:
elif current == normalized_host:
pass # exact match ok
else:
raise ValueError("Invalid or expired session token")
return session

View File

@@ -35,6 +35,10 @@ async def general_exception_handler(_request, exc: Exception):
@app.get("/")
async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
"""Serve admin SPA only for authenticated users with admin/org permissions.
On missing/invalid session or insufficient permissions, serve restricted SPA.
"""
try:
await authz.verify(
auth,
@@ -44,7 +48,9 @@ async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")):
)
return FileResponse(frontend.file("admin/index.html"))
except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
return FileResponse(
frontend.file("restricted", "index.html"), status_code=e.status_code
)
# -------------------- Organizations --------------------

View File

@@ -38,6 +38,17 @@ bearer_auth = HTTPBearer(auto_error=True)
app = FastAPI()
@app.exception_handler(HTTPException)
async def http_exception_handler(_request: Request, exc: HTTPException):
"""Ensure auth cookie is cleared on 401 responses (JSON responses only)."""
if exc.status_code == 401:
resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
session.clear_session_cookie(resp)
return resp
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# Refresh only if at least this much of the session lifetime has been *consumed*.
# Consumption is derived from (now + EXPIRES) - current_expires.
# This guarantees a minimum spacing between DB writes even with frequent /validate calls.
@@ -68,7 +79,11 @@ async def validate_token(
renewed max-age. This keeps active users logged in without needing a separate
refresh endpoint.
"""
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
try:
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
except HTTPException:
# Global handler will clear cookie if 401
raise
renewed = False
if auth:
current_expiry = session_expiry(ctx.session)
@@ -83,7 +98,7 @@ async def validate_token(
session.set_session_cookie(response, auth)
renewed = True
except ValueError:
# Session disappeared, e.g. due to concurrent logout
# Session disappeared, e.g. due to concurrent logout; global handler will clear
raise HTTPException(status_code=401, detail="Session expired")
return {
"valid": True,
@@ -95,6 +110,7 @@ async def validate_token(
@app.get("/forward")
async def forward_authentication(
request: Request,
response: Response,
perm: list[str] = Query([]),
auth=Cookie(None, alias="__Host-auth"),
):
@@ -135,8 +151,13 @@ async def forward_authentication(
}
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("restricted", "index.html").read_bytes()
return Response(html, status_code=e.status_code, media_type="text/html")
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:
session.clear_session_cookie(response)
return Response(html, status_code=status, media_type="text/html")
@app.get("/settings")
@@ -153,7 +174,10 @@ async def get_settings():
@app.post("/user-info")
async def api_user_info(
request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth")
request: Request,
response: Response,
reset: str | None = None,
auth=Cookie(None, alias="__Host-auth"),
):
authenticated = False
session_record = None
@@ -339,11 +363,17 @@ async def api_user_info(
@app.put("/user/display-name")
async def user_update_display_name(
request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth")
request: Request,
response: Response,
payload: dict = Body(...),
auth=Cookie(None, alias="__Host-auth"),
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
s = await get_session(auth, host=request.headers.get("host"))
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
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
@@ -362,7 +392,6 @@ async def api_logout(
try:
await get_session(auth, host=request.headers.get("host"))
except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
return {"message": "Already logged out"}
with suppress(Exception):
await db.instance.delete_session(session_key(auth))
@@ -379,10 +408,9 @@ async def api_logout_all(
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired")
await db.instance.delete_sessions_for_user(s.user_uuid)
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"}
@@ -398,7 +426,6 @@ async def api_delete_session(
try:
current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
raise HTTPException(status_code=401, detail="Session expired") from exc
try:
@@ -415,7 +442,7 @@ async def api_delete_session(
await db.instance.delete_session(target_key)
current_terminated = target_key == session_key(auth)
if current_terminated:
response.delete_cookie(session.AUTH_COOKIE_NAME, path="/")
session.clear_session_cookie(response) # explicit because 200
return {"status": "ok", "current_session_terminated": current_terminated}
@@ -433,15 +460,28 @@ async def api_set_session(
@app.delete("/user/credential/{uuid}")
async def api_delete_credential(
request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth")
request: Request,
response: Response,
uuid: UUID,
auth: str = Cookie(None, alias="__Host-auth"),
):
await delete_credential(uuid, auth, host=request.headers.get("host"))
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
return {"message": "Credential deleted successfully"}
@app.post("/user/create-link")
async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")):
s = await get_session(auth, host=request.headers.get("host"))
async def api_create_link(
request: Request,
response: Response,
auth=Cookie(None, alias="__Host-auth"),
):
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
token = passphrase.generate()
expiry = expires()
await db.instance.create_reset_token(

View File

@@ -2,7 +2,7 @@ import logging
import os
from contextlib import asynccontextmanager
from fastapi import Cookie, FastAPI, HTTPException, Request
from fastapi import Cookie, FastAPI, HTTPException, Request, Response
from fastapi.responses import FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
@@ -46,6 +46,40 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
app = FastAPI(lifespan=lifespan)
@app.middleware("http")
async def auth_host_redirect(request, call_next): # pragma: no cover
cfg = hostutil.configured_auth_host()
if not cfg:
return await call_next(request)
cur = hostutil.normalize_host(request.headers.get("host"))
if not cur or cur == hostutil.normalize_host(cfg):
return await call_next(request)
p = request.url.path or "/"
ui = {"/", "/admin", "/admin/", "/auth/", "/auth/admin", "/auth/admin/"}
restricted = p.startswith(
("/auth/api/admin", "/auth/api/user", "/auth/api/ws", "/auth/ws/")
)
ui_match = p in ui
if not ui_match:
# Treat reset token pages as UI (dynamic). Accept single-segment tokens.
if p.startswith("/auth/"):
t = p[6:]
if t and "/" not in t and passphrase.is_well_formed(t):
ui_match = True
else:
t = p[1:]
if t and "/" not in t and passphrase.is_well_formed(t):
ui_match = True
if not (ui_match or restricted):
return await call_next(request)
if restricted:
return Response(status_code=404)
newp = p[5:] or "/" if ui_match and p.startswith("/auth") else p
return RedirectResponse(f"{request.url.scheme}://{cfg}{newp}", 307)
app.mount("/auth/admin/", admin.app)
app.mount("/auth/api/", api.app)
app.mount("/auth/ws/", ws.app)
@@ -59,8 +93,26 @@ app.mount(
@app.get("/")
@app.get("/auth/")
async def frontapp():
return FileResponse(frontend.file("index.html"))
async def frontapp(
request: Request, response: Response, auth=Cookie(None, alias="__Host-auth")
):
"""Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA.
Login / authentication UX is centralized in the restricted app.
"""
if not auth:
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
from ..authsession import get_session # local import
try:
await get_session(auth, host=request.headers.get("host"))
return FileResponse(frontend.file("index.html"))
except Exception:
if auth:
from . import session as session_mod
session_mod.clear_session_cookie(response)
return FileResponse(frontend.file("restricted", "index.html"), status_code=401)
@app.get("/admin", include_in_schema=False)
@@ -71,7 +123,7 @@ async def admin_root_redirect():
@app.get("/admin/", include_in_schema=False)
async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")):
return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/
return await admin.adminapp(request, auth) # Delegated (enforces access control)
@app.get("/{reset}")

View File

@@ -35,3 +35,17 @@ def set_session_cookie(response: Response, token: str) -> None:
path="/",
samesite="lax",
)
def clear_session_cookie(response: Response) -> None:
# FastAPI's delete_cookie does not set the secure attribute
response.set_cookie(
key=AUTH_COOKIE_NAME,
value="",
max_age=0,
expires=0,
httponly=True,
secure=True,
path="/",
samesite="lax",
)

View File

@@ -73,14 +73,20 @@ def reload_config() -> None:
def normalize_host(raw_host: str | None) -> str | None:
"""Normalize a Host header or hostname by stripping port and lowercasing."""
"""Normalize a Host header preserving port (exact match required)."""
if not raw_host:
return None
candidate = raw_host.strip()
if not candidate:
return None
# Ensure urlsplit can parse bare hosts (prepend //)
# urlsplit to parse (add // for scheme-less); prefer netloc to retain port.
parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}")
host = parsed.hostname or parsed.path or ""
host = host.strip("[]") # Remove IPv6 brackets if present
return host.lower() if host else None
netloc = parsed.netloc or parsed.path or ""
# Strip IPv6 brackets around host part but retain port suffix.
if netloc.startswith("["):
# format: [ipv6]:port or [ipv6]
if "]" in netloc:
host_part, _, rest = netloc.partition("]")
port_part = rest.lstrip(":")
netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "")
return netloc.lower() or None