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