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