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
+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()