Files
lukasandClaude Opus 5.5 165442d897 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>
2026-10-01 21:02:44 +02:00

92 lines
3.0 KiB
Python

"""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)