Admin user management, password reset by email, and the Ultimo Email name

Branding: the page titles and app name are now "Ultimo Email".

Admin panel (now two columns with a divider):
- Users: search by login or by a mail account's address or name (results
  only, nothing listed until something is typed). A user opens a window to
  change their quota, block/unblock them, or delete them; block and delete ask
  for confirmation, and an admin cannot do either to their own account.
- Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear
  message and revokes every session; a new user.signed_out event sends the
  user's open tabs to the sign-in screen at once. Any "not signed in" answer
  now does the same, not only /auth/me. Deleting removes mail accounts (sync
  stops), plugin rows (cleared inside the user's RLS context, since plugin
  tables have no FK to users), blobs, the search index, sessions and the user.
- Outgoing system mail (migration 0020): SMTP server, port, security, login,
  password (vault-encrypted, never sent back), From address and the public
  address reset links point to, plus a "Send test" that reports the server's
  error.

Password reset:
- "Forgot password?" on the sign-in page asks for the user name and always
  answers "If this account exists you will receive an email to your recovery
  email address"; the mail goes out after the response, so neither the reply
  nor its timing reveals an account, and it is capped at 3 per hour.
- The emailed link (token in the URL fragment, single use, 30 minutes) opens a
  page to set a new password. With 2-step verification on, the code is checked
  before anything changes; five wrong codes burn the link.
