App passwords; CalDAV / CardDAV server for phones, Thunderbird, macOS
App passwords (SPEC App. D §D.5): one generated password per app, shown once, revocable on its own, revoked on a password reset; never accepted by the web sign-in. Settings → Security → App passwords (migration 0024). CalDAV / CardDAV server (SPEC Appendix E), built in: /dav/ with /.well-known/caldav|carddav, HTTP Basic with app passwords only (cached checks, throttled failures). The calendar and contacts plugins serve their own tables as collections (Plugin(dav=...)): - calendars as iCalendar: series with overrides, time zones, all-day, attendees and RSVP, alarms <-> reminders; MKCALENDAR / PROPPATCH / DELETE of calendars; subscriptions read-only; writes to upstream-synced calendars flow on upstream; - address books as vCard (3.0 served, 3.0/4.0 read), groups as CATEGORIES; - what a client stores is served back verbatim until the web app edits it; - sync tokens / CTags from change logs filled by triggers (calendar0008, contacts0002), so every write path counts; trimmed nightly after 90 days. REPORTs: calendar-query (time range), calendar-/addressbook-multiget, addressbook-query, sync-collection. nginx and the Vite proxy route /dav/. Settings shows the server and user name to enter; install guide §5.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user