Erste Produktivversion
This commit is contained in:
91
backend/app/security.py
Normal file
91
backend/app/security.py
Normal 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)
|
||||
Reference in New Issue
Block a user