lazyflat/web/auth.py
Moritz 69f2f1f635 lazyflat: combined alert + apply behind authenticated web UI
Three isolated services (alert scraper, apply HTTP worker, web UI+DB)
with argon2 auth, signed cookies, CSRF, rate-limited login, kill switch,
apply circuit breaker, audit log, and strict CSP.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 09:51:35 +02:00

134 lines
3.9 KiB
Python

import hmac
import secrets
import threading
import time
from typing import Optional
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, InvalidHash
from fastapi import HTTPException, Request, Response, status
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from settings import (
AUTH_PASSWORD_HASH,
AUTH_USERNAME,
COOKIE_SECURE,
LOGIN_RATE_LIMIT,
LOGIN_RATE_WINDOW_SECONDS,
SESSION_COOKIE_NAME,
SESSION_MAX_AGE_SECONDS,
SESSION_SECRET,
)
_hasher = PasswordHasher()
_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="session")
_csrf_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="csrf")
# ---------- Password & session ----------
def verify_password(username: str, password: str) -> bool:
if not hmac.compare_digest(username or "", AUTH_USERNAME):
# run hasher anyway to keep timing similar (and not leak whether user exists)
try:
_hasher.verify(AUTH_PASSWORD_HASH, password)
except Exception:
pass
return False
try:
_hasher.verify(AUTH_PASSWORD_HASH, password)
return True
except (VerifyMismatchError, InvalidHash):
return False
def issue_session_cookie(response: Response, username: str) -> None:
token = _serializer.dumps({"u": username, "iat": int(time.time())})
response.set_cookie(
key=SESSION_COOKIE_NAME,
value=token,
max_age=SESSION_MAX_AGE_SECONDS,
httponly=True,
secure=COOKIE_SECURE,
samesite="strict",
path="/",
)
def clear_session_cookie(response: Response) -> None:
response.delete_cookie(
SESSION_COOKIE_NAME,
path="/",
secure=COOKIE_SECURE,
httponly=True,
samesite="strict",
)
def current_user(request: Request) -> Optional[str]:
token = request.cookies.get(SESSION_COOKIE_NAME)
if not token:
return None
try:
data = _serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return None
return data.get("u")
def require_user(request: Request) -> str:
user = current_user(request)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
return user
# ---------- CSRF (synchronizer token bound to session) ----------
def issue_csrf_token(username: str) -> str:
return _csrf_serializer.dumps({"u": username})
def verify_csrf(request: Request, submitted: str) -> bool:
user = current_user(request)
if not user or not submitted:
return False
try:
data = _csrf_serializer.loads(submitted, max_age=SESSION_MAX_AGE_SECONDS)
except (BadSignature, SignatureExpired):
return False
return hmac.compare_digest(str(data.get("u", "")), user)
def require_csrf(request: Request, token: str) -> None:
if not verify_csrf(request, token):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad csrf")
# ---------- Login rate limiting (in-memory, per IP) ----------
_rate_lock = threading.Lock()
_rate_log: dict[str, list[float]] = {}
def rate_limit_login(ip: str) -> bool:
"""Returns True if the request is allowed."""
now = time.time()
cutoff = now - LOGIN_RATE_WINDOW_SECONDS
with _rate_lock:
attempts = [t for t in _rate_log.get(ip, []) if t > cutoff]
if len(attempts) >= LOGIN_RATE_LIMIT:
_rate_log[ip] = attempts
return False
attempts.append(now)
_rate_log[ip] = attempts
# opportunistic cleanup
if len(_rate_log) > 1024:
for k in list(_rate_log.keys()):
if not _rate_log[k] or _rate_log[k][-1] < cutoff:
_rate_log.pop(k, None)
return True
def constant_time_compare(a: str, b: str) -> bool:
return hmac.compare_digest(a or "", b or "")