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:
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""CalDAV / CardDAV server (SPEC Appendix E)."""
|
||||
@@ -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
|
||||
@@ -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)."""
|
||||
...
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user