multi-user: users, per-user profiles/filters/notifications, tab UI, apply forensics
* DB: users + user_profiles/filters/notifications/preferences; applications gets user_id + forensics_json + profile_snapshot_json; new errors table with 14d retention; schema versioning via MIGRATIONS list * auth: password hashes in DB (argon2); env vars seed first admin; per-user sessions; CSRF bound to user id * apply: personal info/WBS moved out of env into the request body; providers take an ApplyContext with Profile + submit_forms; full Playwright recorder (step log, console, page errors, network, screenshots, final HTML) * web: five top-level tabs (Wohnungen/Bewerbungen/Logs/Fehler/Einstellungen); settings sub-tabs profil/filter/benachrichtigungen/account/benutzer; per-user matching, auto-apply and notifications (UI/Telegram/SMTP); red auto-apply switch on Wohnungen tab; forensics detail view for bewerbungen and fehler; retention background thread Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e663386a19
commit
c630b500ef
36 changed files with 2763 additions and 1113 deletions
130
web/auth.py
130
web/auth.py
|
|
@ -1,14 +1,24 @@
|
|||
"""
|
||||
Authentication and session handling.
|
||||
|
||||
- Users live in the DB (users table).
|
||||
- On boot we seed an admin from env if no user exists yet.
|
||||
- Sessions are signed cookies (itsdangerous); CSRF is a separate signed token.
|
||||
- Login is rate-limited per IP.
|
||||
"""
|
||||
import hmac
|
||||
import secrets
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError, InvalidHash
|
||||
from argon2.exceptions import InvalidHash, VerifyMismatchError
|
||||
from fastapi import HTTPException, Request, Response, status
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
import db
|
||||
from settings import (
|
||||
AUTH_PASSWORD_HASH,
|
||||
AUTH_USERNAME,
|
||||
|
|
@ -20,30 +30,63 @@ from settings import (
|
|||
SESSION_SECRET,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("web.auth")
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="session")
|
||||
_csrf_serializer = URLSafeTimedSerializer(SESSION_SECRET, salt="csrf")
|
||||
|
||||
|
||||
# ---------- Password & session ----------
|
||||
# ---------- Password hashing --------------------------------------------------
|
||||
|
||||
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
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_hash(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
_hasher.verify(AUTH_PASSWORD_HASH, password)
|
||||
_hasher.verify(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())})
|
||||
# ---------- Admin bootstrap ---------------------------------------------------
|
||||
|
||||
def bootstrap_admin() -> None:
|
||||
"""If no users exist, create one from env vars as admin."""
|
||||
existing = db.list_users()
|
||||
if existing:
|
||||
logger.info("users already present (%d), skipping admin bootstrap", len(existing))
|
||||
return
|
||||
try:
|
||||
uid = db.create_user(AUTH_USERNAME, AUTH_PASSWORD_HASH, is_admin=True)
|
||||
logger.info("bootstrapped admin user '%s' (id=%s) from env", AUTH_USERNAME, uid)
|
||||
except sqlite3.IntegrityError as e:
|
||||
logger.warning("bootstrap admin failed: %s", e)
|
||||
|
||||
|
||||
# ---------- Login -------------------------------------------------------------
|
||||
|
||||
def verify_login(username: str, password: str) -> Optional[sqlite3.Row]:
|
||||
"""Return user row on success, None otherwise. Timing-safe path runs hash check either way."""
|
||||
user = db.get_user_by_username(username or "")
|
||||
if user is None:
|
||||
# Run the hasher anyway so timing doesn't leak existence
|
||||
try:
|
||||
_hasher.verify(AUTH_PASSWORD_HASH, password or "")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
if not verify_hash(user["password_hash"], password or ""):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
# ---------- Session cookies ---------------------------------------------------
|
||||
|
||||
def issue_session_cookie(response: Response, user_id: int) -> None:
|
||||
token = _serializer.dumps({"uid": user_id, "iat": int(time.time())})
|
||||
response.set_cookie(
|
||||
key=SESSION_COOKIE_NAME,
|
||||
value=token,
|
||||
|
|
@ -57,15 +100,12 @@ def issue_session_cookie(response: Response, username: str) -> None:
|
|||
|
||||
def clear_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
path="/",
|
||||
secure=COOKIE_SECURE,
|
||||
httponly=True,
|
||||
samesite="strict",
|
||||
SESSION_COOKIE_NAME, path="/",
|
||||
secure=COOKIE_SECURE, httponly=True, samesite="strict",
|
||||
)
|
||||
|
||||
|
||||
def current_user(request: Request) -> Optional[str]:
|
||||
def current_user(request: Request) -> Optional[sqlite3.Row]:
|
||||
token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
if not token:
|
||||
return None
|
||||
|
|
@ -73,46 +113,57 @@ def current_user(request: Request) -> Optional[str]:
|
|||
data = _serializer.loads(token, max_age=SESSION_MAX_AGE_SECONDS)
|
||||
except (BadSignature, SignatureExpired):
|
||||
return None
|
||||
return data.get("u")
|
||||
uid = data.get("uid")
|
||||
if not uid:
|
||||
return None
|
||||
u = db.get_user(uid)
|
||||
if u is None or u["disabled"]:
|
||||
return None
|
||||
return u
|
||||
|
||||
|
||||
def require_user(request: Request) -> str:
|
||||
user = current_user(request)
|
||||
if not user:
|
||||
def require_user(request: Request) -> sqlite3.Row:
|
||||
u = current_user(request)
|
||||
if u is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="login required")
|
||||
return user
|
||||
return u
|
||||
|
||||
|
||||
# ---------- CSRF (synchronizer token bound to session) ----------
|
||||
|
||||
def issue_csrf_token(username: str) -> str:
|
||||
return _csrf_serializer.dumps({"u": username})
|
||||
def require_admin(request: Request) -> sqlite3.Row:
|
||||
u = require_user(request)
|
||||
if not u["is_admin"]:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin only")
|
||||
return u
|
||||
|
||||
|
||||
def verify_csrf(request: Request, submitted: str) -> bool:
|
||||
user = current_user(request)
|
||||
if not user or not submitted:
|
||||
# ---------- CSRF --------------------------------------------------------------
|
||||
|
||||
def issue_csrf_token(user_id: int) -> str:
|
||||
return _csrf_serializer.dumps({"uid": user_id})
|
||||
|
||||
|
||||
def verify_csrf(user_id: int, submitted: str) -> bool:
|
||||
if 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)
|
||||
return int(data.get("uid", -1)) == int(user_id)
|
||||
|
||||
|
||||
def require_csrf(request: Request, token: str) -> None:
|
||||
if not verify_csrf(request, token):
|
||||
def require_csrf(user_id: int, token: str) -> None:
|
||||
if not verify_csrf(user_id, token):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="bad csrf")
|
||||
|
||||
|
||||
# ---------- Login rate limiting (in-memory, per IP) ----------
|
||||
# ---------- Login rate limiting ----------------------------------------------
|
||||
|
||||
_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:
|
||||
|
|
@ -122,13 +173,8 @@ def rate_limit_login(ip: str) -> bool:
|
|||
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 "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue