Files

94 lines
3.2 KiB
Python
Raw Permalink Normal View History

"""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