- Settings > Profile: the recovery email can be added, changed or removed,
  confirmed with the current password, which must be typed — autofill and
  paste are refused (TypedPasswordInput).

Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the
logo for a BIMI record.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:16:27 +02:00
co-authored by Claude Opus 5.5
parent 8dc60dbf5c
commit 0ddc0cb001
32 changed files with 2267 additions and 87 deletions
+196
View File
@@ -0,0 +1,196 @@
"""Admin user management (SPEC §6.1): search, quota, block, delete.
Runs against the dev database with real sessions, so every user it creates is
removed again on the way out.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
import httpx
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, select, text
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 user_admin
from app.identity.passwords import hash_password
from app.identity.sessions import create_session, resolve_session
from app.main import create_app
from app.models import Account, PluginKv, User
from app.plugins.db import plugin_session
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
def _handle() -> str:
return f"adm{uuid.uuid4().hex[:8]}@test.local"
class World:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.created: list[uuid.UUID] = []
async def user(self, *, admin: bool = False) -> User:
user = User(handle=_handle(), password_hash=hash_password(PASSWORD), is_admin=admin)
self.session.add(user)
await self.session.commit()
self.created.append(user.id)
return user
async def client(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 world() -> AsyncIterator[World]:
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
w = World(session)
try:
yield w
finally:
await session.rollback()
await session.execute(delete(User).where(User.id.in_(w.created)))
await session.commit()
await session.close()
await engine.dispose()
def _account(user_id: uuid.UUID, email: str, name: str = "") -> Account:
return Account(
user_id=user_id,
email=email,
display_name=name,
imap_config={},
smtp_config={},
secret_ct=b"x",
discovery_src="manual",
state="live",
)
async def test_only_an_admin_can_manage_users(world: World) -> None:
plain = await world.user()
async with await world.client(plain) as c:
assert (await c.get("/api/admin/users")).status_code == 403
async def test_search_by_login_and_by_mail_account(world: World) -> None:
admin = await world.user(admin=True)
target = await world.user()
tag = uuid.uuid4().hex[:10]
world.session.add(_account(target.id, f"x{tag}@example.com", f"Zed {tag}"))
await world.session.commit()
async with await world.client(admin) as c:
by_login = (await c.get("/api/admin/users", params={"q": target.handle[:11]})).json()
assert [u["id"] for u in by_login] == [str(target.id)]
by_email = (await c.get("/api/admin/users", params={"q": f"x{tag}@"})).json()
assert [u["id"] for u in by_email] == [str(target.id)]
assert by_email[0]["accounts"] == [f"x{tag}@example.com"]
by_name = (await c.get("/api/admin/users", params={"q": f"zed {tag}"})).json()
assert [u["id"] for u in by_name] == [str(target.id)]
# A typed % is a character, not "match anything".
assert (await c.get("/api/admin/users", params={"q": f"{tag}%"})).json() == []
async def test_change_quota(world: World) -> None:
admin = await world.user(admin=True)
target = await world.user()
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 123456789})
assert resp.status_code == 200
assert resp.json()["quota_bytes"] == 123456789
bad = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 0})
assert bad.status_code == 422
class _Bus:
def __init__(self) -> None:
self.published: list[tuple[str, dict[str, object]]] = []
async def publish(self, event: str, payload: dict[str, object]) -> None:
self.published.append((event, payload))
@pytest.fixture
def bus(monkeypatch: pytest.MonkeyPatch) -> _Bus:
fake = _Bus()
monkeypatch.setattr(user_admin, "get_event_bus", lambda: fake)
return fake
async def test_block_refuses_login_and_ends_sessions_then_unblock(world: World, bus: _Bus) -> None:
admin = await world.user(admin=True)
target = await world.user()
token = await create_session(target.id, "pytest")
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": True})
assert resp.status_code == 200 and resp.json()["blocked_at"]
# The session the user held is gone, and their open tabs are told to leave.
assert await resolve_session(token) is None
assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
async with anon:
# ...and they cannot sign back in, told why only after a correct password.
login = {"handle": target.handle, "password": PASSWORD}
blocked = await anon.post("/api/auth/login", json=login)
assert blocked.status_code == 403
assert blocked.json()["error"]["code"] == "account_blocked"
wrong = await anon.post("/api/auth/login", json={**login, "password": "nope-nope"})
assert wrong.json()["error"]["code"] == "invalid_credentials"
async with await world.client(admin) as c:
resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": False})
assert resp.json()["blocked_at"] is None
assert (await anon.post("/api/auth/login", json=login)).status_code == 200
async def test_admin_cannot_block_or_delete_themselves(world: World) -> None:
admin = await world.user(admin=True)
async with await world.client(admin) as c:
assert (
await c.patch(f"/api/admin/users/{admin.id}", json={"blocked": True})
).status_code == 409
assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409
async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None:
admin = await world.user(admin=True)
target = await world.user()
world.session.add(_account(target.id, f"d{uuid.uuid4().hex[:8]}@example.com"))
world.session.add(PluginKv(plugin="labels", user_id=target.id, key="k", value={"a": 1}))
await world.session.commit()
# A plugin row: no foreign key to users, behind row-level security.
async with plugin_session(target.id) as ps:
await ps.execute(
text("INSERT INTO plg_contacts_books (user_id, name) VALUES (:u, 'Book')"),
{"u": target.id},
)
token = await create_session(target.id, "pytest")
async with await world.client(admin) as c:
assert (await c.delete(f"/api/admin/users/{target.id}")).status_code == 200
s = world.session
assert await s.scalar(select(User).where(User.id == target.id)) is None
assert await s.scalar(select(Account).where(Account.user_id == target.id)) is None
assert await s.scalar(select(PluginKv).where(PluginKv.user_id == target.id)) is None
async with plugin_session(target.id) as ps:
left = await ps.scalar(text("SELECT count(*) FROM plg_contacts_books"))
assert left == 0
assert await resolve_session(token) is None
assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published
+2 -39
View File
@@ -1,4 +1,5 @@
"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1)."""
"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset
flow is in test_password_reset.py."""
from __future__ import annotations
@@ -19,7 +20,6 @@ from app.identity.passwords import hash_password
from app.identity.sessions import create_session
from app.main import create_app
from app.models import User
from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
@@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None:
assert me.status_code == 401
async def test_password_reset_flow(ctx: Ctx) -> None:
# request a reset; token is stored in redis (not returned)
req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle})
assert req.status_code == 200
redis = get_redis()
keys = await redis.keys("pwreset:*")
# Pick the token bound to THIS user (redis DB may hold others from prior runs).
token = None
for key in keys:
if await redis.get(key) == str(ctx.user_id):
token = key.split(":", 1)[1]
break
assert token is not None, "a reset token should have been issued for this user"
new_password = "brandNewPass9"
reset = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert reset.status_code == 200
# token is single-use
again = await ctx.client.post(
"/api/auth/password/reset", json={"token": token, "password": new_password}
)
assert again.status_code == 400
# new password works, old one does not
async with _fresh_client(ctx.app) as anon:
good = await anon.post(
"/api/auth/login", json={"handle": ctx.handle, "password": new_password}
)
assert good.status_code == 200
bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD})
assert bad.status_code == 401
async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None:
# must not leak whether a handle exists
resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"})
@@ -0,0 +1,309 @@
"""Password reset by email, recovery address, and the system mailer (SPEC §6.1).
The SMTP send is replaced with a recorder, so nothing leaves the machine; the
reset link it would have sent is read back from what was recorded.
"""
from __future__ import annotations
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
import httpx
import pyotp
import pytest
import pytest_asyncio
from httpx import ASGITransport
from sqlalchemy import delete, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
from app.api import admin as admin_api
from app.config import get_settings
from app.identity import routes
from app.identity.mailer import MailerConfig
from app.identity.passwords import hash_password
from app.identity.reset import MAX_TOTP_FAILURES
from app.identity.service import app_settings
from app.identity.sessions import create_session
from app.identity.totp import encrypt_totp_secret
from app.main import create_app
from app.models import AppSettings, User
from app.redis_client import get_redis
CSRF = "csrf-token"
PASSWORD = "hunter2pass"
NEW_PASSWORD = "brandNewPass9"
CFG = MailerConfig(
host="smtp.example.com",
port=587,
security="starttls",
username="postmaster@example.com",
password="pw",
sender="postmaster@example.com",
public_url="https://mail.example.com",
)
@dataclass
class Sent:
mails: list[tuple[str, str, str]] = field(default_factory=list) # (to, subject, text)
def token(self) -> str:
text = self.mails[-1][2]
link = next(w for w in text.split() if w.startswith("https://mail.example.com/"))
assert link.startswith("https://mail.example.com/reset-password#token=")
return link.split("#token=", 1)[1]
@dataclass
class Ctx:
session: AsyncSession
anon: httpx.AsyncClient
sent: Sent
created: list[uuid.UUID] = field(default_factory=list)
async def user(
self, *, recovery: str | None = "me@elsewhere.example", admin: bool = False
) -> User:
user = User(
handle=f"pr{uuid.uuid4().hex[:8]}@test.local",
password_hash=hash_password(PASSWORD),
recovery_email=recovery,
is_admin=admin,
)
self.session.add(user)
await self.session.commit()
self.created.append(user.id)
await get_redis().delete(f"pwreset-rate:{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(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]:
sent = Sent()
async def fake_send(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None:
sent.mails.append((to, subject, text))
monkeypatch.setattr(routes, "send_mail", fake_send)
monkeypatch.setattr(admin_api, "send_mail", fake_send)
monkeypatch.setattr(routes, "mailer_config", lambda row: CFG)
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
session = async_sessionmaker(engine, expire_on_commit=False)()
anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
c = Ctx(session=session, anon=anon, sent=sent)
try:
yield c
finally:
await anon.aclose()
await session.execute(delete(User).where(User.id.in_(c.created)))
await session.commit()
await session.close()
await engine.dispose()
async def _request(ctx: Ctx, handle: str) -> httpx.Response:
return await ctx.anon.post("/api/auth/password/reset-request", json={"handle": handle})
async def _login(ctx: Ctx, handle: str, password: str) -> int:
resp = await ctx.anon.post("/api/auth/login", json={"handle": handle, "password": password})
return resp.status_code
# --- requesting a link -----------------------------------------------------------
async def test_request_mails_a_one_time_link_to_the_recovery_address(ctx: Ctx) -> None:
user = await ctx.user()
assert (await _request(ctx, user.handle)).status_code == 200
assert [m[0] for m in ctx.sent.mails] == ["me@elsewhere.example"]
token = ctx.sent.token()
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
assert check.json() == {"valid": True, "totp_required": False}
reset = await ctx.anon.post(
"/api/auth/password/reset", json={"token": token, "password": NEW_PASSWORD}
)
assert reset.status_code == 200
# Used up: a second go fails, and the page is told so up front.
again = await ctx.anon.post(
"/api/auth/password/reset", json={"token": token, "password": "anotherPass12"}
)
assert again.json()["error"]["code"] == "invalid_token"
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
assert check.json()["valid"] is False
assert await _login(ctx, user.handle, NEW_PASSWORD) == 200
assert await _login(ctx, user.handle, PASSWORD) == 401
async def test_the_answer_never_says_whether_the_account_exists(ctx: Ctx) -> None:
no_recovery = await ctx.user(recovery=None)
for handle in ("nobody-here-at-all", no_recovery.handle):
resp = await _request(ctx, handle)
assert resp.status_code == 200 and resp.json() == {"status": "ok"}
assert ctx.sent.mails == []
async def test_no_mail_while_system_mail_is_not_set_up(
ctx: Ctx, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(routes, "mailer_config", lambda row: None)
user = await ctx.user()
assert (await _request(ctx, user.handle)).status_code == 200
assert ctx.sent.mails == []
async def test_requests_are_capped_per_hour(ctx: Ctx) -> None:
user = await ctx.user()
for _ in range(5):
assert (await _request(ctx, user.handle)).status_code == 200
assert len(ctx.sent.mails) == 3
# --- two-step verification ---------------------------------------------------------
async def test_2fa_code_is_required_before_the_password_changes(ctx: Ctx) -> None:
user = await ctx.user()
secret = pyotp.random_base32()
await ctx.session.execute(
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
)
await ctx.session.commit()
await _request(ctx, user.handle)
token = ctx.sent.token()
body = {"token": token, "password": NEW_PASSWORD}
check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token})
assert check.json()["totp_required"] is True
missing = await ctx.anon.post("/api/auth/password/reset", json=body)
assert missing.json()["error"]["code"] == "totp_required"
wrong = await ctx.anon.post("/api/auth/password/reset", json={**body, "totp": "000000"})
assert wrong.json()["error"]["code"] == "totp_invalid"
# Nothing changed yet, and the link still works.
assert await _login(ctx, user.handle, PASSWORD) == 401 # 2FA account: needs a code
good = await ctx.anon.post(
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
)
assert good.status_code == 200
login = await ctx.anon.post(
"/api/auth/login",
json={"handle": user.handle, "password": NEW_PASSWORD, "totp": pyotp.TOTP(secret).now()},
)
assert login.status_code == 200
async def test_too_many_wrong_codes_burn_the_link(ctx: Ctx) -> None:
user = await ctx.user()
secret = pyotp.random_base32()
await ctx.session.execute(
update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret))
)
await ctx.session.commit()
await _request(ctx, user.handle)
token = ctx.sent.token()
body = {"token": token, "password": NEW_PASSWORD, "totp": "000000"}
for _ in range(MAX_TOTP_FAILURES):
await ctx.anon.post("/api/auth/password/reset", json=body)
late = await ctx.anon.post(
"/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()}
)
assert late.json()["error"]["code"] == "invalid_token"
# --- the recovery address ----------------------------------------------------------
async def test_changing_the_recovery_email_takes_the_password(ctx: Ctx) -> None:
user = await ctx.user(recovery=None)
async with await ctx.signed_in(user) as c:
wrong = await c.put(
"/api/auth/recovery-email",
json={"password": "not-it", "recovery_email": "new@example.com"},
)
assert wrong.status_code == 403
bad = await c.put(
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": "nope"}
)
assert bad.json()["error"]["code"] == "invalid_email"
ok = await c.put(
"/api/auth/recovery-email",
json={"password": PASSWORD, "recovery_email": " New@Example.com "},
)
assert ok.json()["recovery_email"] == "new@example.com"
removed = await c.put(
"/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": ""}
)
assert removed.json()["recovery_email"] is None
# --- admin: system mail settings -----------------------------------------------------
@pytest_asyncio.fixture
async def restore_settings(ctx: Ctx) -> AsyncIterator[None]:
row = await app_settings(ctx.session)
saved = {
k: getattr(row, k)
for k in (
"smtp_host",
"smtp_port",
"smtp_security",
"smtp_username",
"smtp_secret_ct",
"smtp_from",
"public_url",
)
}
await ctx.session.commit()
yield
await ctx.session.execute(update(AppSettings).values(**saved))
await ctx.session.commit()
async def test_admin_configures_system_mail_without_ever_seeing_the_password(
ctx: Ctx, restore_settings: None
) -> None:
admin = await ctx.user(admin=True)
async with await ctx.signed_in(admin) as c:
resp = await c.put(
"/api/admin/mail",
json={
"smtp_host": "smtp.example.com",
"smtp_port": 465,
"smtp_security": "ssl",
"smtp_username": "postmaster@example.com",
"smtp_password": "s3cret",
"smtp_from": "postmaster@example.com",
"public_url": "https://mail.example.com/",
},
)
out = resp.json()
assert out["has_password"] is True and out["configured"] is True
assert out["public_url"] == "https://mail.example.com"
assert "s3cret" not in resp.text
# Omitting the password keeps it; an empty one removes it.
kept = (await c.put("/api/admin/mail", json={"smtp_port": 587})).json()
assert kept["has_password"] is True
gone = (await c.put("/api/admin/mail", json={"smtp_password": ""})).json()
assert gone["has_password"] is False
bad = await c.put("/api/admin/mail", json={"public_url": "mail.example.com"})
assert bad.json()["error"]["code"] == "invalid_url"
test = await c.post("/api/admin/mail/test", json={"to": "me@example.com"})
assert test.status_code == 200
assert ctx.sent.mails[-1][:2] == ("me@example.com", "Ultimo Email test message")