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>
139 lines
5.3 KiB
Python
139 lines
5.3 KiB
Python
"""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()
|