Use encrypted cookie sessions
This commit is contained in:
parent
a7b0352d3c
commit
d84a885fdb
9 changed files with 131 additions and 27 deletions
91
auth.py
91
auth.py
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue