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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user