"""Passwörter, Benutzer-Cache und Brute-Force-Schutz für die Proxy-Anmeldung.""" import asyncio import hashlib import hmac import os import secrets import time from .db import db ITERATIONS = 200_000 def hash_password(password: str) -> str: salt = os.urandom(16) dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, ITERATIONS) return f"pbkdf2_sha256${ITERATIONS}${salt.hex()}${dk.hex()}" def verify_password(password: str, stored: str) -> bool: try: algo, iters, salt, digest = stored.split("$") if algo != "pbkdf2_sha256": return False dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt), int(iters)) return hmac.compare_digest(dk.hex(), digest) except (ValueError, TypeError): return False def new_token() -> str: return secrets.token_urlsafe(32) def token_hash(token: str) -> str: return hashlib.sha256(token.encode()).hexdigest() class UserCache: """Hält Benutzer im Speicher und merkt sich erfolgreiche Anmeldungen, damit nicht jede Proxy-Verbindung eine teure PBKDF2-Prüfung auslöst.""" VERIFIED_TTL = 600 FAIL_WINDOW = 300 FAIL_LIMIT = 10 def __init__(self): self.users: dict[str, dict] = {} self.verified: dict[tuple, float] = {} self.failures: dict[str, list[float]] = {} self.last_seen: dict[int, float] = {} self.reload() def reload(self) -> None: rows = db.query("SELECT * FROM users") self.users = {r["username"].lower(): r for r in rows} self.verified.clear() def get(self, username: str) -> dict | None: return self.users.get(username.lower()) def is_locked(self, ip: str) -> bool: now = time.time() fails = [t for t in self.failures.get(ip, []) if now - t < self.FAIL_WINDOW] self.failures[ip] = fails return len(fails) >= self.FAIL_LIMIT def _fail(self, ip: str) -> None: self.failures.setdefault(ip, []).append(time.time()) async def authenticate(self, username: str, password: str, ip: str) -> dict | None: if self.is_locked(ip): return None user = self.get(username) if not user or not user["enabled"]: self._fail(ip) return None key = (user["id"], hashlib.sha256((password + user["password_hash"]).encode()).digest()) exp = self.verified.get(key) now = time.time() if exp and exp > now: self.touch(user["id"]) return user ok = await asyncio.to_thread(verify_password, password, user["password_hash"]) if not ok: self._fail(ip) return None if len(self.verified) > 5000: self.verified.clear() self.verified[key] = now + self.VERIFIED_TTL self.touch(user["id"]) return user def touch(self, user_id: int) -> None: self.last_seen[user_id] = time.time() def flush_last_seen(self) -> None: if not self.last_seen: return items, self.last_seen = list(self.last_seen.items()), {} db.executemany("UPDATE users SET last_seen=? WHERE id=?", [(t, uid) for uid, t in items]) users = UserCache()