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:
@@ -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)
|
||||
Reference in New Issue
Block a user