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