Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

91
backend/app/security.py Normal file
View File

@@ -0,0 +1,91 @@
import hashlib
import hmac
import secrets
from datetime import UTC, datetime, timedelta
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerifyMismatchError
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import RateLimit
_hasher = PasswordHasher()
# Dummy-Hash gegen Timing-Angriffe: bei unbekanntem Konto wird trotzdem
# eine Verifikation durchgefuehrt, damit die Antwortzeit gleich bleibt.
_DUMMY_HASH = _hasher.hash("dummy-password-for-constant-time-comparison")
def hash_password(password: str) -> str:
return _hasher.hash(password)
def verify_password(password: str, password_hash: str | None) -> bool:
try:
_hasher.verify(password_hash or _DUMMY_HASH, password)
except (VerifyMismatchError, InvalidHashError):
return False
return password_hash is not None
def needs_rehash(password_hash: str) -> bool:
try:
return _hasher.check_needs_rehash(password_hash)
except InvalidHashError:
return True
def new_token() -> str:
"""256 Bit Zufall, URL-tauglich."""
return secrets.token_urlsafe(32)
def hash_token(token: str) -> str:
"""Token landen nur als Hash in der Datenbank. Kein Salt noetig -
der Eingabewert ist bereits hochentropisch."""
return hashlib.sha256(token.encode()).hexdigest()
def tokens_equal(a: str, b: str) -> bool:
return hmac.compare_digest(a, b)
def utcnow() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def normalize_email(email: str) -> str:
return email.strip().lower()
def check_rate_limit(
db: Session, bucket: str, *, limit: int, window_minutes: int
) -> bool:
"""True = Anfrage erlaubt. Zaehlt hoch und gibt False zurueck,
sobald das Limit im aktuellen Fenster erreicht ist."""
now = utcnow()
window = now.replace(second=0, microsecond=0)
window = window - timedelta(minutes=window.minute % window_minutes)
row = db.get(RateLimit, (bucket[:160], window))
if row is None:
row = RateLimit(bucket=bucket[:160], window_start=window, count=1)
db.add(row)
db.flush()
return True
if row.count >= limit:
return False
row.count += 1
db.flush()
return True
def purge_rate_limits(db: Session, older_than_hours: int = 24) -> None:
cutoff = utcnow() - timedelta(hours=older_than_hours)
for row in db.scalars(
select(RateLimit).where(RateLimit.window_start < cutoff)
).all():
db.delete(row)