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:
2026-10-09 21:49:04 +02:00
co-authored by Claude Opus 5.5
parent fa552392c8
commit 0e3793a4d6
30 changed files with 3968 additions and 4 deletions
+4
View File
@@ -49,6 +49,10 @@ def register_routers(app: FastAPI) -> None:
):
app.include_router(router, prefix="/api")
app.include_router(ws_router, prefix="/api")
# CalDAV / CardDAV (SPEC Appendix E): at the root, where clients look for it.
from app.dav.routes import router as dav_router
app.include_router(dav_router)
# Mount discovered plugin routers at /api/plugins/{name} (SPEC §11). Hooks are
# subscribed in the lifespan startup so they register once per process.
+1
View File
@@ -0,0 +1 @@
"""CalDAV / CardDAV server (SPEC Appendix E)."""
+93
View File
@@ -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
+118
View File
@@ -0,0 +1,118 @@
"""What a plugin implements to serve its collections over DAV (SPEC App. E.1).
The core (app/dav/routes.py) speaks the protocol; a provider only knows its
own data: collections (calendars, address books) and the resources in them
(one iCalendar object / one vCard each), named, with an ETag. Every method
gets the signed-in user's id; providers open the user's RLS session
(``sdk(name).session(user_id)``) themselves.
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal, Protocol
class DavError(Exception):
"""A request the provider refuses: ``status`` is the HTTP answer
(403 read-only, 404 missing, 409 conflict, 412 precondition failed, 415
unsupported data); ``condition`` an optional DAV precondition element
(e.g. ``{urn:ietf:params:xml:ns:caldav}valid-calendar-data``)."""
def __init__(self, status: int, message: str = "", condition: str | None = None) -> None:
super().__init__(message or str(status))
self.status = status
self.condition = condition
@dataclass
class Collection:
id: str
displayname: str
sync_token: int # the latest change number; also the CTag
description: str = ""
color: str | None = None # "#rrggbb"
read_only: bool = False
components: list[str] = field(default_factory=lambda: ["VEVENT"]) # calendars only
@dataclass
class Resource:
name: str # last path segment, e.g. "abc.ics"
etag: str # without quotes
data: str | None = None # the iCalendar / vCard text, when asked for
@dataclass
class Changes:
sync_token: int
changed: list[str] # resource names changed or added since the old token
deleted: list[str] # resource names gone since the old token
class DavProvider(Protocol):
kind: Literal["calendar", "addressbook"]
content_type: str # "text/calendar; charset=utf-8" / "text/vcard; charset=utf-8"
extension: str # ".ics" / ".vcf"
async def collections(self, user_id: uuid.UUID) -> list[Collection]: ...
async def resources(self, user_id: uuid.UUID, collection_id: str) -> list[Resource]: ...
async def get(
self, user_id: uuid.UUID, collection_id: str, names: list[str] | None
) -> list[Resource]: ...
async def put(
self,
user_id: uuid.UUID,
collection_id: str,
name: str,
data: str,
*,
if_match: str | None,
if_none_match: bool,
) -> tuple[str, bool]:
"""Store ``data`` as ``name``; returns (new ETag, created)."""
...
async def delete(
self, user_id: uuid.UUID, collection_id: str, name: str, *, if_match: str | None
) -> None: ...
async def changes(self, user_id: uuid.UUID, collection_id: str, since: int) -> Changes | None:
"""What changed after change number ``since``; None when that token is
too old to answer (the client then resyncs from scratch)."""
...
async def create_collection(
self, user_id: uuid.UUID, collection_id: str, displayname: str, color: str | None
) -> None:
"""Create a collection at the id the client chose (MKCALENDAR / MKCOL);
DavError(403) when that id cannot be used."""
...
async def update_collection(
self,
user_id: uuid.UUID,
collection_id: str,
*,
displayname: str | None = None,
color: str | None = None,
description: str | None = None,
) -> None: ...
async def delete_collection(self, user_id: uuid.UUID, collection_id: str) -> None: ...
async def query(
self,
user_id: uuid.UUID,
collection_id: str,
start: datetime | None,
end: datetime | None,
) -> list[str] | None:
"""Calendars: names of resources with an occurrence overlapping the
range; None when the provider cannot filter (the core then returns all)."""
...
+595
View File
@@ -0,0 +1,595 @@
"""The CalDAV / CardDAV endpoints (SPEC Appendix E).
/.well-known/caldav, /.well-known/carddav → 301 /dav/
/dav/ root: points at the principal
/dav/principals/{user}/ the signed-in user
/dav/calendars/{user}/[{calendar}/[{name}]] calendar home, calendars, events
/dav/addressbooks/{user}/[{book}/[{name}]] address-book home, books, contacts
The data comes from the plugins' providers (app/dav/provider.py): the calendar
plugin's for ``calendars``, the contacts plugin's for ``addressbooks``. Signed
in with HTTP Basic and an app password only (app/dav/auth.py); session cookies
are never looked at, so a web page cannot drive these endpoints.
"""
from __future__ import annotations
import logging
import uuid
import xml.etree.ElementTree as ET
from typing import Any
from urllib.parse import quote, unquote, urlsplit
from fastapi import APIRouter, Request, Response
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.dav import auth
from app.dav.provider import Collection, DavError, DavProvider, Resource
from app.dav.xml import (
CR,
CS,
A,
BadRequest,
C,
D,
Multistatus,
PropValue,
el,
error_body,
href,
mkcol_props,
parse,
propfind_request,
proppatch_request,
q,
report_request,
)
from app.db import session_scope
from app.models import Account, User
logger = logging.getLogger(__name__)
router = APIRouter()
METHODS = [
"OPTIONS",
"PROPFIND",
"PROPPATCH",
"REPORT",
"GET",
"HEAD",
"PUT",
"DELETE",
"MKCALENDAR",
"MKCOL",
]
DAV_HEADER = "1, 3, calendar-access, addressbook, extended-mkcol"
XML_TYPE = "application/xml; charset=utf-8"
HOMES = {"calendars": "calendar", "addressbooks": "addressbook"}
SYNC_PREFIX = "http://ultimo.email/ns/sync/"
def _providers() -> dict[str, DavProvider]:
from app.plugins import loader
out: dict[str, DavProvider] = {}
for plugin in loader.get_plugins():
if plugin.dav is not None:
out[plugin.dav.kind] = plugin.dav
return out
# ---- entry points ------------------------------------------------------------------
@router.api_route("/.well-known/caldav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
@router.api_route("/.well-known/carddav", methods=["GET", "HEAD", "PROPFIND", "OPTIONS"])
async def well_known() -> Response:
return RedirectResponse("/dav/", status_code=301)
@router.api_route("/dav", methods=METHODS)
@router.api_route("/dav/{path:path}", methods=METHODS)
async def dav(request: Request, path: str = "") -> Response:
method = request.method.upper()
if method == "OPTIONS":
return Response(
status_code=200,
headers={"DAV": DAV_HEADER, "Allow": ", ".join(METHODS), "Content-Length": "0"},
)
ip = request.client.host if request.client else ""
try:
user = await auth.authenticate(request.headers.get("authorization"), ip)
except auth.Throttled:
return Response(status_code=429, headers={"Retry-After": "900"})
if user is None:
return Response(
status_code=401,
headers={"WWW-Authenticate": 'Basic realm="Ultimo", charset="UTF-8"'},
)
parts = [unquote(p) for p in path.split("/") if p]
ctx = _Ctx(request, user, parts)
try:
return await ctx.handle(method)
except BadRequest as err:
return Response(status_code=400, content=str(err))
except DavError as err:
body = error_body(err.condition) if err.condition else b""
return Response(
status_code=err.status,
content=body,
media_type=XML_TYPE if body else None,
)
class _Ctx:
def __init__(self, request: Request, user: User, parts: list[str]) -> None:
self.request = request
self.user = user
self.uid = str(user.id)
self.parts = parts
self.providers = _providers()
# -- paths -----------------------------------------------------------------------
def principal(self) -> str:
return f"/dav/principals/{self.uid}/"
def home(self, segment: str) -> str:
return f"/dav/{segment}/{self.uid}/"
def coll_href(self, segment: str, coll_id: str) -> str:
return f"{self.home(segment)}{quote(coll_id, safe='')}/"
def res_href(self, segment: str, coll_id: str, name: str) -> str:
return f"{self.coll_href(segment, coll_id)}{quote(name, safe='')}"
# -- dispatch --------------------------------------------------------------------
async def handle(self, method: str) -> Response:
p = self.parts
if not p:
return await self.root(method)
if p[0] == "principals":
if len(p) != 2 or p[1] != self.uid:
raise DavError(404)
return await self.principal_req(method)
if p[0] in HOMES and len(p) >= 2:
if p[1] != self.uid:
raise DavError(403)
provider = self.providers.get(HOMES[p[0]])
if provider is None:
raise DavError(404)
if len(p) == 2:
return await self.home_req(method, p[0], provider)
if len(p) == 3:
return await self.collection_req(method, p[0], provider, p[2])
if len(p) == 4:
return await self.resource_req(method, p[0], provider, p[2], p[3])
raise DavError(404)
async def body(self) -> ET.Element | None:
return parse(await self.request.body())
def depth(self) -> int:
value = (self.request.headers.get("depth") or "1").strip().lower()
return 0 if value == "0" else 1
@staticmethod
def multistatus(ms: Multistatus) -> Response:
return Response(status_code=207, content=ms.to_bytes(), media_type=XML_TYPE)
async def respond_props(
self, items: list[tuple[str, dict[str, PropValue]]], method_root: ET.Element | None
) -> Response:
req = propfind_request(method_root)
ms = Multistatus()
for path, props in items:
if req.names_only:
ms.response(path, dict.fromkeys(props))
elif req.names is None:
ms.response(path, props)
else:
found = {n: props[n] for n in req.names if n in props}
missing = [n for n in req.names if n not in props]
ms.response(path, found, missing)
return self.multistatus(ms)
# -- root and principal ----------------------------------------------------------
def common(self) -> dict[str, PropValue]:
return {
q(D, "current-user-principal"): [href(self.principal())],
q(D, "principal-URL"): [href(self.principal())],
q(D, "owner"): [href(self.principal())],
}
async def root(self, method: str) -> Response:
if method != "PROPFIND":
return Response(status_code=200 if method in ("GET", "HEAD") else 405)
props = {**self.common(), q(D, "resourcetype"): [el(q(D, "collection"))]}
return await self.respond_props([("/dav/", props)], await self.body())
async def addresses(self) -> list[str]:
async with session_scope() as db:
emails = list(
await db.scalars(select(Account.email).where(Account.user_id == self.user.id))
)
out = [self.user.handle, *emails]
return list(dict.fromkeys(e.lower() for e in out))
async def principal_props(self) -> dict[str, PropValue]:
props = {
**self.common(),
q(D, "resourcetype"): [el(q(D, "principal")), el(q(D, "collection"))],
q(D, "displayname"): self.user.handle,
q(C, "calendar-user-address-set"): [
href(f"mailto:{a}") for a in await self.addresses()
],
q(C, "calendar-user-type"): "INDIVIDUAL",
q(D, "principal-collection-set"): [href("/dav/principals/")],
q(D, "supported-report-set"): [],
}
if "calendar" in self.providers:
props[q(C, "calendar-home-set")] = [href(self.home("calendars"))]
if "addressbook" in self.providers:
props[q(CR, "addressbook-home-set")] = [href(self.home("addressbooks"))]
return props
async def principal_req(self, method: str) -> Response:
if method == "PROPFIND":
return await self.respond_props(
[(self.principal(), await self.principal_props())], await self.body()
)
if method == "REPORT": # principal-property-search, expand-property: nothing to add
return self.multistatus(Multistatus())
return Response(status_code=405)
# -- homes -------------------------------------------------------------------------
def privileges(self, read_only: bool) -> list[ET.Element]:
names = ["read", "read-current-user-privilege-set"]
if not read_only:
names += ["write", "write-content", "write-properties", "bind", "unbind"]
return [el(q(D, "privilege"), children=[el(q(D, n))]) for n in names]
def reports(self, kind: str) -> list[ET.Element]:
ns, reports = (
(C, ["calendar-query", "calendar-multiget"])
if kind == "calendar"
else (CR, ["addressbook-query", "addressbook-multiget"])
)
items = [(ns, r) for r in reports] + [(D, "sync-collection")]
return [
el(q(D, "supported-report"), children=[el(q(D, "report"), children=[el(q(n, r))])])
for n, r in items
]
async def home_req(self, method: str, segment: str, provider: DavProvider) -> Response:
if method == "PROPFIND":
home_props: dict[str, PropValue] = {
**self.common(),
q(D, "resourcetype"): [el(q(D, "collection"))],
q(D, "displayname"): "Calendars" if provider.kind == "calendar" else "Contacts",
q(D, "current-user-privilege-set"): self.privileges(False),
}
items = [(self.home(segment), home_props)]
if self.depth():
for coll in await provider.collections(self.user.id):
items.append(
(self.coll_href(segment, coll.id), self.collection_props(provider, coll))
)
return await self.respond_props(items, await self.body())
if method == "REPORT":
return self.multistatus(Multistatus())
if method in ("GET", "HEAD"):
return Response(status_code=200)
return Response(status_code=405)
# -- collections -------------------------------------------------------------------
def collection_props(self, provider: DavProvider, coll: Collection) -> dict[str, PropValue]:
token = f"{SYNC_PREFIX}{coll.sync_token}"
props: dict[str, PropValue] = {
**self.common(),
q(D, "displayname"): coll.displayname,
q(D, "current-user-privilege-set"): self.privileges(coll.read_only),
q(D, "supported-report-set"): self.reports(provider.kind),
q(D, "sync-token"): token,
q(CS, "getctag"): str(coll.sync_token),
}
if provider.kind == "calendar":
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(C, "calendar"))]
props[q(C, "supported-calendar-component-set")] = [
el(q(C, "comp"), attrs={"name": comp}) for comp in coll.components
]
props[q(C, "supported-calendar-data")] = [
el(q(C, "calendar-data"), attrs={"content-type": "text/calendar", "version": "2.0"})
]
props[q(C, "calendar-description")] = coll.description
if coll.color:
props[q(A, "calendar-color")] = coll.color
else:
props[q(D, "resourcetype")] = [el(q(D, "collection")), el(q(CR, "addressbook"))]
props[q(CR, "supported-address-data")] = [
el(
q(CR, "address-data-type"),
attrs={"content-type": "text/vcard", "version": "3.0"},
),
el(
q(CR, "address-data-type"),
attrs={"content-type": "text/vcard", "version": "4.0"},
),
]
props[q(CR, "addressbook-description")] = coll.description
return props
async def find(self, provider: DavProvider, coll_id: str) -> Collection:
for coll in await provider.collections(self.user.id):
if coll.id == coll_id:
return coll
raise DavError(404)
def resource_props(
self, provider: DavProvider, res: Resource, with_data: bool
) -> dict[str, PropValue]:
props: dict[str, PropValue] = {
q(D, "getetag"): f'"{res.etag}"',
q(D, "getcontenttype"): provider.content_type,
q(D, "resourcetype"): [],
}
if with_data and res.data is not None:
data_prop = (
q(C, "calendar-data") if provider.kind == "calendar" else q(CR, "address-data")
)
props[data_prop] = res.data
return props
async def collection_req(
self, method: str, segment: str, provider: DavProvider, coll_id: str
) -> Response:
if method == "MKCALENDAR" or method == "MKCOL":
return await self.make_collection(segment, provider, coll_id)
coll = await self.find(provider, coll_id)
if method == "PROPFIND":
items = [(self.coll_href(segment, coll.id), self.collection_props(provider, coll))]
if self.depth():
for res in await provider.resources(self.user.id, coll.id):
items.append(
(
self.res_href(segment, coll.id, res.name),
self.resource_props(provider, res, False),
)
)
return await self.respond_props(items, await self.body())
if method == "REPORT":
return await self.report(segment, provider, coll)
if method == "PROPPATCH":
return await self.proppatch(segment, provider, coll)
if method == "DELETE":
if coll.read_only:
raise DavError(403)
await provider.delete_collection(self.user.id, coll.id)
return Response(status_code=204)
if method in ("GET", "HEAD"):
return Response(status_code=200)
return Response(status_code=405)
async def make_collection(self, segment: str, provider: DavProvider, coll_id: str) -> Response:
if any(c.id == coll_id for c in await provider.collections(self.user.id)):
raise DavError(405) # exists already
root = await self.body()
props = mkcol_props(root)
rtype = props.get(q(D, "resourcetype"))
if self.request.method.upper() == "MKCOL" and (
rtype is None
or rtype.find(q(CR, "addressbook")) is None
or provider.kind != "addressbook"
):
raise DavError(403)
if self.request.method.upper() == "MKCALENDAR" and provider.kind != "calendar":
raise DavError(403)
name_el = props.get(q(D, "displayname"))
color_el = props.get(q(A, "calendar-color"))
await provider.create_collection(
self.user.id,
coll_id,
(name_el.text or "").strip() if name_el is not None else "",
(color_el.text or "").strip() if color_el is not None else None,
)
return Response(status_code=201)
async def proppatch(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
patch = proppatch_request(await self.body())
if coll.read_only:
raise DavError(403)
values: dict[str, Any] = {}
for name, value in patch.set.items():
text = (value.text or "").strip()
if name == q(D, "displayname"):
values["displayname"] = text
elif name == q(A, "calendar-color"):
values["color"] = text
elif name in (q(C, "calendar-description"), q(CR, "addressbook-description")):
values["description"] = text
if values:
await provider.update_collection(self.user.id, coll.id, **values)
# Properties not kept (calendar order, …) are accepted and ignored:
# clients treat a refusal as a failed sync.
ms = Multistatus()
ms.response(self.coll_href(segment, coll.id), dict.fromkeys([*patch.set, *patch.remove]))
return self.multistatus(ms)
async def report(self, segment: str, provider: DavProvider, coll: Collection) -> Response:
root = await self.body()
if root is None:
raise BadRequest("empty report")
rep = report_request(root)
wants_data = rep.props is None or any(
p in (q(C, "calendar-data"), q(CR, "address-data")) for p in rep.props
)
ms = Multistatus()
def add(res: Resource) -> None:
props = self.resource_props(provider, res, wants_data)
if rep.props is None:
ms.response(self.res_href(segment, coll.id, res.name), props)
else:
found = {n: props[n] for n in rep.props if n in props}
missing = [n for n in rep.props if n not in props]
ms.response(self.res_href(segment, coll.id, res.name), found, missing)
if rep.kind in ("calendar-multiget", "addressbook-multiget"):
names = [_last_segment(h) for h in rep.hrefs]
got = {r.name: r for r in await provider.get(self.user.id, coll.id, names)}
for h, name in zip(rep.hrefs, names, strict=True):
if name in got:
add(got[name])
else:
ms.response(h, {}, status=404)
return self.multistatus(ms)
if rep.kind in ("calendar-query", "addressbook-query"):
names_q = (
await provider.query(self.user.id, coll.id, rep.start, rep.end)
if rep.kind == "calendar-query"
else None
)
for res in (
await provider.get(self.user.id, coll.id, names_q)
if wants_data
else (
await provider.resources(self.user.id, coll.id)
if names_q is None
else [
r
for r in await provider.resources(self.user.id, coll.id)
if r.name in set(names_q)
]
)
):
add(res)
return self.multistatus(ms)
# sync-collection (RFC 6578)
since = _token_number(rep.sync_token)
if rep.sync_token and since is None:
raise DavError(403, condition=q(D, "valid-sync-token"))
if since is None:
changed = None
deleted: list[str] = []
token = coll.sync_token
else:
changes = await provider.changes(self.user.id, coll.id, since)
if changes is None:
raise DavError(403, condition=q(D, "valid-sync-token"))
changed, deleted, token = changes.changed, changes.deleted, changes.sync_token
if changed is None:
resources = (
await provider.get(self.user.id, coll.id, None)
if wants_data
else await provider.resources(self.user.id, coll.id)
)
elif changed:
resources = (
await provider.get(self.user.id, coll.id, changed)
if wants_data
else [
r
for r in await provider.resources(self.user.id, coll.id)
if r.name in set(changed)
]
)
else:
resources = []
present = {r.name for r in resources}
for res in resources:
add(res)
for name in deleted:
if name not in present:
ms.response(self.res_href(segment, coll.id, name), {}, status=404)
ms.sync_token(f"{SYNC_PREFIX}{token}")
return self.multistatus(ms)
# -- resources ---------------------------------------------------------------------
async def resource_req(
self, method: str, segment: str, provider: DavProvider, coll_id: str, name: str
) -> Response:
coll = await self.find(provider, coll_id)
if method in ("GET", "HEAD"):
got = await provider.get(self.user.id, coll.id, [name])
if not got:
raise DavError(404)
res = got[0]
content = (res.data or "").encode() if method == "GET" else b""
return Response(
status_code=200,
content=content,
media_type=provider.content_type,
headers={"ETag": f'"{res.etag}"'},
)
if method == "PROPFIND":
got = await provider.get(self.user.id, coll.id, [name])
if not got:
raise DavError(404)
return await self.respond_props(
[
(
self.res_href(segment, coll.id, name),
self.resource_props(provider, got[0], True),
)
],
await self.body(),
)
if method == "PUT":
if coll.read_only:
raise DavError(403, condition=q(D, "need-privileges"))
data = (await self.request.body()).decode("utf-8", "replace")
etag, created = await provider.put(
self.user.id,
coll.id,
name,
data,
if_match=_etag_header(self.request.headers.get("if-match")),
if_none_match=(self.request.headers.get("if-none-match") or "").strip() == "*",
)
return Response(status_code=201 if created else 204, headers={"ETag": f'"{etag}"'})
if method == "DELETE":
if coll.read_only:
raise DavError(403)
await provider.delete(
self.user.id,
coll.id,
name,
if_match=_etag_header(self.request.headers.get("if-match")),
)
return Response(status_code=204)
return Response(status_code=405)
def _last_segment(h: str) -> str:
path = urlsplit(h).path
return unquote(path.rstrip("/").rsplit("/", 1)[-1])
def _token_number(token: str) -> int | None:
if not token:
return None
tail = token.rsplit("/", 1)[-1]
return int(tail) if tail.isdigit() else None
def _etag_header(value: str | None) -> str | None:
if not value:
return None
value = value.strip()
if value == "*":
return "*"
if value.startswith("W/"):
value = value[2:]
return value.strip('"')
__all__ = ["router", "uuid"]
+236
View File
@@ -0,0 +1,236 @@
"""WebDAV XML: reading request bodies, writing multistatus answers (RFC 4918,
4791, 6352, 6578). Properties are ``{namespace}name`` strings, as ElementTree
names them."""
from __future__ import annotations
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import UTC, datetime
from defusedxml import ElementTree as SafeET
D = "DAV:"
C = "urn:ietf:params:xml:ns:caldav"
CR = "urn:ietf:params:xml:ns:carddav"
CS = "http://calendarserver.org/ns/"
A = "http://apple.com/ns/ical/"
for _prefix, _ns in (("d", D), ("cal", C), ("card", CR), ("cs", CS), ("ical", A)):
ET.register_namespace(_prefix, _ns)
def q(ns: str, name: str) -> str:
return f"{{{ns}}}{name}"
class BadRequest(Exception):
pass
def parse(body: bytes) -> ET.Element | None:
if not body.strip():
return None
try:
root = SafeET.fromstring(body)
except Exception as err: # noqa: BLE001 - any malformed XML is the client's error
raise BadRequest(str(err)) from err
return root # type: ignore[no-any-return]
@dataclass
class PropRequest:
"""PROPFIND: which properties (None = all)."""
names: list[str] | None = None
names_only: bool = False
def propfind_request(root: ET.Element | None) -> PropRequest:
if root is None or root.find(q(D, "allprop")) is not None:
return PropRequest()
if root.find(q(D, "propname")) is not None:
return PropRequest(names_only=True)
prop = root.find(q(D, "prop"))
if prop is None:
return PropRequest()
return PropRequest(names=[child.tag for child in prop])
def requested_props(root: ET.Element) -> list[str] | None:
prop = root.find(q(D, "prop"))
if prop is None:
return None
return [child.tag for child in prop]
@dataclass
class Report:
# calendar-query | calendar-multiget | addressbook-query | addressbook-multiget
# | sync-collection
kind: str
props: list[str] | None
hrefs: list[str] = field(default_factory=list)
start: datetime | None = None
end: datetime | None = None
sync_token: str = ""
limit: int | None = None
def _ical_time(value: str | None) -> datetime | None:
if not value:
return None
try:
return datetime.strptime(value.strip(), "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
def report_request(root: ET.Element) -> Report:
tag = root.tag
names = {
q(C, "calendar-query"): "calendar-query",
q(C, "calendar-multiget"): "calendar-multiget",
q(CR, "addressbook-query"): "addressbook-query",
q(CR, "addressbook-multiget"): "addressbook-multiget",
q(D, "sync-collection"): "sync-collection",
}
kind = names.get(tag)
if kind is None:
raise BadRequest(f"unsupported report {tag}")
rep = Report(kind=kind, props=requested_props(root))
rep.hrefs = [(h.text or "").strip() for h in root.findall(q(D, "href"))]
if kind == "calendar-query":
tr = root.find(f".//{q(C, 'time-range')}")
if tr is not None:
rep.start = _ical_time(tr.get("start"))
rep.end = _ical_time(tr.get("end"))
if kind == "sync-collection":
rep.sync_token = (root.findtext(q(D, "sync-token")) or "").strip()
nresults = root.find(f"{q(D, 'limit')}/{q(D, 'nresults')}")
if nresults is not None and (nresults.text or "").strip().isdigit():
rep.limit = int((nresults.text or "").strip())
return rep
@dataclass
class PropPatch:
set: dict[str, ET.Element] = field(default_factory=dict)
remove: list[str] = field(default_factory=list)
def proppatch_request(root: ET.Element | None) -> PropPatch:
out = PropPatch()
if root is None:
return out
for action in root:
prop = action.find(q(D, "prop"))
if prop is None:
continue
for child in prop:
if action.tag == q(D, "set"):
out.set[child.tag] = child
elif action.tag == q(D, "remove"):
out.remove.append(child.tag)
return out
def mkcol_props(root: ET.Element | None) -> dict[str, ET.Element]:
"""MKCALENDAR / extended MKCOL: the properties to set on the new collection."""
if root is None:
return {}
out: dict[str, ET.Element] = {}
for sett in root.findall(q(D, "set")):
prop = sett.find(q(D, "prop"))
if prop is not None:
for child in prop:
out[child.tag] = child
return out
# ---- answers --------------------------------------------------------------------------
# A property's value: text, a list of child elements, or an element builder.
PropValue = str | list[ET.Element] | None
class Multistatus:
def __init__(self) -> None:
self.root = ET.Element(q(D, "multistatus"))
def response(
self,
href: str,
found: dict[str, PropValue],
missing: list[str] | None = None,
status: int | None = None,
) -> None:
resp = ET.SubElement(self.root, q(D, "response"))
ET.SubElement(resp, q(D, "href")).text = href
if status is not None:
ET.SubElement(resp, q(D, "status")).text = _status_line(status)
return
if found:
ps = ET.SubElement(resp, q(D, "propstat"))
prop = ET.SubElement(ps, q(D, "prop"))
for name, value in found.items():
el = ET.SubElement(prop, name)
if isinstance(value, str):
el.text = value
elif isinstance(value, list):
el.extend(value)
ET.SubElement(ps, q(D, "status")).text = _status_line(200)
if missing:
ps = ET.SubElement(resp, q(D, "propstat"))
prop = ET.SubElement(ps, q(D, "prop"))
for name in missing:
ET.SubElement(prop, name)
ET.SubElement(ps, q(D, "status")).text = _status_line(404)
def sync_token(self, token: str) -> None:
ET.SubElement(self.root, q(D, "sync-token")).text = token
def to_bytes(self) -> bytes:
body: bytes = ET.tostring(self.root, encoding="utf-8")
return b'<?xml version="1.0" encoding="utf-8"?>\n' + body
def el(
tag: str,
text: str | None = None,
children: list[ET.Element] | None = None,
attrs: dict[str, str] | None = None,
) -> ET.Element:
e = ET.Element(tag, attrs or {})
if text is not None:
e.text = text
if children:
e.extend(children)
return e
def href(path: str) -> ET.Element:
return el(q(D, "href"), path)
def error_body(condition: str) -> bytes:
root = ET.Element(q(D, "error"))
ET.SubElement(root, condition)
return b'<?xml version="1.0" encoding="utf-8"?>\n' + bytes(ET.tostring(root, encoding="utf-8"))
_REASONS = {
200: "OK",
201: "Created",
204: "No Content",
207: "Multi-Status",
403: "Forbidden",
404: "Not Found",
409: "Conflict",
412: "Precondition Failed",
507: "Insufficient Storage",
}
def _status_line(code: int) -> str:
return f"HTTP/1.1 {code} {_REASONS.get(code, '')}".rstrip()
+104
View File
@@ -0,0 +1,104 @@
"""App passwords: one generated password per mail app (SPEC App. D §D.5).
A mail app (Thunderbird, a phone's Mail app) signs in to the hosted mailbox
over IMAP/SMTP, where no 2FA code can be asked for. It gets a password of its
own — 16 random letters, shown once — that can be revoked without touching the
main password or the other apps. The webmail itself never accepts one.
Stored as argon2id hashes (identity/passwords.py), the form the mail server's
SQL directory reads (§D.3). The hash is of the bare 16 letters: the mail
server compares exactly what an app sends, so the password is copied and
entered without the spaces it is shown with.
"""
from __future__ import annotations
import secrets
import string
import uuid
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.identity.passwords import hash_password, verify_password
from app.models import UserAppPassword
LENGTH = 16 # 26^16 ≈ 2^75
MAX_PER_USER = 25
NAME_MAX = 60
_ALPHABET = string.ascii_lowercase
def generation_key(user_id: uuid.UUID) -> str:
"""Redis counter bumped whenever the user's app passwords change: DAV
sign-ins cached under the old value stop counting (app/dav/auth.py)."""
return f"apppw:gen:{user_id}"
async def _changed(user_id: uuid.UUID) -> None:
from app.redis_client import get_redis
await get_redis().incr(generation_key(user_id))
class TooMany(Exception):
"""The user already has MAX_PER_USER app passwords."""
def generate() -> str:
return "".join(secrets.choice(_ALPHABET) for _ in range(LENGTH))
async def list_for(db: AsyncSession, user_id: uuid.UUID) -> list[UserAppPassword]:
rows = await db.scalars(
select(UserAppPassword)
.where(UserAppPassword.user_id == user_id)
.order_by(UserAppPassword.created_at.desc())
)
return list(rows)
async def create(db: AsyncSession, user_id: uuid.UUID, name: str) -> tuple[UserAppPassword, str]:
"""A new app password named ``name``; returns the row and the password
itself, which is never available again."""
count = await db.scalar(
select(func.count()).select_from(UserAppPassword).where(UserAppPassword.user_id == user_id)
)
if (count or 0) >= MAX_PER_USER:
raise TooMany
password = generate()
row = UserAppPassword(
user_id=user_id, name=name.strip()[:NAME_MAX], password_hash=hash_password(password)
)
db.add(row)
await db.commit()
return row, password
async def revoke(db: AsyncSession, user_id: uuid.UUID, app_password_id: uuid.UUID) -> bool:
result = await db.execute(
delete(UserAppPassword).where(
UserAppPassword.id == app_password_id, UserAppPassword.user_id == user_id
)
)
await db.commit()
await _changed(user_id)
return bool(result.rowcount) # type: ignore[attr-defined]
async def revoke_all(db: AsyncSession, user_id: uuid.UUID) -> None:
"""Every app signs in again (the main password was reset)."""
await db.execute(delete(UserAppPassword).where(UserAppPassword.user_id == user_id))
await db.commit()
await _changed(user_id)
async def verify(db: AsyncSession, user_id: uuid.UUID, password: str) -> UserAppPassword | None:
"""The app password ``password`` matches, if any — for the mail server's
sign-in and for tests; never for the webmail's own sign-in."""
if len(password) != LENGTH:
return None
for row in await list_for(db, user_id):
if verify_password(row.password_hash, password):
return row
return None
+60 -1
View File
@@ -16,8 +16,8 @@ from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.events import get_event_bus
from app.identity import app_passwords, trusted_devices
from app.identity import totp as totp_mod
from app.identity import trusted_devices
from app.identity.cookies import (
clear_auth_cookies,
new_csrf_token,
@@ -34,6 +34,9 @@ from app.identity.reset import (
request_allowed,
)
from app.identity.schemas import (
AppPasswordCreatedOut,
AppPasswordIn,
AppPasswordOut,
LoginRequest,
MessageOut,
PasswordResetCheckIn,
@@ -312,12 +315,68 @@ async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user.password_hash = hash_password(body.password)
await db.commit()
await destroy_all_sessions(user.id) # force re-login everywhere after reset
# Mail apps sign in again too: the reset may be the owner taking the account back.
await app_passwords.revoke_all(db, user.id)
# Whoever reset the password may be the owner taking the account back: no
# device keeps skipping the code on the old trust.
await trusted_devices.revoke_all(user.id)
return MessageOut()
# --- App passwords (SPEC App. D §D.5) -----------------------------------------
def _app_password_out(row: object) -> AppPasswordOut:
from app.models import UserAppPassword
assert isinstance(row, UserAppPassword)
return AppPasswordOut(
id=str(row.id),
name=row.name,
created_at=row.created_at.isoformat(),
last_used_at=row.last_used_at.isoformat() if row.last_used_at else None,
)
@router.get("/app-passwords", response_model=list[AppPasswordOut])
async def list_app_passwords(user: CurrentUser, db: DbSession) -> list[AppPasswordOut]:
return [_app_password_out(r) for r in await app_passwords.list_for(db, user.id)]
@router.post(
"/app-passwords", response_model=AppPasswordCreatedOut, dependencies=[Depends(require_csrf)]
)
async def create_app_password(
body: AppPasswordIn, user: CurrentUser, db: DbSession
) -> AppPasswordCreatedOut:
"""A new password for one mail app. Takes the account password: an app
password opens the mailbox without a 2FA code."""
if not verify_password(user.password_hash, body.password):
raise_api_error(403, "invalid_password", "incorrect password")
if not body.name.strip():
raise_api_error(400, "invalid_name", "give the app password a name")
try:
row, password = await app_passwords.create(db, user.id, body.name)
except app_passwords.TooMany:
raise_api_error(
409, "too_many", f"you can have up to {app_passwords.MAX_PER_USER} app passwords"
)
return AppPasswordCreatedOut(**_app_password_out(row).model_dump(), app_password=password)
@router.delete(
"/app-passwords/{app_password_id}",
response_model=MessageOut,
dependencies=[Depends(require_csrf)],
)
async def revoke_app_password(
app_password_id: uuid.UUID, user: CurrentUser, db: DbSession
) -> MessageOut:
if not await app_passwords.revoke(db, user.id, app_password_id):
raise_api_error(404, "not_found", "app password not found")
return MessageOut()
@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)])
async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut:
"""Add, change or remove the recovery address. Takes the password: whoever
+17
View File
@@ -63,6 +63,23 @@ class PasswordResetCheckOut(BaseModel):
totp_required: bool = False
class AppPasswordIn(BaseModel):
name: str = Field(min_length=1, max_length=60) # "Thunderbird on laptop"
password: str = Field(min_length=1, max_length=1024) # the account password, to confirm
class AppPasswordOut(BaseModel):
id: str
name: str
created_at: str
last_used_at: str | None = None
class AppPasswordCreatedOut(AppPasswordOut):
# The generated password itself: in this answer only, never again.
app_password: str
class RecoveryEmailIn(BaseModel):
password: str = Field(min_length=1, max_length=1024)
# None (or blank) removes the recovery address.
+16
View File
@@ -353,6 +353,22 @@ class SavedSearch(Base):
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
class UserAppPassword(Base):
"""A password for one mail app (IMAP/SMTP), revocable on its own (§D.5)."""
__tablename__ = "user_app_passwords"
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(Text, nullable=False) # "Thunderbird on laptop"
password_hash: Mapped[str] = mapped_column(Text, nullable=False) # argon2id
created_at: Mapped[dt.datetime] = _now()
# Set by the mail server's sign-in, once there is one.
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
class PushSubscription(Base):
"""A browser or installed app to tell about new mail (Web Push)."""
+3
View File
@@ -112,6 +112,9 @@ class Plugin:
# Core plugins (calendar, contacts) ship builtin=True: always installed, never
# offered for removal in the UI, and the uninstall path refuses --drop-data.
builtin: bool = False
# Serves its collections to CalDAV / CardDAV clients (app/dav/provider.py
# DavProvider; SPEC Appendix E).
dav: Any = None
def __post_init__(self) -> None:
if not _NAME_RE.fullmatch(self.name):
@@ -0,0 +1,43 @@
"""app passwords: one password per mail app (SPEC App. D §D.5)
Mail apps (Thunderbird, a phone's Mail app) will sign in to the user's hosted
mailbox over IMAP/SMTP, where no 2FA code can be asked for. Each app gets its
own generated password instead, which can be revoked on its own; the main
password with 2FA stays for the webmail. Stored as argon2id hashes, the form
the mail server's SQL directory reads (§D.3).
Revision ID: 0024
Revises: 0023
Create Date: 2026-10-09
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
revision: str = "0024"
down_revision: str | None = "0023"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"""
CREATE TABLE user_app_passwords (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL,
password_hash text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
last_used_at timestamptz
)
"""
)
op.execute("CREATE INDEX ix_user_app_passwords_user ON user_app_passwords (user_id)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS user_app_passwords")
@@ -0,0 +1,138 @@
"""App passwords (SPEC App. D §D.5): one generated password per mail app,
shown once, revocable on its own, never accepted by the webmail's sign-in."""
from __future__ import annotations
import re
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.identity import app_passwords
from app.identity.passwords import hash_password
from app.identity.reset import issue_token
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User, UserAppPassword
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@dataclass
class Ctx:
db: AsyncSession
anon: httpx.AsyncClient
created: list[uuid.UUID] = field(default_factory=list)
async def user(self) -> User:
user = User(
handle=f"ap{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password(PASSWORD)
)
self.db.add(user)
await self.db.commit()
self.created.append(user.id)
return user
async def signed_in(self, user: User) -> httpx.AsyncClient:
token = await create_session(user.id, "pytest")
return httpx.AsyncClient(
transport=ASGITransport(app=create_app()),
base_url="http://testserver",
cookies={"session": token, "csrf": CSRF},
headers={"X-CSRF": CSRF},
)
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
db = async_sessionmaker(engine, expire_on_commit=False)()
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
c = Ctx(db=db, anon=anon)
try:
yield c
finally:
await anon.aclose()
await db.execute(delete(User).where(User.id.in_(c.created)))
await db.commit()
await db.close()
await engine.dispose()
async def _create(client: httpx.AsyncClient, name: str, password: str = PASSWORD) -> httpx.Response:
return await client.post("/api/auth/app-passwords", json={"name": name, "password": password})
async def test_created_once_listed_without_secret_and_revoked(ctx: Ctx) -> None:
user = await ctx.user()
client = await ctx.signed_in(user)
assert (await _create(client, "Thunderbird", "wrong")).status_code == 403
made = await _create(client, " Thunderbird on laptop ")
assert made.status_code == 200
body = made.json()
assert body["name"] == "Thunderbird on laptop"
assert re.fullmatch(r"[a-z]{16}", body["app_password"])
listed = (await client.get("/api/auth/app-passwords")).json()
assert [a["name"] for a in listed] == ["Thunderbird on laptop"]
assert "app_password" not in listed[0] and "password_hash" not in listed[0]
assert listed[0]["last_used_at"] is None
# What the mail server will check: this one matches, nothing else does.
row = await app_passwords.verify(ctx.db, user.id, body["app_password"])
assert row is not None and str(row.id) == body["id"]
assert await app_passwords.verify(ctx.db, user.id, PASSWORD) is None
assert await app_passwords.verify(ctx.db, user.id, "a" * 16) is None
stored = await ctx.db.scalar(
select(UserAppPassword.password_hash).where(UserAppPassword.id == row.id)
)
assert stored and stored.startswith("$argon2id$") and body["app_password"] not in stored
# The webmail's own sign-in never takes an app password.
login = await ctx.anon.post(
"/api/auth/login", json={"handle": user.handle, "password": body["app_password"]}
)
assert login.status_code == 401
# Someone else cannot revoke it; its owner can, once.
other = await ctx.signed_in(await ctx.user())
assert (await other.delete(f"/api/auth/app-passwords/{body['id']}")).status_code == 404
assert (await client.delete(f"/api/auth/app-passwords/{body['id']}")).status_code == 200
assert (await client.delete(f"/api/auth/app-passwords/{body['id']}")).status_code == 404
assert await app_passwords.verify(ctx.db, user.id, body["app_password"]) is None
await client.aclose()
await other.aclose()
async def test_at_most_a_few_per_user(ctx: Ctx, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(app_passwords, "MAX_PER_USER", 2)
client = await ctx.signed_in(await ctx.user())
assert (await _create(client, "one")).status_code == 200
assert (await _create(client, "two")).status_code == 200
third = await _create(client, "three")
assert third.status_code == 409 and third.json()["error"]["code"] == "too_many"
await client.aclose()
async def test_password_reset_revokes_them_all(ctx: Ctx) -> None:
user = await ctx.user()
client = await ctx.signed_in(user)
secret = (await _create(client, "Phone")).json()["app_password"]
token = await issue_token(user.id)
reset = await ctx.anon.post(
"/api/auth/password/reset", json={"token": token, "password": "brandNewPass9"}
)
assert reset.status_code == 200
assert await app_passwords.verify(ctx.db, user.id, secret) is None
await client.aclose()
+231
View File
@@ -0,0 +1,231 @@
"""CalDAV server (SPEC Appendix E): discovery, sign-in with app passwords, and
two-way changes between DAV clients and the web app on the same rows."""
from __future__ import annotations
import base64
import re
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.identity import app_passwords
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
CAL = "/api/plugins/calendar"
PROPFIND_ALL = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
EVENT = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//test//EN
BEGIN:VEVENT
UID:{uid}
DTSTAMP:20261009T120000Z
DTSTART;TZID=Europe/Warsaw:20311013T090000
DTEND;TZID=Europe/Warsaw:20311013T100000
SUMMARY:{summary}
X-CLIENT-ONLY:keep me
BEGIN:VALARM
ACTION:DISPLAY
DESCRIPTION:x
TRIGGER:-PT30M
END:VALARM
END:VEVENT
END:VCALENDAR
"""
@dataclass
class Ctx:
dav: httpx.AsyncClient # a CalDAV client (Basic, app password)
web: httpx.AsyncClient # the web app (session)
anon: httpx.AsyncClient
user: User
app_password: str
app_password_id: uuid.UUID
home: str
calendar_id: str
def _basic(login: str, password: str) -> dict[str, str]:
return {"Authorization": "Basic " + base64.b64encode(f"{login}:{password}".encode()).decode()}
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as s:
user = User(
handle=f"dav{uuid.uuid4().hex[:8]}@test.local",
password_hash=hash_password("account-pw"),
)
s.add(user)
await s.commit()
async with session_scope() as db:
row, password = await app_passwords.create(db, user.id, "test client")
transport = ASGITransport(app=create_app())
token = await create_session(user.id, "pytest")
web = httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
cookies={"session": token, "csrf": "c"},
headers={"X-CSRF": "c"},
)
calendar_id = (await web.get(f"{CAL}/calendars")).json()[0]["id"] # bootstraps "Personal"
dav = httpx.AsyncClient(
transport=transport, base_url="http://testserver", headers=_basic(user.handle, password)
)
anon = httpx.AsyncClient(transport=transport, base_url="http://testserver")
try:
yield Ctx(dav, web, anon, user, password, row.id, f"/dav/calendars/{user.id}/", calendar_id)
finally:
for c in (dav, web, anon):
await c.aclose()
async with sm() as s:
await s.execute(
text("DELETE FROM plg_calendar_calendars WHERE user_id = :u"), {"u": str(user.id)}
)
await s.execute(
text("DELETE FROM plg_calendar_dav_changes WHERE user_id = :u"), {"u": str(user.id)}
)
await s.execute(delete(User).where(User.id == user.id))
await s.commit()
await engine.dispose()
def _sync_report(token: str = "") -> bytes:
return (
'<?xml version="1.0"?><d:sync-collection xmlns:d="DAV:">'
f"<d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level>"
"<d:prop><d:getetag/></d:prop></d:sync-collection>"
).encode()
async def _sync(c: Ctx, token: str = "") -> tuple[str, str]:
r = await c.dav.request("REPORT", f"{c.home}{c.calendar_id}/", content=_sync_report(token))
assert r.status_code == 207, r.text
new = re.search(r"<d:sync-token>([^<]+)</d:sync-token>", r.text)
assert new
return new.group(1), r.text
async def test_discovery_and_sign_in(ctx: Ctx) -> None:
assert (await ctx.anon.get("/.well-known/caldav")).headers["location"] == "/dav/"
assert (await ctx.anon.request("PROPFIND", "/dav/")).status_code == 401
# The account password never works here — only app passwords.
wrong = await ctx.anon.request(
"PROPFIND", "/dav/", headers=_basic(ctx.user.handle, "account-pw")
)
assert wrong.status_code == 401
root = await ctx.dav.request("PROPFIND", "/dav/", headers={"Depth": "0"}, content=PROPFIND_ALL)
assert root.status_code == 207 and f"/dav/principals/{ctx.user.id}/" in root.text
principal = await ctx.dav.request(
"PROPFIND", f"/dav/principals/{ctx.user.id}/", headers={"Depth": "0"}
)
assert ctx.home in principal.text and "calendar-home-set" in principal.text
home = await ctx.dav.request("PROPFIND", ctx.home, headers={"Depth": "1"})
assert (
home.status_code == 207 and "Personal" in home.text and f"{ctx.calendar_id}/" in home.text
)
# Someone else's home: refused.
other = await ctx.dav.request(
"PROPFIND", f"/dav/calendars/{uuid.uuid4()}/", headers={"Depth": "1"}
)
assert other.status_code == 403
# Revoking the app password locks the client out at once (no stale cache).
async with session_scope() as db:
await app_passwords.revoke(db, ctx.user.id, ctx.app_password_id)
assert (await ctx.dav.request("PROPFIND", "/dav/")).status_code == 401
async def test_client_and_web_app_see_each_others_changes(ctx: Ctx) -> None:
url = f"{ctx.home}{ctx.calendar_id}/review.ics"
token0, _ = await _sync(ctx)
# A client adds an event: the web app shows it, alarm included.
put = await ctx.dav.put(
url,
content=EVENT.format(uid="dav-1@test", summary="Review"),
headers={"If-None-Match": "*"},
)
assert put.status_code == 201
etag = put.headers["etag"]
events = (
await ctx.web.get(
f"{CAL}/events", params={"start": "2031-10-13T00:00:00Z", "end": "2031-10-14T00:00:00Z"}
)
).json()["events"]
assert [(e["summary"], e["reminders"], e["tz"]) for e in events] == [
("Review", [30], "Europe/Warsaw")
]
event_id = events[0]["id"]
got = await ctx.dav.get(url)
assert got.headers["etag"] == etag and "X-CLIENT-ONLY:keep me" in got.text # verbatim
token1, body = await _sync(ctx, token0)
assert "review.ics" in body
# Creating it again, or editing an old version, is refused.
again = await ctx.dav.put(
url, content=EVENT.format(uid="dav-1@test", summary="x"), headers={"If-None-Match": "*"}
)
assert again.status_code == 412
stale = await ctx.dav.put(
url, content=EVENT.format(uid="dav-1@test", summary="x"), headers={"If-Match": '"nope"'}
)
assert stale.status_code == 412
# The web app renames it: the client gets the new version (regenerated).
assert (
await ctx.web.patch(f"{CAL}/events/{event_id}", json={"summary": "Review (web)"})
).status_code == 200
token2, body = await _sync(ctx, token1)
assert "review.ics" in body and token2 != token1
got = await ctx.dav.get(url)
assert got.headers["etag"] != etag and "SUMMARY:Review (web)" in got.text
assert "TRIGGER:-PT30M" in got.text and "TZID=Europe/Warsaw" in got.text
# The web app deletes it: the client hears it is gone.
assert (await ctx.web.delete(f"{CAL}/events/{event_id}")).status_code == 200
_, body = await _sync(ctx, token2)
assert "review.ics" in body and "404" in body
assert (await ctx.dav.get(url)).status_code == 404
async def test_client_delete_and_read_only_calendars(ctx: Ctx) -> None:
url = f"{ctx.home}{ctx.calendar_id}/gone.ics"
put = await ctx.dav.put(url, content=EVENT.format(uid="dav-2@test", summary="Temp"))
assert put.status_code == 201
assert (await ctx.dav.delete(url, headers={"If-Match": put.headers["etag"]})).status_code == 204
events = (
await ctx.web.get(
f"{CAL}/events", params={"start": "2031-10-13T00:00:00Z", "end": "2031-10-14T00:00:00Z"}
)
).json()["events"]
assert events == []
# A subscribed (read-only) calendar refuses writes.
async with session_scope() as db:
sub = await db.scalar(
text(
"INSERT INTO plg_calendar_calendars (user_id, name, kind, read_only) "
"VALUES (:u, 'Holidays', 'ics_subscription', true) RETURNING id"
),
{"u": str(ctx.user.id)},
)
ro = await ctx.dav.put(
f"{ctx.home}{sub}/x.ics", content=EVENT.format(uid="dav-3@test", summary="x")
)
assert ro.status_code == 403
+160
View File
@@ -0,0 +1,160 @@
"""CardDAV server (SPEC Appendix E.4): address books, vCards in and out, groups
as CATEGORIES, and changes flowing both ways with the web app."""
from __future__ import annotations
import base64
import re
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
import httpx
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, text
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.config import get_settings
from app.db import session_scope
from app.identity import app_passwords
from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
CON = "/api/plugins/contacts"
IPHONE_CARD = (
"BEGIN:VCARD\r\nVERSION:3.0\r\nPRODID:-//Apple Inc.//iPhone OS 18.0//EN\r\n"
"N:Kowalska;Anna;;;\r\nFN:Anna Kowalska\r\nORG:Imaginary Pixels;Compositing\r\n"
"item1.EMAIL;type=INTERNET;type=WORK;type=pref:anna@ip.example\r\n"
"TEL;type=CELL;type=VOICE:+48 600 100 200\r\nCATEGORIES:Clients\r\n"
"X-ABUID:ABC123\r\nUID:card-1\r\nEND:VCARD\r\n"
)
@dataclass
class Ctx:
dav: httpx.AsyncClient
web: httpx.AsyncClient
user: User
home: str
book_id: str
@pytest_asyncio.fixture
async def ctx() -> AsyncIterator[Ctx]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
sm = async_sessionmaker(engine, expire_on_commit=False)
async with sm() as s:
user = User(
handle=f"card{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw")
)
s.add(user)
await s.commit()
async with session_scope() as db:
_, password = await app_passwords.create(db, user.id, "test client")
transport = ASGITransport(app=create_app())
token = await create_session(user.id, "pytest")
web = httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
cookies={"session": token, "csrf": "c"},
headers={"X-CSRF": "c"},
)
auth = "Basic " + base64.b64encode(f"{user.handle}:{password}".encode()).decode()
dav = httpx.AsyncClient(
transport=transport, base_url="http://testserver", headers={"Authorization": auth}
)
home = f"/dav/addressbooks/{user.id}/"
listing = await dav.request("PROPFIND", home, headers={"Depth": "1"})
book_id = re.findall(rf"{home}([0-9a-f-]{{36}})/", listing.text)[0]
try:
yield Ctx(dav, web, user, home, book_id)
finally:
await dav.aclose()
await web.aclose()
async with sm() as s:
for table in ("plg_contacts_books", "plg_contacts_groups", "plg_contacts_dav_changes"):
await s.execute(
text(f"DELETE FROM {table} WHERE user_id = :u"), {"u": str(user.id)}
)
await s.execute(delete(User).where(User.id == user.id))
await s.commit()
await engine.dispose()
async def _sync(c: Ctx, token: str = "") -> tuple[str, str]:
body = (
'<?xml version="1.0"?><d:sync-collection xmlns:d="DAV:">'
f"<d:sync-token>{token}</d:sync-token><d:sync-level>1</d:sync-level>"
"<d:prop><d:getetag/></d:prop></d:sync-collection>"
)
r = await c.dav.request("REPORT", f"{c.home}{c.book_id}/", content=body)
assert r.status_code == 207, r.text
m = re.search(r"<d:sync-token>([^<]+)</d:sync-token>", r.text)
assert m
return m.group(1), r.text
async def test_discovery_finds_the_address_book(ctx: Ctx) -> None:
principal = await ctx.dav.request(
"PROPFIND", f"/dav/principals/{ctx.user.id}/", headers={"Depth": "0"}
)
assert "addressbook-home-set" in principal.text and ctx.home in principal.text
book = await ctx.dav.request("PROPFIND", f"{ctx.home}{ctx.book_id}/", headers={"Depth": "0"})
assert "Contacts" in book.text and "addressbook" in book.text
async def test_a_phone_contact_and_the_web_app(ctx: Ctx) -> None:
url = f"{ctx.home}{ctx.book_id}/ABC123.vcf"
token0, _ = await _sync(ctx)
put = await ctx.dav.put(url, content=IPHONE_CARD, headers={"If-None-Match": "*"})
assert put.status_code == 201
# The web app shows it, in the group its category named.
listed = (await ctx.web.get(f"{CON}/contacts", params={"filter": "all", "q": ""})).json()[
"contacts"
]
assert [c["display_name"] for c in listed] == ["Anna Kowalska"]
contact = (await ctx.web.get(f"{CON}/contacts/{listed[0]['id']}")).json()
assert contact["org"] == "Imaginary Pixels" and contact["department"] == "Compositing"
assert (
contact["emails"][0]["value"] == "anna@ip.example"
and contact["phones"][0]["type"] == "mobile"
)
assert [g["name"] for g in contact["groups"]] == ["Clients"]
got = await ctx.dav.get(url)
assert got.headers["etag"] == put.headers["etag"] and "X-ABUID:ABC123" in got.text # verbatim
token1, body = await _sync(ctx, token0)
assert "ABC123.vcf" in body
# The web app edits it: the phone gets a regenerated card.
edit = dict(contact, display_name="Anna K.", group_ids=[g["id"] for g in contact["groups"]])
assert (await ctx.web.put(f"{CON}/contacts/{contact['id']}", json=edit)).status_code == 200
token2, body = await _sync(ctx, token1)
assert "ABC123.vcf" in body
got = await ctx.dav.get(url)
assert "FN:Anna K." in got.text and "CATEGORIES:Clients" in got.text
# Deleting the group changes the contact's CATEGORIES.
gid = contact["groups"][0]["id"]
assert (await ctx.web.delete(f"{CON}/groups/{gid}")).status_code == 200
token3, body = await _sync(ctx, token2)
assert "ABC123.vcf" in body and "CATEGORIES" not in (await ctx.dav.get(url)).text
# Multiget, as phones fetch changed cards.
multiget = (
'<?xml version="1.0"?><card:addressbook-multiget xmlns:d="DAV:" '
'xmlns:card="urn:ietf:params:xml:ns:carddav"><d:prop><d:getetag/><card:address-data/>'
f"</d:prop><d:href>{url}</d:href><d:href>{ctx.home}{ctx.book_id}/missing.vcf</d:href>"
"</card:addressbook-multiget>"
)
r = await ctx.dav.request("REPORT", f"{ctx.home}{ctx.book_id}/", content=multiget)
assert r.status_code == 207 and "BEGIN:VCARD" in r.text and "404" in r.text
# Deleted in the web app: gone for the phone.
assert (await ctx.web.delete(f"{CON}/contacts/{contact['id']}")).status_code == 200
_, body = await _sync(ctx, token3)
assert "ABC123.vcf" in body and "404" in body