Adding the new rewrite
This commit is contained in:
1 parent
4aca9f08d1
commit
9b48b01d1d
62 files changed
+4510
-1406
No files matched your search
+159
@@ -0,0 +1,159 @@
|
||||
"""Authentication: argon2 password hashing + signed session cookies.
|
||||
|
||||
Single-admin design for homelab use. Credentials set via env vars:
|
||||
ADMIN_USERNAME, ADMIN_PASSWORD_HASH, SESSION_SECRET
|
||||
|
||||
If ADMIN_PASSWORD_HASH is unset, the first-run setup page generates one
|
||||
and stores it in Redis under the key ``ups:admin:hash``. The stored hash
|
||||
takes precedence over env vars so the operator can change their password
|
||||
via the UI.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, Response
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
from passlib.hash import argon2
|
||||
|
||||
from .settings import settings
|
||||
from .storage import get_redis
|
||||
|
||||
SESSION_COOKIE = "ups_session"
|
||||
CSRF_COOKIE = "csrf_token"
|
||||
CSRF_HEADER = "X-CSRF-Token"
|
||||
ADMIN_HASH_KEY = "ups:admin:hash"
|
||||
ADMIN_USER_KEY = "ups:admin:username"
|
||||
|
||||
|
||||
def _serializer() -> URLSafeTimedSerializer:
|
||||
secret = settings.session_secret
|
||||
if not secret:
|
||||
# Best effort ephemeral secret so we don't crash at import time in dev
|
||||
secret = "dev-insecure-change-me-please"
|
||||
return URLSafeTimedSerializer(secret, salt="ups-session")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Produce an argon2 hash of the given password."""
|
||||
return argon2.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, hashed: str) -> bool:
|
||||
try:
|
||||
return argon2.verify(password, hashed)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_stored_admin() -> tuple[str | None, str | None]:
|
||||
"""Return (username, password_hash) from Redis or settings.
|
||||
|
||||
Redis-stored credentials take precedence when present.
|
||||
"""
|
||||
try:
|
||||
r = get_redis()
|
||||
redis_hash = r.get(ADMIN_HASH_KEY)
|
||||
redis_user = r.get(ADMIN_USER_KEY)
|
||||
except Exception:
|
||||
redis_hash = None
|
||||
redis_user = None
|
||||
username = redis_user or settings.admin_username
|
||||
pw_hash = redis_hash or settings.admin_password_hash
|
||||
return username, pw_hash
|
||||
|
||||
|
||||
def store_admin(username: str, password: str) -> None:
|
||||
"""Persist admin credentials to Redis."""
|
||||
r = get_redis()
|
||||
r.set(ADMIN_USER_KEY, username)
|
||||
r.set(ADMIN_HASH_KEY, hash_password(password))
|
||||
|
||||
|
||||
def is_admin_configured() -> bool:
|
||||
_, pw_hash = get_stored_admin()
|
||||
return bool(pw_hash)
|
||||
|
||||
|
||||
def create_session_token(username: str) -> str:
|
||||
return _serializer().dumps({"u": username})
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> str | None:
|
||||
try:
|
||||
data = _serializer().loads(
|
||||
token, max_age=settings.session_max_age_seconds
|
||||
)
|
||||
if isinstance(data, dict):
|
||||
return data.get("u")
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def make_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def set_auth_cookies(response: Response, username: str) -> str:
|
||||
"""Set session + CSRF cookies on the response. Returns CSRF token."""
|
||||
session_token = create_session_token(username)
|
||||
csrf_token = make_csrf_token()
|
||||
secure = settings.trust_proxy
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
session_token,
|
||||
max_age=settings.session_max_age_seconds,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
CSRF_COOKIE,
|
||||
csrf_token,
|
||||
max_age=settings.session_max_age_seconds,
|
||||
httponly=False, # JS needs to read this
|
||||
samesite="lax",
|
||||
secure=secure,
|
||||
path="/",
|
||||
)
|
||||
return csrf_token
|
||||
|
||||
|
||||
def clear_auth_cookies(response: Response) -> None:
|
||||
response.delete_cookie(SESSION_COOKIE, path="/")
|
||||
response.delete_cookie(CSRF_COOKIE, path="/")
|
||||
|
||||
|
||||
def current_user(request: Request) -> str | None:
|
||||
"""Return current session username or None."""
|
||||
token = request.cookies.get(SESSION_COOKIE)
|
||||
if not token:
|
||||
return None
|
||||
return verify_session_token(token)
|
||||
|
||||
|
||||
def require_session(request: Request) -> str:
|
||||
"""FastAPI dependency - 401 if not authenticated."""
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def require_csrf(request: Request) -> None:
|
||||
"""Enforce double-submit CSRF token on mutating requests."""
|
||||
if request.method in ("GET", "HEAD", "OPTIONS"):
|
||||
return
|
||||
cookie_token = request.cookies.get(CSRF_COOKIE)
|
||||
header_token = request.headers.get(CSRF_HEADER)
|
||||
if not cookie_token or not header_token or cookie_token != header_token:
|
||||
raise HTTPException(status_code=403, detail="CSRF token invalid or missing")
|
||||
|
||||
|
||||
def require_session_and_csrf(
|
||||
request: Request, user: str = Depends(require_session)
|
||||
) -> str:
|
||||
require_csrf(request)
|
||||
return user
|
||||
Reference in new issue
Block a user