Use encrypted cookie sessions

This commit is contained in:
kacper 2026-04-12 22:02:47 -04:00
parent a7b0352d3c
commit d84a885fdb
9 changed files with 131 additions and 27 deletions

91
auth.py
View file

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import secrets
import time
from base64 import urlsafe_b64encode
@ -7,6 +8,7 @@ from dataclasses import dataclass
from hashlib import sha256
from typing import Any
from cryptography.fernet import Fernet, InvalidToken
from fastapi import Request, Response
from settings import Settings
@ -21,6 +23,7 @@ class SessionRecord:
forgejo_token: str | None
user: dict[str, Any]
created_at: float
expires_at: float
@dataclass
@ -31,7 +34,6 @@ class OAuthStateRecord:
created_at: float
_SESSIONS: dict[str, SessionRecord] = {}
_OAUTH_STATES: dict[str, OAuthStateRecord] = {}
@ -40,7 +42,7 @@ def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | N
if header_token:
return header_token, "authorization"
session = _session_from_request(request)
session = _session_from_request(request, settings)
if session and session.forgejo_token:
return session.forgejo_token, "session"
@ -50,26 +52,30 @@ def resolve_forgejo_token(request: Request, settings: Settings) -> tuple[str | N
return None, "none"
def current_session_user(request: Request) -> dict[str, Any] | None:
session = _session_from_request(request)
def current_session_user(request: Request, settings: Settings) -> dict[str, Any] | None:
session = _session_from_request(request, settings)
return session.user if session else None
def create_login_session(
response: Response,
settings: Settings,
forgejo_token: str | None,
user: dict[str, Any],
) -> None:
session_id = secrets.token_urlsafe(32)
_SESSIONS[session_id] = SessionRecord(
created_at = time.time()
session = SessionRecord(
forgejo_token=forgejo_token,
user=user,
created_at=time.time(),
created_at=created_at,
expires_at=created_at + SESSION_MAX_AGE_SECONDS,
)
encrypted_session = _encrypt_session(session, settings)
response.set_cookie(
SESSION_COOKIE_NAME,
session_id,
encrypted_session,
httponly=True,
secure=settings.auth_cookie_secure,
samesite="lax",
max_age=SESSION_MAX_AGE_SECONDS,
path="/",
@ -77,9 +83,6 @@ def create_login_session(
def clear_login_session(request: Request, response: Response) -> None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if session_id:
_SESSIONS.pop(session_id, None)
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
@ -126,17 +129,69 @@ def _safe_return_path(value: str) -> str:
return value
def _session_from_request(request: Request) -> SessionRecord | None:
session_id = request.cookies.get(SESSION_COOKIE_NAME)
if not session_id:
def _session_from_request(request: Request, settings: Settings) -> SessionRecord | None:
encrypted_session = request.cookies.get(SESSION_COOKIE_NAME)
if not encrypted_session or not settings.auth_secret_key:
return None
session = _SESSIONS.get(session_id)
if not session:
try:
payload = _session_cipher(settings).decrypt(
encrypted_session.encode("utf-8"),
ttl=SESSION_MAX_AGE_SECONDS,
)
raw_session = json.loads(payload.decode("utf-8"))
except (InvalidToken, json.JSONDecodeError, UnicodeDecodeError, ValueError):
return None
if time.time() - session.created_at > SESSION_MAX_AGE_SECONDS:
_SESSIONS.pop(session_id, None)
session = _session_from_payload(raw_session)
if session is None or session.expires_at <= time.time():
return None
return session
def _encrypt_session(session: SessionRecord, settings: Settings) -> str:
payload = {
"forgejo_token": session.forgejo_token,
"user": session.user,
"created_at": session.created_at,
"expires_at": session.expires_at,
}
return (
_session_cipher(settings)
.encrypt(
json.dumps(payload, separators=(",", ":")).encode("utf-8"),
)
.decode("utf-8")
)
def _session_from_payload(payload: object) -> SessionRecord | None:
if not isinstance(payload, dict):
return None
forgejo_token = payload.get("forgejo_token")
user = payload.get("user")
created_at = payload.get("created_at")
expires_at = payload.get("expires_at")
if forgejo_token is not None and not isinstance(forgejo_token, str):
return None
if not isinstance(user, dict):
return None
if not isinstance(created_at, (int, float)) or not isinstance(expires_at, (int, float)):
return None
return SessionRecord(
forgejo_token=forgejo_token,
user=user,
created_at=float(created_at),
expires_at=float(expires_at),
)
def _session_cipher(settings: Settings) -> Fernet:
if not settings.auth_secret_key:
raise ValueError("AUTH_SECRET_KEY is required for encrypted login sessions.")
key = urlsafe_b64encode(sha256(settings.auth_secret_key.encode("utf-8")).digest())
return Fernet(key)