Session keys hardened (namespaced hashes of tokens). Various cleanup.

This commit is contained in:
Leo Vasanko
2026-02-15 20:14:48 +00:00
parent 18722f0e01
commit e59852b44c
21 changed files with 495 additions and 565 deletions
+37 -4
View File
@@ -3,6 +3,7 @@ from contextlib import suppress
from datetime import UTC, datetime, timedelta
from fastapi import (
Body,
Depends,
FastAPI,
HTTPException,
@@ -13,7 +14,7 @@ from fastapi import (
from fastapi.responses import JSONResponse
from fastapi.security import HTTPBearer
from paskia import db
from paskia import authcode, db
from paskia._version import __version__
from paskia.authsession import EXPIRES, expires, get_reset
from paskia.fastapi import authz, session, user
@@ -22,7 +23,7 @@ from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME, get_client_ip
from paskia.globals import passkey as global_passkey
from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev
bearer_auth = HTTPBearer(auto_error=True)
bearer_auth = HTTPBearer(auto_error=False)
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
@@ -67,6 +68,34 @@ async def general_exception_handler(
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.post("/exchange")
async def exchange_code(
request: Request,
response: Response,
code: str = Body(..., embed=True),
):
"""Exchange a session code for setting the session cookie.
Called by frontend after WebSocket authentication.
The code is ephemeral (60s TTL) and can only be used once.
"""
auth_code = authcode.codes.pop(code, None)
if not auth_code:
raise HTTPException(status_code=400, detail="Invalid or expired code")
secret = auth_code.session_key
# Verify the session exists
host = hostutil.normalize_host(request.headers.get("host", ""))
ctx = db.data().session_ctx(secret, host)
if not ctx:
raise HTTPException(status_code=400, detail="Session not found")
# Set the session cookie
session.set_session_cookie(response, secret)
return {"status": "ok", "user": str(ctx.user.uuid)}
@app.post("/validate")
async def validate_token(
request: Request,
@@ -91,7 +120,7 @@ async def validate_token(
consumed = EXPIRES - (ctx.session.expiry - datetime.now(UTC))
if not timedelta(0) < consumed < _REFRESH_INTERVAL:
db.update_session(
auth,
ctx.session.key,
ip=get_client_ip(request),
user_agent=request.headers.get("user-agent") or "",
expiry=expires(),
@@ -230,6 +259,8 @@ async def api_user_info(
@app.get("/token-info")
async def token_info(credentials=Depends(bearer_auth)):
"""Get reset/device-add token info. Pass token via Bearer header."""
if not credentials or not credentials.credentials:
raise HTTPException(401, "Bearer token required")
token = credentials.credentials
if not passphrase.is_well_formed(token):
raise HTTPException(400, "Invalid token format")
@@ -254,7 +285,7 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
if not ctx:
return {"message": "Already logged out"}
with suppress(Exception):
db.delete_session(auth, ctx=ctx, action="logout")
db.delete_session(ctx.session.key, ctx=ctx, action="logout")
session.clear_session_cookie(response)
return {"message": "Logged out successfully"}
@@ -263,6 +294,8 @@ async def api_logout(request: Request, response: Response, auth=AUTH_COOKIE):
async def api_set_session(
request: Request, response: Response, auth=Depends(bearer_auth)
):
if not auth or not auth.credentials:
raise HTTPException(401, "Bearer token required")
ctx = db.data().session_ctx(auth.credentials, request.headers.get("host"))
if not ctx:
raise HTTPException(401, "Session expired")