"""Registration, authentication, and login rate limiting (SPEC §6.1).""" from __future__ import annotations from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.identity.handles import InvalidHandle, full_handle, normalize_localpart from app.identity.passwords import hash_password, verify_password from app.models import User from app.redis_client import get_redis # SPEC §6.1: 10 attempts / 15 min per handle AND per IP. _RL_MAX = 10 _RL_WINDOW = 15 * 60 class RegistrationError(Exception): """Registration failed for a user-actionable reason.""" def __init__(self, code: str, message: str) -> None: super().__init__(message) self.code = code self.message = message @dataclass(frozen=True) class RateLimited(Exception): """Raised when the login rate limit is exceeded.""" retry_after: int class TotpRequired(Exception): """Password verified but the account has 2FA enabled and no code was given.""" class TotpInvalid(Exception): """Password verified but the supplied TOTP code was wrong.""" def resolve_handle(handle_input: str) -> str: """Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form.""" text = handle_input.strip().lower() if "@" in text: return text return f"{normalize_localpart(text)}@{get_settings().app_domain}" async def register_user( db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None ) -> User: """Create a new user. Raises :class:`RegistrationError` on validation/uniqueness.""" try: handle = full_handle(localpart) except InvalidHandle as exc: raise RegistrationError(exc.code, exc.message) from exc user = User( handle=handle, password_hash=hash_password(password), recovery_email=(recovery_email.strip().lower() if recovery_email else None), ) db.add(user) try: await db.flush() except IntegrityError as exc: await db.rollback() raise RegistrationError("handle_taken", "that handle is already registered") from exc return user async def _bump_rate_limit(key: str) -> int: redis = get_redis() count = await redis.incr(key) if count == 1: await redis.expire(key, _RL_WINDOW) return int(count) async def _check_rate_limit(handle: str, ip: str) -> None: for key in (f"rl:login:handle:{handle}", f"rl:login:ip:{ip}"): count = await _bump_rate_limit(key) if count > _RL_MAX: ttl = await get_redis().ttl(key) raise RateLimited(retry_after=max(ttl, 1)) async def authenticate( db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None ) -> User | None: """Verify credentials (constant-time). Returns the user or None; may rate-limit. When the account has TOTP enabled, raises :class:`TotpRequired` (no code given) or :class:`TotpInvalid` (wrong code) after the password check passes. """ handle = resolve_handle(handle_input) await _check_rate_limit(handle, ip) user = await db.scalar(select(User).where(User.handle == handle)) # Always run a verify to keep timing uniform whether or not the user exists. stored_hash = user.password_hash if user else _DUMMY_HASH ok = verify_password(stored_hash, password) if user is None or not ok: return None if user.totp_secret is not None: from app.identity.totp import decrypt_totp_secret from app.identity.totp import verify as verify_totp if not totp: raise TotpRequired if not verify_totp(decrypt_totp_secret(user.totp_secret), totp): raise TotpInvalid return user # A precomputed hash so authentication of a non-existent handle still spends # argon2 time (mitigates user-enumeration via timing, SPEC §6.1 constant-time). _DUMMY_HASH = hash_password("x" * 24)