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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 --------------------
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user