Files
webmail-client/backend/app/identity/app_passwords.py
T
lukasandClaude Opus 5.5 0e3793a4d6 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>
2026-10-09 21:49:04 +02:00

105 lines
3.5 KiB
Python

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