125 lines
3.9 KiB
Python
125 lines
3.9 KiB
Python
"""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)
|