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:
@@ -307,3 +307,26 @@ async def test_admin_configures_system_mail_without_ever_seeing_the_password(
|
||||
test = await c.post("/api/admin/mail/test", json={"to": "me@example.com"})
|
||||
assert test.status_code == 200
|
||||
assert ctx.sent.mails[-1][:2] == ("me@example.com", "Ultimo Email test message")
|
||||
|
||||
|
||||
async def test_reset_mail_carries_the_logo_inline(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The logo is a PNG inside the message (cid:), not a remote image or SVG."""
|
||||
import aiosmtplib
|
||||
|
||||
from app.identity import mailer
|
||||
|
||||
sent: list[object] = []
|
||||
|
||||
async def fake_send(msg: object, **kwargs: object) -> None:
|
||||
sent.append(msg)
|
||||
|
||||
monkeypatch.setattr(aiosmtplib, "send", fake_send)
|
||||
subject, text, html = mailer.reset_mail("https://mail.example.com/x", "a@test.local")
|
||||
await mailer.send_mail(CFG, "to@example.com", subject, text, html)
|
||||
msg = sent[0]
|
||||
parts = {p.get_content_type(): p for p in msg.walk()} # type: ignore[attr-defined]
|
||||
assert {"multipart/alternative", "text/plain", "multipart/related", "text/html"} <= set(parts)
|
||||
logo = parts["image/png"]
|
||||
assert logo["Content-ID"] == "<ultimo-logo@ultimo.email>"
|
||||
assert logo.get_payload(decode=True).startswith(b"\x89PNG")
|
||||
assert 'src="cid:ultimo-logo@ultimo.email"' in parts["text/html"].get_content()
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Trusted devices: "don't ask for the code on this device" at a 2FA sign-in."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import httpx
|
||||
import pyotp
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity import trusted_devices
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.totp import encrypt_totp_secret
|
||||
from app.main import create_app
|
||||
from app.models import User
|
||||
|
||||
PASSWORD = "hunter2pass"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
session: AsyncSession
|
||||
created: list[uuid.UUID] = field(default_factory=list)
|
||||
|
||||
async def user(self) -> tuple[User, str]:
|
||||
secret = pyotp.random_base32()
|
||||
user = User(
|
||||
handle=f"td{uuid.uuid4().hex[:8]}@test.local",
|
||||
password_hash=hash_password(PASSWORD),
|
||||
totp_secret=encrypt_totp_secret(secret),
|
||||
)
|
||||
self.session.add(user)
|
||||
await self.session.commit()
|
||||
self.created.append(user.id)
|
||||
return user, secret
|
||||
|
||||
|
||||
def _browser() -> httpx.AsyncClient:
|
||||
"""One browser: keeps its cookies between requests."""
|
||||
return httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
session = async_sessionmaker(engine, expire_on_commit=False)()
|
||||
c = Ctx(session=session)
|
||||
try:
|
||||
yield c
|
||||
finally:
|
||||
for uid in c.created:
|
||||
await trusted_devices.revoke_all(uid)
|
||||
await session.execute(delete(User).where(User.id.in_(c.created)))
|
||||
await session.commit()
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _login(
|
||||
browser: httpx.AsyncClient, user: User, totp: str | None = None, remember: bool = False
|
||||
) -> httpx.Response:
|
||||
body: dict[str, object] = {"handle": user.handle, "password": PASSWORD}
|
||||
if totp:
|
||||
body["totp"] = totp
|
||||
if remember:
|
||||
body["remember_device"] = True
|
||||
return await browser.post("/api/auth/login", json=body)
|
||||
|
||||
|
||||
async def test_a_remembered_browser_signs_in_without_the_code(ctx: Ctx) -> None:
|
||||
user, secret = await ctx.user()
|
||||
async with _browser() as browser:
|
||||
first = await _login(browser, user, pyotp.TOTP(secret).now(), remember=True)
|
||||
assert first.status_code == 200
|
||||
assert "trusted_device" in browser.cookies
|
||||
again = await _login(browser, user)
|
||||
assert again.status_code == 200
|
||||
# The password is still checked.
|
||||
wrong = await browser.post(
|
||||
"/api/auth/login", json={"handle": user.handle, "password": "not-it-at-all"}
|
||||
)
|
||||
assert wrong.json()["error"]["code"] == "invalid_credentials"
|
||||
|
||||
|
||||
async def test_without_the_box_the_code_is_asked_every_time(ctx: Ctx) -> None:
|
||||
user, secret = await ctx.user()
|
||||
async with _browser() as browser:
|
||||
assert (await _login(browser, user, pyotp.TOTP(secret).now())).status_code == 200
|
||||
assert "trusted_device" not in browser.cookies
|
||||
assert (await _login(browser, user)).json()["error"]["code"] == "totp_required"
|
||||
|
||||
|
||||
async def test_trust_is_per_account_and_per_browser(ctx: Ctx) -> None:
|
||||
alice, alice_secret = await ctx.user()
|
||||
bob, _ = await ctx.user()
|
||||
async with _browser() as browser:
|
||||
await _login(browser, alice, pyotp.TOTP(alice_secret).now(), remember=True)
|
||||
# Another account on the same browser still needs its own code.
|
||||
assert (await _login(browser, bob)).json()["error"]["code"] == "totp_required"
|
||||
async with _browser() as other_browser:
|
||||
assert (await _login(other_browser, alice)).json()["error"]["code"] == "totp_required"
|
||||
|
||||
|
||||
async def test_turning_2fa_off_revokes_the_trust(ctx: Ctx) -> None:
|
||||
user, secret = await ctx.user()
|
||||
async with _browser() as browser:
|
||||
await _login(browser, user, pyotp.TOTP(secret).now(), remember=True)
|
||||
token = browser.cookies["trusted_device"]
|
||||
await trusted_devices.revoke_all(user.id) # what totp/disable, reset and block call
|
||||
assert (await _login(browser, user)).json()["error"]["code"] == "totp_required"
|
||||
assert browser.cookies.get("trusted_device") == token # stale cookie, no longer honoured
|
||||
|
||||
|
||||
async def test_totp_disable_endpoint_revokes(ctx: Ctx) -> None:
|
||||
user, secret = await ctx.user()
|
||||
async with _browser() as browser:
|
||||
await _login(browser, user, pyotp.TOTP(secret).now(), remember=True)
|
||||
old_token = browser.cookies["trusted_device"]
|
||||
csrf = browser.cookies["csrf"]
|
||||
resp = await browser.post(
|
||||
"/api/auth/totp/disable", json={"password": PASSWORD}, headers={"X-CSRF": csrf}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
# Turn 2FA back on: the old trust must not come back with it.
|
||||
user.totp_secret = encrypt_totp_secret(pyotp.random_base32())
|
||||
ctx.session.add(user)
|
||||
await ctx.session.commit()
|
||||
async with _browser() as browser:
|
||||
browser.cookies.set("trusted_device", old_token)
|
||||
assert (await _login(browser, user)).json()["error"]["code"] == "totp_required"
|
||||
Reference in New Issue
Block a user