"""Signing in a DAV client: HTTP Basic with an app password (SPEC App. E.2). Only app passwords: the account password (with or without 2FA) never works here, so a lost phone is one revocation away and 2FA cannot be bypassed. A sync makes many requests, each with the credentials; a successful check is remembered for a few minutes under a hash of the password, and forgotten as soon as the user's app passwords change (``app_passwords`` bumps a counter). """ from __future__ import annotations import base64 import binascii import datetime as dt import hashlib import uuid from sqlalchemy import select, update from app.db import session_scope from app.identity import app_passwords from app.identity.service import resolve_handle from app.models import User, UserAppPassword from app.redis_client import get_redis CACHE_S = 300 FAIL_WINDOW_S = 900 MAX_FAILS_PER_ADDRESS = 10 MAX_FAILS_PER_IP = 30 _LAST_USED_EVERY = dt.timedelta(minutes=5) class Throttled(Exception): """Too many failed sign-ins recently.""" def _credentials(header: str | None) -> tuple[str, str] | None: if not header or not header.lower().startswith("basic "): return None try: decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8") except (binascii.Error, UnicodeDecodeError): return None login, sep, password = decoded.partition(":") return (login.strip(), password) if sep and login.strip() else None async def authenticate(header: str | None, ip: str) -> User | None: """The user these Basic credentials sign in, or None. Raises Throttled.""" creds = _credentials(header) if creds is None: return None login, password = creds redis = get_redis() handle = resolve_handle(login) fail_keys = (f"dav:fail:addr:{handle}", f"dav:fail:ip:{ip}") fails = [int(await redis.get(k) or 0) for k in fail_keys] if fails[0] >= MAX_FAILS_PER_ADDRESS or fails[1] >= MAX_FAILS_PER_IP: raise Throttled async with session_scope() as db: user = await db.scalar(select(User).where(User.handle == handle)) if user is None or user.blocked_at is not None: await _failed(fail_keys) return None gen = int(await redis.get(app_passwords.generation_key(user.id)) or 0) digest = hashlib.sha256(f"{user.id}:{password}".encode()).hexdigest() cache_key = f"dav:auth:{user.id}:{gen}:{digest}" if await redis.get(cache_key): return user row = await app_passwords.verify(db, user.id, password) if row is None: await _failed(fail_keys) return None await redis.set(cache_key, str(row.id), ex=CACHE_S) now = dt.datetime.now(dt.UTC) if row.last_used_at is None or now - row.last_used_at > _LAST_USED_EVERY: await db.execute( update(UserAppPassword).where(UserAppPassword.id == row.id).values(last_used_at=now) ) db.expunge(user) return user async def _failed(keys: tuple[str, ...]) -> None: redis = get_redis() for key in keys: await redis.incr(key) await redis.expire(key, FAIL_WINDOW_S) def user_id_of(user: User) -> uuid.UUID: return user.id