Logo in system mail; "don't ask for the 2FA code on this device"

System mail: the password-reset email (and the admin panel's test email) now
opens with the Ultimo logo, centered. It is a PNG rendered from
icons/ultimo-logo.svg and embedded in the message (multipart/related, cid:),
so it shows where remote images are blocked and in clients that do not
render SVG. The PNG is listed in package-data so the Docker image carries it.

Trusted devices: a 2FA sign-in offers "Don't ask for the code on this device
in future". Ticked with a correct code, the browser gets its own HttpOnly
trusted-device cookie (token in Redis, 90 days, renewed on use); later
sign-ins to the same account from that browser need only the password. Other
accounts and browsers are unaffected. The trust is revoked on a password
reset, when 2FA is turned off, and when an admin blocks or deletes the user;
signing out keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:02:44 +02:00
co-authored by Claude Opus 5.5
parent d1f6d183e0
commit 165442d897
14 changed files with 334 additions and 12 deletions
+3 -2
View File
@@ -20,7 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.deps import CurrentAdmin, require_csrf
from app.identity.mailer import MailerError, mailer_config, send_mail
from app.identity.mailer import LOGO_HTML, MailerError, mailer_config, send_mail
from app.identity.schemas import (
AdminMailIn,
AdminMailOut,
@@ -131,7 +131,8 @@ async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -
"This is a test message from Ultimo Email.\n\n"
"Outgoing system mail works: password-reset links will be sent from this account.\n"
)
html = f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
# Same logo header as the reset mail, so the test shows what users will get.
html = LOGO_HTML + f"<p>{text.replace(chr(10) * 2, '</p><p>').strip()}</p>"
try:
await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html)
except MailerError as exc:
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+23 -1
View File
@@ -15,6 +15,8 @@ import logging
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr, make_msgid
from functools import cache
from pathlib import Path
import aiosmtplib
@@ -26,6 +28,22 @@ logger = logging.getLogger(__name__)
SENDER_NAME = "Ultimo Email"
_TIMEOUT = 20.0
# The logo travels inside the message (multipart/related, referenced by cid:),
# not as a link: it shows even where remote images are blocked, and it is a PNG
# because mail clients (Gmail first) do not display SVG. Rendered at twice the
# size it is shown at, for sharp high-DPI screens.
_LOGO_FILE = Path(__file__).parent / "assets" / "ultimo-logo.png"
_LOGO_CID = "ultimo-logo@ultimo.email"
LOGO_HTML = (
f'<p style="text-align:center;margin:0 0 24px"><img src="cid:{_LOGO_CID}" '
'width="180" height="47" alt="Ultimo Email" style="display:inline-block;border:0"></p>'
)
@cache
def _logo() -> bytes:
return _LOGO_FILE.read_bytes()
class MailerError(Exception):
"""System mail is not configured, or the SMTP server refused the message."""
@@ -67,6 +85,10 @@ async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: s
msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this
msg.set_content(text)
msg.add_alternative(html, subtype="html")
if f"cid:{_LOGO_CID}" in html:
html_part = msg.get_body(("html",))
if isinstance(html_part, EmailMessage):
html_part.add_related(_logo(), maintype="image", subtype="png", cid=f"<{_LOGO_CID}>")
try:
await aiosmtplib.send(
msg,
@@ -99,7 +121,7 @@ def reset_mail(link: str, handle: str) -> tuple[str, str, str]:
)
body = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5"
html = (
f'<!doctype html>\n<html><body style="{body}">\n'
f'<!doctype html>\n<html><body style="{body}">\n{LOGO_HTML}\n'
f"<p>Someone asked to change the password of the Ultimo Email account <b>{h}</b>.</p>\n"
"<p>To choose a new password, open this link within 30 minutes:</p>\n"
f'<p><a href="{u}" style="{button}">Change password</a></p>\n'
+14
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging
import re
import uuid
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response
@@ -16,6 +17,7 @@ from app.config import get_settings
from app.db import get_session
from app.events import get_event_bus
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,
@@ -133,6 +135,9 @@ async def register(
@router.post("/login", response_model=UserOut)
async def login(body: LoginRequest, request: Request, response: Response, db: DbSession) -> UserOut:
async def trusted(user_id: uuid.UUID) -> bool:
return await trusted_devices.is_trusted(request, response, user_id)
try:
user = await authenticate(
db,
@@ -140,6 +145,7 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db
password=body.password,
ip=_client_ip(request),
totp=body.totp,
trusted_device=trusted,
)
except RateLimited as exc:
response.headers["Retry-After"] = str(exc.retry_after)
@@ -154,6 +160,10 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db
)
if user is None:
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
if body.remember_device and body.totp and user.totp_secret is not None:
# Only after a code was actually checked: a trusted sign-in cannot extend
# the trust to anything new.
await trusted_devices.trust(request, response, user.id)
await _establish_session(request, response, user)
# Sync v2 §3/§5: a login marks the user active (session heat) and promotes
# their accounts' tier to hot.
@@ -208,6 +218,7 @@ async def totp_disable(body: TotpDisableRequest, user: CurrentUser, db: DbSessio
raise_api_error(401, "invalid_credentials", "incorrect password")
user.totp_secret = None
await db.commit()
await trusted_devices.revoke_all(user.id) # trust was in place of a code that no longer exists
return MessageOut()
@@ -301,6 +312,9 @@ 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
# 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()
+2
View File
@@ -19,6 +19,8 @@ class LoginRequest(BaseModel):
handle: str = Field(min_length=1, max_length=320)
password: str = Field(min_length=1, max_length=1024)
totp: str | None = None # required when the account has 2FA enabled
# With a correct code: don't ask this browser for one again (trusted device).
remember_device: bool = False
class TotpSetupResponse(BaseModel):
+14 -2
View File
@@ -2,6 +2,8 @@
from __future__ import annotations
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from sqlalchemy import select
@@ -152,12 +154,20 @@ async def _check_rate_limit(handle: str, ip: str) -> None:
async def authenticate(
db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None
db: AsyncSession,
*,
handle_input: str,
password: str,
ip: str,
totp: str | None = None,
trusted_device: Callable[[uuid.UUID], Awaitable[bool]] | None = None,
) -> User | None:
"""Verify credentials (constant-time). Returns the user or None; may rate-limit.
When the account has TOTP enabled, raises :class:`TotpRequired` (no code given)
or :class:`TotpInvalid` (wrong code) after the password check passes.
or :class:`TotpInvalid` (wrong code) after the password check passes — unless
``trusted_device`` says this browser was trusted for the account at an earlier
2FA sign-in, in which case the password is enough.
"""
handle = resolve_handle(handle_input)
await _check_rate_limit(handle, ip)
@@ -176,6 +186,8 @@ async def authenticate(
from app.identity.totp import decrypt_totp_secret
from app.identity.totp import verify as verify_totp
if not totp and trusted_device is not None and await trusted_device(user.id):
return user
if not totp:
raise TotpRequired
if not verify_totp(decrypt_totp_secret(user.totp_secret), totp):
+91
View File
@@ -0,0 +1,91 @@
"""Trusted devices: "don't ask for the 2FA code on this device" (SPEC §6.1).
Ticking the box at a successful 2FA sign-in stores a random token in Redis
(``trustdev:{token}`` -> user id) and hands it to the browser in its own
HttpOnly cookie. A later sign-in to the *same* account from that browser may
then skip the code — the password is still required. Other accounts signing in
on the same browser are unaffected.
The trust lasts TRUST_DAYS and is renewed each time it is used, so a device in
regular use stays trusted while one left alone for that long has to show a code
again. A per-user index (``trustdev-user:{user_id}``) lets everything be revoked
at once: on a password reset, when 2FA is turned off, and when an administrator
blocks or deletes the account. Signing out does not revoke it — that is the
point of the box.
"""
from __future__ import annotations
import secrets
import uuid
from fastapi import Request, Response
from app.redis_client import aw, get_redis
TRUST_DAYS = 90
_TTL = TRUST_DAYS * 24 * 3600
_PREFIX = "trustdev:"
_INDEX = "trustdev-user:"
COOKIE_SECURE = "__Host-trusted-device"
COOKIE_INSECURE = "trusted_device"
def _cookie_name(request: Request) -> str:
# Same split as the session cookie: the __Host- prefix needs HTTPS.
return COOKIE_SECURE if request.url.scheme == "https" else COOKIE_INSECURE
def read_cookie(request: Request) -> str:
return request.cookies.get(COOKIE_SECURE) or request.cookies.get(COOKIE_INSECURE, "")
async def trust(request: Request, response: Response, user_id: uuid.UUID) -> None:
"""Remember this browser for ``user_id`` and set the cookie."""
token = secrets.token_urlsafe(32)
redis = get_redis()
await redis.set(_PREFIX + token, str(user_id), ex=_TTL)
await aw(redis.sadd(_INDEX + str(user_id), token))
await redis.expire(_INDEX + str(user_id), _TTL)
response.set_cookie(
_cookie_name(request),
token,
max_age=_TTL,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
path="/",
)
async def is_trusted(request: Request, response: Response, user_id: uuid.UUID) -> bool:
"""Whether this browser is trusted for ``user_id``; renews the trust if so."""
token = read_cookie(request)
if not token:
return False
redis = get_redis()
if await redis.get(_PREFIX + token) != str(user_id):
return False
await redis.expire(_PREFIX + token, _TTL)
await redis.expire(_INDEX + str(user_id), _TTL)
response.set_cookie(
_cookie_name(request),
token,
max_age=_TTL,
httponly=True,
secure=request.url.scheme == "https",
samesite="lax",
path="/",
)
return True
async def revoke_all(user_id: uuid.UUID) -> None:
"""Forget every device trusted for ``user_id``."""
redis = get_redis()
index = _INDEX + str(user_id)
tokens: set[str] = await aw(redis.smembers(index))
if tokens:
await redis.delete(*(_PREFIX + t for t in tokens))
await redis.delete(index)
+2
View File
@@ -21,6 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.accounts.service import delete_account
from app.config import get_settings
from app.events import get_event_bus
from app.identity import trusted_devices
from app.identity.sessions import destroy_all_sessions
from app.models import Account, Message, User
from app.plugins.db import plugin_session
@@ -79,6 +80,7 @@ async def _sign_out_everywhere(user_id: uuid.UUID) -> None:
"""Revoke every session, and tell the user's open tabs so they leave at once
instead of showing already-loaded mail until their next request."""
await destroy_all_sessions(user_id)
await trusted_devices.revoke_all(user_id)
await get_event_bus().publish("user.signed_out", {"user_id": str(user_id)})