From 0ddc0cb0016cf6338cd67570fa1dc7ed1730a92a Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Thu, 1 Oct 2026 20:16:27 +0200 Subject: [PATCH] Admin user management, password reset by email, and the Ultimo Email name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branding: the page titles and app name are now "Ultimo Email". Admin panel (now two columns with a divider): - Users: search by login or by a mail account's address or name (results only, nothing listed until something is typed). A user opens a window to change their quota, block/unblock them, or delete them; block and delete ask for confirmation, and an admin cannot do either to their own account. - Blocking (users.blocked_at, migration 0019) refuses sign-in with a clear message and revokes every session; a new user.signed_out event sends the user's open tabs to the sign-in screen at once. Any "not signed in" answer now does the same, not only /auth/me. Deleting removes mail accounts (sync stops), plugin rows (cleared inside the user's RLS context, since plugin tables have no FK to users), blobs, the search index, sessions and the user. - Outgoing system mail (migration 0020): SMTP server, port, security, login, password (vault-encrypted, never sent back), From address and the public address reset links point to, plus a "Send test" that reports the server's error. Password reset: - "Forgot password?" on the sign-in page asks for the user name and always answers "If this account exists you will receive an email to your recovery email address"; the mail goes out after the response, so neither the reply nor its timing reveals an account, and it is capped at 3 per hour. - The emailed link (token in the URL fragment, single use, 30 minutes) opens a page to set a new password. With 2-step verification on, the code is checked before anything changes; five wrong codes burn the link. - Settings > Profile: the recovery email can be added, changed or removed, confirmed with the current password, which must be typed — autofill and paste are refused (TypedPasswordInput). Also adds frontend/icons/ultimo-logo-bimi.svg, an SVG Tiny PS version of the logo for a BIMI record. Co-Authored-By: Claude Opus 5.5 --- backend/app/api/admin.py | 161 +++++- backend/app/events.py | 3 + backend/app/identity/deps.py | 5 +- backend/app/identity/mailer.py | 110 ++++ backend/app/identity/reset.py | 65 ++- backend/app/identity/routes.py | 109 +++- backend/app/identity/schemas.py | 68 +++ backend/app/identity/service.py | 7 + backend/app/identity/user_admin.py | 125 +++++ backend/app/models.py | 16 + backend/app/ws.py | 4 + .../migrations/versions/0019_user_blocked.py | 29 + .../migrations/versions/0020_system_mail.py | 48 ++ backend/tests/api/test_admin_users.py | 196 +++++++ backend/tests/identity/test_identity_m1.py | 41 +- backend/tests/identity/test_password_reset.py | 309 +++++++++++ frontend/admin.html | 2 +- frontend/icons/ultimo-logo-bimi.svg | 14 + frontend/index.html | 2 +- frontend/src/App.tsx | 4 + frontend/src/admin/AdminApp.tsx | 506 +++++++++++++++++- .../src/components/TypedPasswordInput.tsx | 66 +++ frontend/src/i18n/en.ts | 74 ++- frontend/src/lib/api.ts | 34 ++ frontend/src/lib/auth.ts | 20 +- frontend/src/lib/types.ts | 25 + frontend/src/lib/ws.ts | 9 + frontend/src/main.tsx | 11 +- frontend/src/routes/ForgotPassword.tsx | 61 +++ frontend/src/routes/Login.tsx | 5 + frontend/src/routes/ResetPassword.tsx | 130 +++++ frontend/src/routes/settings/Settings.tsx | 95 +++- 32 files changed, 2267 insertions(+), 87 deletions(-) create mode 100644 backend/app/identity/mailer.py create mode 100644 backend/app/identity/user_admin.py create mode 100644 backend/migrations/versions/0019_user_blocked.py create mode 100644 backend/migrations/versions/0020_system_mail.py create mode 100644 backend/tests/api/test_admin_users.py create mode 100644 backend/tests/identity/test_password_reset.py create mode 100644 frontend/icons/ultimo-logo-bimi.svg create mode 100644 frontend/src/components/TypedPasswordInput.tsx create mode 100644 frontend/src/routes/ForgotPassword.tsx create mode 100644 frontend/src/routes/ResetPassword.tsx diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index ae15ac9..0875763 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,24 +1,40 @@ """Instance administration (SPEC §6.1). -The backend seam for the admin panel. Today it carries only the registration -gate — the first account on a fresh install becomes the administrator and closes -signup behind itself, and this is how that gets reopened. Further instance-wide -options land here as the panel grows. +The backend seam for the admin panel: the registration gate — the first account +on a fresh install becomes the administrator and closes signup behind itself, +and this is how that gets reopened — the default quota, and managing users +(search, per-user quota, block, delete). An administrator cannot block or delete +their own account, so the instance can never lock out its last admin that way. """ from __future__ import annotations import datetime as dt +import uuid from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query +from sqlalchemy import func, select 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.schemas import AdminSettingsIn, AdminSettingsOut +from app.identity.mailer import MailerError, mailer_config, send_mail +from app.identity.schemas import ( + AdminMailIn, + AdminMailOut, + AdminMailTestIn, + AdminSettingsIn, + AdminSettingsOut, + AdminUserOut, + AdminUserPatch, + MessageOut, +) from app.identity.service import app_settings -from app.models import AppSettings +from app.identity.user_admin import delete_user, search_users, set_blocked +from app.models import Account, AppSettings, Message, User +from app.security.crypto import encrypt_secret router = APIRouter(prefix="/admin", tags=["admin"]) @@ -54,3 +70,134 @@ async def write_settings( row.updated_at = dt.datetime.now(dt.UTC) await db.commit() return _out(row) + + +# --- Outgoing system mail ------------------------------------------------------- + + +def _mail_out(row: AppSettings) -> AdminMailOut: + return AdminMailOut( + smtp_host=row.smtp_host, + smtp_port=row.smtp_port, + smtp_security=row.smtp_security, + smtp_username=row.smtp_username, + has_password=row.smtp_secret_ct is not None, + smtp_from=row.smtp_from, + public_url=row.public_url, + configured=bool(row.smtp_host and row.smtp_from and row.public_url), + ) + + +@router.get("/mail", response_model=AdminMailOut) +async def read_mail(admin: CurrentAdmin, db: DbSession) -> AdminMailOut: + return _mail_out(await app_settings(db)) + + +@router.put("/mail", response_model=AdminMailOut, dependencies=[Depends(require_csrf)]) +async def write_mail(body: AdminMailIn, admin: CurrentAdmin, db: DbSession) -> AdminMailOut: + """Merge the supplied SMTP fields. The password is stored vault-encrypted.""" + row = await app_settings(db, for_update=True) + for name in ("smtp_host", "smtp_username", "smtp_from"): + value = getattr(body, name) + if value is not None: + setattr(row, name, value.strip()) + if body.smtp_port is not None: + row.smtp_port = body.smtp_port + if body.smtp_security is not None: + row.smtp_security = body.smtp_security + if body.smtp_password is not None: + row.smtp_secret_ct = ( + encrypt_secret(body.smtp_password.encode()) if body.smtp_password else None + ) + if body.public_url is not None: + url = body.public_url.strip().rstrip("/") + if url and not url.startswith(("http://", "https://")): + raise_api_error(400, "invalid_url", "the address must start with http:// or https://") + row.public_url = url + row.updated_at = dt.datetime.now(dt.UTC) + await db.commit() + return _mail_out(row) + + +@router.post("/mail/test", response_model=MessageOut, dependencies=[Depends(require_csrf)]) +async def test_mail(body: AdminMailTestIn, admin: CurrentAdmin, db: DbSession) -> MessageOut: + """Send a test message with the saved settings, reporting the server's error.""" + cfg = mailer_config(await app_settings(db)) + if cfg is None: + raise_api_error( + 409, "mail_not_configured", "set the SMTP server, From address and public address first" + ) + text = ( + "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"

{text.replace(chr(10) * 2, '

').strip()}

" + try: + await send_mail(cfg, body.to.strip(), "Ultimo Email test message", text, html) + except MailerError as exc: + raise_api_error(502, "mail_failed", f"sending failed: {exc}") + return MessageOut() + + +# --- Users -------------------------------------------------------------------- + + +def _user_out(user: User, used_bytes: int, accounts: list[str]) -> AdminUserOut: + return AdminUserOut( + id=user.id, + handle=user.handle, + is_admin=user.is_admin, + blocked_at=user.blocked_at.isoformat() if user.blocked_at else None, + quota_bytes=user.quota_bytes, + used_bytes=used_bytes, + created_at=user.created_at.isoformat(), + accounts=accounts, + ) + + +async def _target(db: AsyncSession, admin: User, user_id: uuid.UUID) -> User: + """The user an admin action is aimed at; never the admin themselves.""" + user = await db.scalar(select(User).where(User.id == user_id)) + if user is None: + raise_api_error(404, "not_found", "user not found") + if user.id == admin.id: + raise_api_error(409, "cannot_modify_self", "you cannot do this to your own account") + return user + + +@router.get("/users", response_model=list[AdminUserOut]) +async def list_users( + admin: CurrentAdmin, db: DbSession, q: str = Query("", max_length=200) +) -> list[AdminUserOut]: + """Search users by login, or by a mail account's address or display name.""" + return [_user_out(u, used, emails) for u, used, emails in await search_users(db, q)] + + +@router.patch("/users/{user_id}", response_model=AdminUserOut, dependencies=[Depends(require_csrf)]) +async def patch_user( + user_id: uuid.UUID, body: AdminUserPatch, admin: CurrentAdmin, db: DbSession +) -> AdminUserOut: + """Change a user's quota and/or block or unblock them.""" + user = await _target(db, admin, user_id) + if body.quota_bytes is not None: + user.quota_bytes = body.quota_bytes + if body.blocked is not None: + await set_blocked(db, user, body.blocked) + await db.commit() + used = await db.scalar( + select(func.coalesce(func.sum(Message.size_bytes), 0)).where(Message.user_id == user.id) + ) + emails = list( + await db.scalars( + select(Account.email).where(Account.user_id == user.id).order_by(Account.email) + ) + ) + return _user_out(user, int(used or 0), emails) + + +@router.delete("/users/{user_id}", dependencies=[Depends(require_csrf)]) +async def remove_user(user_id: uuid.UUID, admin: CurrentAdmin, db: DbSession) -> dict[str, str]: + """Delete a user and all of their data. Cannot be undone.""" + user = await _target(db, admin, user_id) + await delete_user(db, user) + return {"status": "deleted"} diff --git a/backend/app/events.py b/backend/app/events.py index e1ea717..e74a2ae 100644 --- a/backend/app/events.py +++ b/backend/app/events.py @@ -41,6 +41,9 @@ ALLOWED_EVENTS: frozenset[str] = frozenset( # Sync v2 §3: published on login and every WS connect; drives tier # promotion in the sync workers (§5). Payload: {user_id}. "user.active", + # The user's sessions were revoked by an administrator (blocked or + # deleted): open tabs drop to the sign-in screen. Payload: {user_id}. + "user.signed_out", } ) diff --git a/backend/app/identity/deps.py b/backend/app/identity/deps.py index d6c97d6..1248ea0 100644 --- a/backend/app/identity/deps.py +++ b/backend/app/identity/deps.py @@ -30,8 +30,9 @@ async def current_user( ) -> User: """Load the authenticated :class:`User` (I-7: scoped by the session's user_id).""" user = await db.scalar(select(User).where(User.id == session.user_id)) - if user is None: - # Session references a deleted user; treat as unauthenticated. + if user is None or user.blocked_at is not None: + # Session references a deleted or blocked user; treat as unauthenticated. + # (Blocking also revokes the sessions; this covers any request in flight.) raise_api_error(401, "not_authenticated", "authentication required") return user diff --git a/backend/app/identity/mailer.py b/backend/app/identity/mailer.py new file mode 100644 index 0000000..a2a77b4 --- /dev/null +++ b/backend/app/identity/mailer.py @@ -0,0 +1,110 @@ +"""Outgoing system mail (SPEC §6.1): mail the server sends on its own behalf. + +Today that is only the password-reset link. It goes out through the SMTP +account the administrator configured in the admin panel (``app_settings``), +never through a user's own mail account — the whole point is that the user +cannot sign in. The administrator's server is trusted, so unlike user mail +accounts this connection is not held to the SSRF guard (it may well be a relay +on the LAN). +""" + +from __future__ import annotations + +import html as html_lib +import logging +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr, make_msgid + +import aiosmtplib + +from app.models import AppSettings +from app.security.crypto import decrypt_secret + +logger = logging.getLogger(__name__) + +SENDER_NAME = "Ultimo Email" +_TIMEOUT = 20.0 + + +class MailerError(Exception): + """System mail is not configured, or the SMTP server refused the message.""" + + +@dataclass(frozen=True) +class MailerConfig: + host: str + port: int + security: str # starttls | ssl | none + username: str + password: str + sender: str + public_url: str + + +def mailer_config(row: AppSettings) -> MailerConfig | None: + """The usable configuration, or None while the admin has not set one up.""" + if not (row.smtp_host and row.smtp_from and row.public_url): + return None + password = decrypt_secret(row.smtp_secret_ct).decode() if row.smtp_secret_ct else "" + return MailerConfig( + host=row.smtp_host, + port=row.smtp_port, + security=row.smtp_security, + username=row.smtp_username, + password=password, + sender=row.smtp_from, + public_url=row.public_url.rstrip("/"), + ) + + +async def send_mail(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None: + msg = EmailMessage() + msg["From"] = formataddr((SENDER_NAME, cfg.sender)) + msg["To"] = to + msg["Subject"] = subject + msg["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[-1]) + msg["Auto-Submitted"] = "auto-generated" # RFC 3834: do not auto-reply to this + msg.set_content(text) + msg.add_alternative(html, subtype="html") + try: + await aiosmtplib.send( + msg, + hostname=cfg.host, + port=cfg.port, + username=cfg.username or None, + password=cfg.password or None, + use_tls=cfg.security == "ssl", + start_tls=cfg.security == "starttls", + timeout=_TIMEOUT, + ) + except (aiosmtplib.SMTPException, OSError) as exc: + raise MailerError(str(exc) or type(exc).__name__) from exc + + +def reset_mail(link: str, handle: str) -> tuple[str, str, str]: + """(subject, text, html) of the password-reset message.""" + subject = "Reset your Ultimo Email password" + text = ( + f"Someone asked to change the password of the Ultimo Email account {handle}.\n\n" + f"To choose a new password, open this link within 30 minutes:\n\n{link}\n\n" + "The link works once. If you did not ask for this, ignore this email — " + "your password stays as it is.\n" + ) + h, u = html_lib.escape(handle), html_lib.escape(link, quote=True) + muted = "font-size:13px;color:#6b7280" + button = ( + "display:inline-block;background:#2563eb;color:#ffffff;text-decoration:none;" + "padding:10px 18px;border-radius:8px;font-weight:bold" + ) + body = "font-family:Arial,Helvetica,sans-serif;font-size:15px;color:#1f2937;line-height:1.5" + html = ( + f'\n\n' + f"

Someone asked to change the password of the Ultimo Email account {h}.

\n" + "

To choose a new password, open this link within 30 minutes:

\n" + f'

Change password

\n' + f'

Or paste this address into your browser:
{u}

\n' + f'

The link works once. If you did not ask for this, ignore this ' + "email — your password stays as it is.

\n\n" + ) + return subject, text, html diff --git a/backend/app/identity/reset.py b/backend/app/identity/reset.py index 07dac71..935f0c6 100644 --- a/backend/app/identity/reset.py +++ b/backend/app/identity/reset.py @@ -1,43 +1,76 @@ """Password reset tokens (SPEC §6.1). -Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a -recovery email on file. Actual delivery of the token to the recovery address -requires an app-level mailer (not part of M0/M1); until then the token is logged -server-side. See docs/open-questions.md. +Single-use, 30-minute, Redis-backed tokens, emailed as a link to the account's +recovery address through the system mailer (:mod:`app.identity.mailer`). + +A token survives a wrong 2FA code (the page asks again) but not five: after +that it is burned, so the link cannot be used to guess codes. Requests are +capped per account so the reset form cannot be used to flood someone's inbox. """ from __future__ import annotations -import logging import secrets import uuid from app.redis_client import aw, get_redis -logger = logging.getLogger(__name__) - _PREFIX = "pwreset:" +_FAILS = "pwreset-fail:" +_RATE = "pwreset-rate:" _TTL = 30 * 60 # 30 minutes +MAX_TOTP_FAILURES = 5 +MAX_REQUESTS_PER_HOUR = 3 -async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str: +async def issue_token(user_id: uuid.UUID) -> str: """Create a single-use reset token bound to ``user_id`` and return it.""" token = secrets.token_urlsafe(32) await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL) - # TODO(spec): deliver via the recovery address once an app mailer exists. - logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email) return token +async def request_allowed(user_id: uuid.UUID) -> bool: + """Count a reset request; False once the account has had its hourly share.""" + redis = get_redis() + key = _RATE + str(user_id) + count = int(await redis.incr(key)) + if count == 1: + await redis.expire(key, 3600) + return count <= MAX_REQUESTS_PER_HOUR + + +def _user_id(raw: str | None) -> uuid.UUID | None: + if raw is None: + return None + try: + return uuid.UUID(raw) + except ValueError: + return None + + +async def peek_token(token: str) -> uuid.UUID | None: + """The user a still-valid token belongs to, without using it up.""" + if not token: + return None + return _user_id(await get_redis().get(_PREFIX + token)) + + async def consume_token(token: str) -> uuid.UUID | None: """Atomically consume a reset token, returning the user_id (or None).""" if not token: return None redis = get_redis() raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically - if raw is None: - return None - try: - return uuid.UUID(raw) - except ValueError: - return None + await redis.delete(_FAILS + token) + return _user_id(raw) + + +async def record_totp_failure(token: str) -> None: + """Count a wrong 2FA code against the token; burn it at the limit.""" + redis = get_redis() + fails = await redis.incr(_FAILS + token) + if fails == 1: + await redis.expire(_FAILS + token, _TTL) + if fails >= MAX_TOTP_FAILURES: + await redis.delete(_PREFIX + token, _FAILS + token) diff --git a/backend/app/identity/routes.py b/backend/app/identity/routes.py index 0891190..2141558 100644 --- a/backend/app/identity/routes.py +++ b/backend/app/identity/routes.py @@ -3,9 +3,11 @@ TOTP setup/enable, session list/revoke, password reset.""" from __future__ import annotations +import logging +import re from typing import Annotated -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, BackgroundTasks, Depends, Request, Response from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -20,13 +22,23 @@ from app.identity.cookies import ( set_auth_cookies, ) from app.identity.deps import CurrentSession, CurrentUser, require_csrf +from app.identity.mailer import MailerConfig, MailerError, mailer_config, reset_mail, send_mail from app.identity.passwords import hash_password, verify_password -from app.identity.reset import consume_token, issue_token +from app.identity.reset import ( + consume_token, + issue_token, + peek_token, + record_totp_failure, + request_allowed, +) from app.identity.schemas import ( LoginRequest, MessageOut, + PasswordResetCheckIn, + PasswordResetCheckOut, PasswordResetIn, PasswordResetRequestIn, + RecoveryEmailIn, RegisterRequest, RegistrationStatusOut, SessionOut, @@ -36,10 +48,12 @@ from app.identity.schemas import ( UserOut, ) from app.identity.service import ( + AccountBlocked, RateLimited, RegistrationError, TotpInvalid, TotpRequired, + app_settings, authenticate, register_user, registration_status, @@ -56,8 +70,14 @@ from app.models import User from app.redis_client import get_redis from app.sync import leases +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/auth", tags=["auth"]) +# Deliberately loose: the recovery address only has to be something mail can be +# sent to; the mail server is the real judge. +_EMAIL_RE = re.compile(r"[^@\s]+@[^@\s]+\.[^@\s]+") + DbSession = Annotated[AsyncSession, Depends(get_session)] @@ -128,6 +148,10 @@ async def login(body: LoginRequest, request: Request, response: Response, db: Db raise_api_error(401, "totp_required", "a 2FA code is required") except TotpInvalid: raise_api_error(401, "totp_invalid", "incorrect 2FA code") + except AccountBlocked: + raise_api_error( + 403, "account_blocked", "this account has been blocked by the administrator" + ) if user is None: raise_api_error(401, "invalid_credentials", "incorrect handle or password") await _establish_session(request, response, user) @@ -209,25 +233,86 @@ async def revoke(sid: str, user: CurrentUser) -> MessageOut: # --- Password reset (SPEC §6.1) --------------------------------------------- +async def _send_reset_mail(cfg: MailerConfig, to: str, handle: str, token: str) -> None: + """Runs after the response has gone out (see password_reset_request).""" + # The token rides in the fragment, which browsers never send to a server, so + # it stays out of access logs and Referer headers. + subject, text, html = reset_mail(f"{cfg.public_url}/reset-password#token={token}", handle) + try: + await send_mail(cfg, to, subject, text, html) + except MailerError as exc: + logger.warning("password reset mail for %s could not be sent: %s", handle, exc) + + @router.post("/password/reset-request", response_model=MessageOut) -async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut: - # Always return ok (do not leak whether the handle exists / has recovery mail). +async def password_reset_request( + body: PasswordResetRequestIn, background: BackgroundTasks, db: DbSession +) -> MessageOut: + """Email a reset link to the account's recovery address. + + The answer is the same whether or not the account exists, has a recovery + address, or hit the hourly cap, and the mail is sent after the response — + so neither the reply nor its timing tells a stranger anything. + """ handle = resolve_handle(body.handle) user = await db.scalar(select(User).where(User.handle == handle)) - if user is not None and user.recovery_email: - await issue_token(user.id, user.recovery_email) + if user is None or not user.recovery_email: + return MessageOut() + cfg = mailer_config(await app_settings(db)) + if cfg is None: + logger.warning("password reset requested for %s, but system mail is not set up", handle) + return MessageOut() + if not await request_allowed(user.id): + return MessageOut() + token = await issue_token(user.id) + background.add_task(_send_reset_mail, cfg, user.recovery_email, user.handle, token) return MessageOut() +@router.post("/password/reset/check", response_model=PasswordResetCheckOut) +async def password_reset_check(body: PasswordResetCheckIn, db: DbSession) -> PasswordResetCheckOut: + """Whether a reset link still works, so the page can say so before asking + for a new password. Does not use the link up.""" + user_id = await peek_token(body.token) + user = await db.get(User, user_id) if user_id else None + if user is None: + return PasswordResetCheckOut(valid=False) + return PasswordResetCheckOut(valid=True, totp_required=user.totp_secret is not None) + + @router.post("/password/reset", response_model=MessageOut) async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut: - user_id = await consume_token(body.token) - if user_id is None: - raise_api_error(400, "invalid_token", "reset token is invalid or expired") - user = await db.get(User, user_id) + """Set a new password with a reset link. With 2FA on, the code is checked + before anything changes; a missing or wrong code leaves the link usable + (up to MAX_TOTP_FAILURES wrong codes).""" + user_id = await peek_token(body.token) + user = await db.get(User, user_id) if user_id else None if user is None: - raise_api_error(400, "invalid_token", "reset token is invalid or expired") + raise_api_error(400, "invalid_token", "this link is invalid or has expired") + if user.totp_secret is not None: + if not body.totp: + raise_api_error(401, "totp_required", "a 2FA code is required") + if not totp_mod.verify(totp_mod.decrypt_totp_secret(user.totp_secret), body.totp): + await record_totp_failure(body.token) + raise_api_error(401, "totp_invalid", "incorrect 2FA code") + # Only now is the link spent — and only if nobody spent it in the meantime. + if await consume_token(body.token) != user.id: + raise_api_error(400, "invalid_token", "this link is invalid or has expired") user.password_hash = hash_password(body.password) await db.commit() - await destroy_all_sessions(user_id) # force re-login everywhere after reset + await destroy_all_sessions(user.id) # force re-login everywhere after reset return MessageOut() + + +@router.put("/recovery-email", response_model=UserOut, dependencies=[Depends(require_csrf)]) +async def set_recovery_email(body: RecoveryEmailIn, user: CurrentUser, db: DbSession) -> UserOut: + """Add, change or remove the recovery address. Takes the password: whoever + controls this address can take the account over with a reset link.""" + if not verify_password(user.password_hash, body.password): + raise_api_error(403, "invalid_password", "incorrect password") + email = (body.recovery_email or "").strip().lower() or None + if email is not None and not _EMAIL_RE.fullmatch(email): + raise_api_error(400, "invalid_email", "enter a valid email address") + user.recovery_email = email + await db.commit() + return _user_out(user) diff --git a/backend/app/identity/schemas.py b/backend/app/identity/schemas.py index f14a656..4778af5 100644 --- a/backend/app/identity/schemas.py +++ b/backend/app/identity/schemas.py @@ -3,6 +3,7 @@ from __future__ import annotations import uuid +from typing import Literal from pydantic import BaseModel, Field @@ -47,6 +48,23 @@ class PasswordResetRequestIn(BaseModel): class PasswordResetIn(BaseModel): token: str = Field(min_length=1) password: str = Field(min_length=8, max_length=1024) + totp: str | None = None # required when the account has 2FA enabled + + +class PasswordResetCheckIn(BaseModel): + token: str = Field(min_length=1) + + +class PasswordResetCheckOut(BaseModel): + valid: bool + # Tells the page to expect the 2FA step; only the link's holder learns it. + totp_required: bool = False + + +class RecoveryEmailIn(BaseModel): + password: str = Field(min_length=1, max_length=1024) + # None (or blank) removes the recovery address. + recovery_email: str | None = Field(default=None, max_length=320) class UserOut(BaseModel): @@ -79,5 +97,55 @@ class AdminSettingsIn(BaseModel): default_quota_bytes: int | None = Field(default=None, gt=0) +class AdminMailOut(BaseModel): + smtp_host: str + smtp_port: int + smtp_security: str + smtp_username: str + # The password itself never leaves the server. + has_password: bool + smtp_from: str + public_url: str + # Host, From address and public address are all set: reset mail can go out. + configured: bool + + +class AdminMailIn(BaseModel): + """Merge semantics: omitted fields are left as they are.""" + + smtp_host: str | None = Field(default=None, max_length=255) + smtp_port: int | None = Field(default=None, ge=1, le=65535) + smtp_security: Literal["starttls", "ssl", "none"] | None = None + smtp_username: str | None = Field(default=None, max_length=320) + # Omitted keeps the stored password; an empty string removes it. + smtp_password: str | None = Field(default=None, max_length=1024) + smtp_from: str | None = Field(default=None, max_length=320) + public_url: str | None = Field(default=None, max_length=500) + + +class AdminMailTestIn(BaseModel): + to: str = Field(min_length=3, max_length=320) + + +class AdminUserOut(BaseModel): + id: uuid.UUID + handle: str + is_admin: bool + # ISO timestamp while blocked; null otherwise. + blocked_at: str | None + quota_bytes: int + used_bytes: int + created_at: str + # The user's connected mail accounts, so the admin can tell who this is. + accounts: list[str] + + +class AdminUserPatch(BaseModel): + """Merge semantics: omitted fields are left as they are.""" + + quota_bytes: int | None = Field(default=None, gt=0) + blocked: bool | None = None + + class MessageOut(BaseModel): status: str = "ok" diff --git a/backend/app/identity/service.py b/backend/app/identity/service.py index da7ada9..69e26c0 100644 --- a/backend/app/identity/service.py +++ b/backend/app/identity/service.py @@ -53,6 +53,10 @@ class TotpInvalid(Exception): """Password verified but the supplied TOTP code was wrong.""" +class AccountBlocked(Exception): + """Password verified but an administrator has blocked the account.""" + + def resolve_handle(handle_input: str) -> str: """Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form.""" text = handle_input.strip().lower() @@ -164,6 +168,9 @@ async def authenticate( ok = verify_password(stored_hash, password) if user is None or not ok: return None + # Only after the password: a stranger must not learn that a handle is blocked. + if user.blocked_at is not None: + raise AccountBlocked if user.totp_secret is not None: from app.identity.totp import decrypt_totp_secret diff --git a/backend/app/identity/user_admin.py b/backend/app/identity/user_admin.py new file mode 100644 index 0000000..0f81c62 --- /dev/null +++ b/backend/app/identity/user_admin.py @@ -0,0 +1,125 @@ +"""Administrator actions on user accounts (SPEC §6.1 admin panel). + +Search, per-user quota, blocking and deletion. Blocking keeps every byte of the +user's data (and keeps their mail syncing) so unblocking is instant; it only +refuses sign-in and revokes the sessions they hold. Deletion is total: mail +accounts (through the same path the user's own "remove account" takes, so the +sync runners stop), plugin rows, blobs on disk, the search index and the user +row, whose foreign keys cascade to everything else core owns. +""" + +from __future__ import annotations + +import datetime as dt +import shutil +import uuid +from pathlib import Path + +from sqlalchemy import func, or_, select, text +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.sessions import destroy_all_sessions +from app.models import Account, Message, User +from app.plugins.db import plugin_session +from app.search.factory import get_search_backend + +SEARCH_LIMIT = 25 + + +async def search_users(db: AsyncSession, q: str) -> list[tuple[User, int, list[str]]]: + """Users whose login, or one of whose mail accounts' address or display name, + contains ``q`` (case-insensitive). An empty ``q`` lists the first users by + login. Each row comes with its storage used and its mail account addresses.""" + stmt = select(User).order_by(User.handle).limit(SEARCH_LIMIT) + q = q.strip() + if q: + # Typed % and _ are literal characters, not wildcards. + escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + like = f"%{escaped}%" + by_account = select(Account.user_id).where( + or_(Account.email.ilike(like), Account.display_name.ilike(like)) + ) + stmt = stmt.where(or_(User.handle.ilike(like), User.id.in_(by_account))) + users = list(await db.scalars(stmt)) + if not users: + return [] + ids = [u.id for u in users] + used: dict[uuid.UUID, int] = { + user_id: int(total) + for user_id, total in await db.execute( + select(Message.user_id, func.coalesce(func.sum(Message.size_bytes), 0)) + .where(Message.user_id.in_(ids)) + .group_by(Message.user_id) + ) + } + emails: dict[uuid.UUID, list[str]] = {} + for user_id, email in await db.execute( + select(Account.user_id, Account.email) + .where(Account.user_id.in_(ids)) + .order_by(Account.email) + ): + emails.setdefault(user_id, []).append(email) + return [(u, int(used.get(u.id, 0)), emails.get(u.id, [])) for u in users] + + +async def set_blocked(db: AsyncSession, user: User, blocked: bool) -> None: + """Block or unblock. Blocking also signs the user out everywhere.""" + if blocked and user.blocked_at is None: + user.blocked_at = dt.datetime.now(dt.UTC) + await db.flush() + await _sign_out_everywhere(user.id) + elif not blocked: + user.blocked_at = None + + +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 get_event_bus().publish("user.signed_out", {"user_id": str(user_id)}) + + +async def _plugin_tables(db: AsyncSession) -> list[str]: + """Every plugin table holding per-user rows (``plg_*`` with a ``user_id``).""" + rows = await db.execute( + text( + "SELECT table_name FROM information_schema.columns " + "WHERE table_schema = current_schema() AND column_name = 'user_id' " + "AND table_name LIKE 'plg\\_%' ORDER BY table_name" + ) + ) + return [r[0] for r in rows] + + +async def delete_user(db: AsyncSession, user: User) -> None: + """Delete ``user`` and everything they own. Commits.""" + user_id = user.id + + # Mail accounts first, one by one: this is what stops their sync runners and + # tidies threads, exactly as when the user removes an account themselves. + for account_id in list(await db.scalars(select(Account.id).where(Account.user_id == user_id))): + await delete_account(db, user_id=user_id, account_id=account_id) + await db.commit() + + # Plugin tables carry no foreign key to users and sit behind row-level + # security, so they are cleared from inside the user's own RLS context. + tables = await _plugin_tables(db) + if tables: + async with plugin_session(user_id) as ps: + for table in tables: + await ps.execute( + text(f'DELETE FROM "{table}" WHERE user_id = :uid'), {"uid": user_id} + ) + + await _sign_out_everywhere(user_id) + await get_search_backend().delete_user(user_id) + + # The user row: its foreign keys cascade to the rest of core's tables. + await db.delete(user) + await db.commit() + + # Whatever blobs are left (drafts' attachments and the like) go with the folder. + shutil.rmtree(Path(get_settings().blob_root) / str(user_id), ignore_errors=True) diff --git a/backend/app/models.py b/backend/app/models.py index 96fe85d..8114973 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -69,6 +69,19 @@ class AppSettings(Base): default_quota_bytes: Mapped[int] = mapped_column( BigInteger, nullable=False, server_default=text("5368709120") ) + # Outgoing system mail (password-reset links): the SMTP account the server + # sends from. The password is vault-encrypted (like Account.secret_ct). + smtp_host: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + smtp_port: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("587")) + smtp_security: Mapped[str] = mapped_column( + Text, nullable=False, server_default=text("'starttls'") + ) # starttls | ssl | none + smtp_username: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + smtp_secret_ct: Mapped[bytes | None] = mapped_column(BYTEA) + smtp_from: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + # Where users reach the app, e.g. https://mail.example.com — reset links are + # built from this, never from the request's Host header. + public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) updated_at: Mapped[dt.datetime] = _now() @@ -91,6 +104,9 @@ class User(Base): # The first account created on a fresh install bootstraps as administrator; # the admin panel can grant it to others later. is_admin: Mapped[bool] = mapped_column(nullable=False, server_default=text("false")) + # Set while an administrator has blocked the account (no sign-in, sessions + # refused); NULL when not blocked. + blocked_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True)) created_at: Mapped[dt.datetime] = _now() diff --git a/backend/app/ws.py b/backend/app/ws.py index 85e0e42..c0d9478 100644 --- a/backend/app/ws.py +++ b/backend/app/ws.py @@ -55,6 +55,10 @@ async def ws_endpoint(websocket: WebSocket) -> None: payload = data.get("payload", {}) if str(payload.get("user_id")) == user_id: await websocket.send_json(data) + if data.get("event") == "user.signed_out": + # The session this socket was opened with no longer exists. + await websocket.close(code=1008) + return async def receive() -> None: while True: diff --git a/backend/migrations/versions/0019_user_blocked.py b/backend/migrations/versions/0019_user_blocked.py new file mode 100644 index 0000000..821b4c4 --- /dev/null +++ b/backend/migrations/versions/0019_user_blocked.py @@ -0,0 +1,29 @@ +"""administrator can block a user + +``users.blocked_at`` is set while an administrator has blocked the account: it +cannot sign in and its sessions stop working. NULL means not blocked. A +timestamp rather than a flag so the admin panel can say since when. + +Revision ID: 0019 +Revises: 0018 +Create Date: 2026-09-30 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0019" +down_revision: str | None = "0018" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE users ADD COLUMN blocked_at timestamptz") + + +def downgrade() -> None: + op.execute("ALTER TABLE users DROP COLUMN IF EXISTS blocked_at") diff --git a/backend/migrations/versions/0020_system_mail.py b/backend/migrations/versions/0020_system_mail.py new file mode 100644 index 0000000..c602450 --- /dev/null +++ b/backend/migrations/versions/0020_system_mail.py @@ -0,0 +1,48 @@ +"""outgoing system mail (password reset) configured in the admin panel + +The server sends its own mail — today only password-reset links — through an +SMTP account the administrator sets up in the admin panel. The password is +envelope-encrypted with MASTER_KEY like every mail-account secret. ``public_url`` +is where users reach the app; reset links are built from it, never from the +request's Host header, which an attacker controls. + +Revision ID: 0020 +Revises: 0019 +Create Date: 2026-09-30 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0020" +down_revision: str | None = "0019" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_COLUMNS = ( + ("smtp_host", "text NOT NULL DEFAULT ''"), + ("smtp_port", "integer NOT NULL DEFAULT 587"), + ("smtp_security", "text NOT NULL DEFAULT 'starttls'"), + ("smtp_username", "text NOT NULL DEFAULT ''"), + ("smtp_secret_ct", "bytea"), + ("smtp_from", "text NOT NULL DEFAULT ''"), + ("public_url", "text NOT NULL DEFAULT ''"), +) + + +def upgrade() -> None: + for name, ddl in _COLUMNS: + op.execute(f"ALTER TABLE app_settings ADD COLUMN {name} {ddl}") + op.execute( + "ALTER TABLE app_settings ADD CONSTRAINT ck_app_settings_smtp_security " + "CHECK (smtp_security IN ('starttls', 'ssl', 'none'))" + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE app_settings DROP CONSTRAINT IF EXISTS ck_app_settings_smtp_security") + for name, _ in reversed(_COLUMNS): + op.execute(f"ALTER TABLE app_settings DROP COLUMN IF EXISTS {name}") diff --git a/backend/tests/api/test_admin_users.py b/backend/tests/api/test_admin_users.py new file mode 100644 index 0000000..dcfafa4 --- /dev/null +++ b/backend/tests/api/test_admin_users.py @@ -0,0 +1,196 @@ +"""Admin user management (SPEC §6.1): search, quota, block, delete. + +Runs against the dev database with real sessions, so every user it creates is +removed again on the way out. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator + +import httpx +import pytest +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, select, text +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 user_admin +from app.identity.passwords import hash_password +from app.identity.sessions import create_session, resolve_session +from app.main import create_app +from app.models import Account, PluginKv, User +from app.plugins.db import plugin_session + +CSRF = "csrf-token" +PASSWORD = "hunter2pass" + + +def _handle() -> str: + return f"adm{uuid.uuid4().hex[:8]}@test.local" + + +class World: + def __init__(self, session: AsyncSession) -> None: + self.session = session + self.created: list[uuid.UUID] = [] + + async def user(self, *, admin: bool = False) -> User: + user = User(handle=_handle(), password_hash=hash_password(PASSWORD), is_admin=admin) + self.session.add(user) + await self.session.commit() + self.created.append(user.id) + return user + + async def client(self, user: User) -> httpx.AsyncClient: + token = await create_session(user.id, "pytest") + return httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + headers={"X-CSRF": CSRF}, + ) + + +@pytest_asyncio.fixture +async def world() -> AsyncIterator[World]: + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + session = async_sessionmaker(engine, expire_on_commit=False)() + w = World(session) + try: + yield w + finally: + await session.rollback() + await session.execute(delete(User).where(User.id.in_(w.created))) + await session.commit() + await session.close() + await engine.dispose() + + +def _account(user_id: uuid.UUID, email: str, name: str = "") -> Account: + return Account( + user_id=user_id, + email=email, + display_name=name, + imap_config={}, + smtp_config={}, + secret_ct=b"x", + discovery_src="manual", + state="live", + ) + + +async def test_only_an_admin_can_manage_users(world: World) -> None: + plain = await world.user() + async with await world.client(plain) as c: + assert (await c.get("/api/admin/users")).status_code == 403 + + +async def test_search_by_login_and_by_mail_account(world: World) -> None: + admin = await world.user(admin=True) + target = await world.user() + tag = uuid.uuid4().hex[:10] + world.session.add(_account(target.id, f"x{tag}@example.com", f"Zed {tag}")) + await world.session.commit() + async with await world.client(admin) as c: + by_login = (await c.get("/api/admin/users", params={"q": target.handle[:11]})).json() + assert [u["id"] for u in by_login] == [str(target.id)] + by_email = (await c.get("/api/admin/users", params={"q": f"x{tag}@"})).json() + assert [u["id"] for u in by_email] == [str(target.id)] + assert by_email[0]["accounts"] == [f"x{tag}@example.com"] + by_name = (await c.get("/api/admin/users", params={"q": f"zed {tag}"})).json() + assert [u["id"] for u in by_name] == [str(target.id)] + # A typed % is a character, not "match anything". + assert (await c.get("/api/admin/users", params={"q": f"{tag}%"})).json() == [] + + +async def test_change_quota(world: World) -> None: + admin = await world.user(admin=True) + target = await world.user() + async with await world.client(admin) as c: + resp = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 123456789}) + assert resp.status_code == 200 + assert resp.json()["quota_bytes"] == 123456789 + bad = await c.patch(f"/api/admin/users/{target.id}", json={"quota_bytes": 0}) + assert bad.status_code == 422 + + +class _Bus: + def __init__(self) -> None: + self.published: list[tuple[str, dict[str, object]]] = [] + + async def publish(self, event: str, payload: dict[str, object]) -> None: + self.published.append((event, payload)) + + +@pytest.fixture +def bus(monkeypatch: pytest.MonkeyPatch) -> _Bus: + fake = _Bus() + monkeypatch.setattr(user_admin, "get_event_bus", lambda: fake) + return fake + + +async def test_block_refuses_login_and_ends_sessions_then_unblock(world: World, bus: _Bus) -> None: + admin = await world.user(admin=True) + target = await world.user() + token = await create_session(target.id, "pytest") + async with await world.client(admin) as c: + resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": True}) + assert resp.status_code == 200 and resp.json()["blocked_at"] + # The session the user held is gone, and their open tabs are told to leave. + assert await resolve_session(token) is None + assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published + anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t") + async with anon: + # ...and they cannot sign back in, told why only after a correct password. + login = {"handle": target.handle, "password": PASSWORD} + blocked = await anon.post("/api/auth/login", json=login) + assert blocked.status_code == 403 + assert blocked.json()["error"]["code"] == "account_blocked" + wrong = await anon.post("/api/auth/login", json={**login, "password": "nope-nope"}) + assert wrong.json()["error"]["code"] == "invalid_credentials" + + async with await world.client(admin) as c: + resp = await c.patch(f"/api/admin/users/{target.id}", json={"blocked": False}) + assert resp.json()["blocked_at"] is None + assert (await anon.post("/api/auth/login", json=login)).status_code == 200 + + +async def test_admin_cannot_block_or_delete_themselves(world: World) -> None: + admin = await world.user(admin=True) + async with await world.client(admin) as c: + assert ( + await c.patch(f"/api/admin/users/{admin.id}", json={"blocked": True}) + ).status_code == 409 + assert (await c.delete(f"/api/admin/users/{admin.id}")).status_code == 409 + + +async def test_delete_removes_the_user_and_their_data(world: World, bus: _Bus) -> None: + admin = await world.user(admin=True) + target = await world.user() + world.session.add(_account(target.id, f"d{uuid.uuid4().hex[:8]}@example.com")) + world.session.add(PluginKv(plugin="labels", user_id=target.id, key="k", value={"a": 1})) + await world.session.commit() + # A plugin row: no foreign key to users, behind row-level security. + async with plugin_session(target.id) as ps: + await ps.execute( + text("INSERT INTO plg_contacts_books (user_id, name) VALUES (:u, 'Book')"), + {"u": target.id}, + ) + token = await create_session(target.id, "pytest") + + async with await world.client(admin) as c: + assert (await c.delete(f"/api/admin/users/{target.id}")).status_code == 200 + + s = world.session + assert await s.scalar(select(User).where(User.id == target.id)) is None + assert await s.scalar(select(Account).where(Account.user_id == target.id)) is None + assert await s.scalar(select(PluginKv).where(PluginKv.user_id == target.id)) is None + async with plugin_session(target.id) as ps: + left = await ps.scalar(text("SELECT count(*) FROM plg_contacts_books")) + assert left == 0 + assert await resolve_session(token) is None + assert ("user.signed_out", {"user_id": str(target.id)}) in bus.published diff --git a/backend/tests/identity/test_identity_m1.py b/backend/tests/identity/test_identity_m1.py index ba83b42..8327a00 100644 --- a/backend/tests/identity/test_identity_m1.py +++ b/backend/tests/identity/test_identity_m1.py @@ -1,4 +1,5 @@ -"""M1 identity: 2FA (TOTP), session list/revoke, password reset (SPEC §6.1/§9.1).""" +"""M1 identity: 2FA (TOTP), session list/revoke (SPEC §6.1/§9.1). The password reset +flow is in test_password_reset.py.""" from __future__ import annotations @@ -19,7 +20,6 @@ from app.identity.passwords import hash_password from app.identity.sessions import create_session from app.main import create_app from app.models import User -from app.redis_client import get_redis CSRF = "csrf-token" PASSWORD = "hunter2pass" @@ -145,43 +145,6 @@ async def test_sessions_list_and_revoke(ctx: Ctx) -> None: assert me.status_code == 401 -async def test_password_reset_flow(ctx: Ctx) -> None: - # request a reset; token is stored in redis (not returned) - req = await ctx.client.post("/api/auth/password/reset-request", json={"handle": ctx.handle}) - assert req.status_code == 200 - - redis = get_redis() - keys = await redis.keys("pwreset:*") - # Pick the token bound to THIS user (redis DB may hold others from prior runs). - token = None - for key in keys: - if await redis.get(key) == str(ctx.user_id): - token = key.split(":", 1)[1] - break - assert token is not None, "a reset token should have been issued for this user" - - new_password = "brandNewPass9" - reset = await ctx.client.post( - "/api/auth/password/reset", json={"token": token, "password": new_password} - ) - assert reset.status_code == 200 - - # token is single-use - again = await ctx.client.post( - "/api/auth/password/reset", json={"token": token, "password": new_password} - ) - assert again.status_code == 400 - - # new password works, old one does not - async with _fresh_client(ctx.app) as anon: - good = await anon.post( - "/api/auth/login", json={"handle": ctx.handle, "password": new_password} - ) - assert good.status_code == 200 - bad = await anon.post("/api/auth/login", json={"handle": ctx.handle, "password": PASSWORD}) - assert bad.status_code == 401 - - async def test_reset_request_unknown_handle_still_ok(ctx: Ctx) -> None: # must not leak whether a handle exists resp = await ctx.client.post("/api/auth/password/reset-request", json={"handle": "nobody-here"}) diff --git a/backend/tests/identity/test_password_reset.py b/backend/tests/identity/test_password_reset.py new file mode 100644 index 0000000..ded426a --- /dev/null +++ b/backend/tests/identity/test_password_reset.py @@ -0,0 +1,309 @@ +"""Password reset by email, recovery address, and the system mailer (SPEC §6.1). + +The SMTP send is replaced with a recorder, so nothing leaves the machine; the +reset link it would have sent is read back from what was recorded. +""" + +from __future__ import annotations + +import uuid +from collections.abc import AsyncIterator +from dataclasses import dataclass, field + +import httpx +import pyotp +import pytest +import pytest_asyncio +from httpx import ASGITransport +from sqlalchemy import delete, update +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.api import admin as admin_api +from app.config import get_settings +from app.identity import routes +from app.identity.mailer import MailerConfig +from app.identity.passwords import hash_password +from app.identity.reset import MAX_TOTP_FAILURES +from app.identity.service import app_settings +from app.identity.sessions import create_session +from app.identity.totp import encrypt_totp_secret +from app.main import create_app +from app.models import AppSettings, User +from app.redis_client import get_redis + +CSRF = "csrf-token" +PASSWORD = "hunter2pass" +NEW_PASSWORD = "brandNewPass9" +CFG = MailerConfig( + host="smtp.example.com", + port=587, + security="starttls", + username="postmaster@example.com", + password="pw", + sender="postmaster@example.com", + public_url="https://mail.example.com", +) + + +@dataclass +class Sent: + mails: list[tuple[str, str, str]] = field(default_factory=list) # (to, subject, text) + + def token(self) -> str: + text = self.mails[-1][2] + link = next(w for w in text.split() if w.startswith("https://mail.example.com/")) + assert link.startswith("https://mail.example.com/reset-password#token=") + return link.split("#token=", 1)[1] + + +@dataclass +class Ctx: + session: AsyncSession + anon: httpx.AsyncClient + sent: Sent + created: list[uuid.UUID] = field(default_factory=list) + + async def user( + self, *, recovery: str | None = "me@elsewhere.example", admin: bool = False + ) -> User: + user = User( + handle=f"pr{uuid.uuid4().hex[:8]}@test.local", + password_hash=hash_password(PASSWORD), + recovery_email=recovery, + is_admin=admin, + ) + self.session.add(user) + await self.session.commit() + self.created.append(user.id) + await get_redis().delete(f"pwreset-rate:{user.id}") + return user + + async def signed_in(self, user: User) -> httpx.AsyncClient: + token = await create_session(user.id, "pytest") + return httpx.AsyncClient( + transport=ASGITransport(app=create_app()), + base_url="http://testserver", + cookies={"session": token, "csrf": CSRF}, + headers={"X-CSRF": CSRF}, + ) + + +@pytest_asyncio.fixture +async def ctx(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Ctx]: + sent = Sent() + + async def fake_send(cfg: MailerConfig, to: str, subject: str, text: str, html: str) -> None: + sent.mails.append((to, subject, text)) + + monkeypatch.setattr(routes, "send_mail", fake_send) + monkeypatch.setattr(admin_api, "send_mail", fake_send) + monkeypatch.setattr(routes, "mailer_config", lambda row: CFG) + + engine = create_async_engine(get_settings().database_url, poolclass=NullPool) + session = async_sessionmaker(engine, expire_on_commit=False)() + anon = httpx.AsyncClient(transport=ASGITransport(app=create_app()), base_url="http://t") + c = Ctx(session=session, anon=anon, sent=sent) + try: + yield c + finally: + await anon.aclose() + await session.execute(delete(User).where(User.id.in_(c.created))) + await session.commit() + await session.close() + await engine.dispose() + + +async def _request(ctx: Ctx, handle: str) -> httpx.Response: + return await ctx.anon.post("/api/auth/password/reset-request", json={"handle": handle}) + + +async def _login(ctx: Ctx, handle: str, password: str) -> int: + resp = await ctx.anon.post("/api/auth/login", json={"handle": handle, "password": password}) + return resp.status_code + + +# --- requesting a link ----------------------------------------------------------- + + +async def test_request_mails_a_one_time_link_to_the_recovery_address(ctx: Ctx) -> None: + user = await ctx.user() + assert (await _request(ctx, user.handle)).status_code == 200 + assert [m[0] for m in ctx.sent.mails] == ["me@elsewhere.example"] + token = ctx.sent.token() + + check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token}) + assert check.json() == {"valid": True, "totp_required": False} + + reset = await ctx.anon.post( + "/api/auth/password/reset", json={"token": token, "password": NEW_PASSWORD} + ) + assert reset.status_code == 200 + # Used up: a second go fails, and the page is told so up front. + again = await ctx.anon.post( + "/api/auth/password/reset", json={"token": token, "password": "anotherPass12"} + ) + assert again.json()["error"]["code"] == "invalid_token" + check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token}) + assert check.json()["valid"] is False + + assert await _login(ctx, user.handle, NEW_PASSWORD) == 200 + assert await _login(ctx, user.handle, PASSWORD) == 401 + + +async def test_the_answer_never_says_whether_the_account_exists(ctx: Ctx) -> None: + no_recovery = await ctx.user(recovery=None) + for handle in ("nobody-here-at-all", no_recovery.handle): + resp = await _request(ctx, handle) + assert resp.status_code == 200 and resp.json() == {"status": "ok"} + assert ctx.sent.mails == [] + + +async def test_no_mail_while_system_mail_is_not_set_up( + ctx: Ctx, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(routes, "mailer_config", lambda row: None) + user = await ctx.user() + assert (await _request(ctx, user.handle)).status_code == 200 + assert ctx.sent.mails == [] + + +async def test_requests_are_capped_per_hour(ctx: Ctx) -> None: + user = await ctx.user() + for _ in range(5): + assert (await _request(ctx, user.handle)).status_code == 200 + assert len(ctx.sent.mails) == 3 + + +# --- two-step verification --------------------------------------------------------- + + +async def test_2fa_code_is_required_before_the_password_changes(ctx: Ctx) -> None: + user = await ctx.user() + secret = pyotp.random_base32() + await ctx.session.execute( + update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret)) + ) + await ctx.session.commit() + await _request(ctx, user.handle) + token = ctx.sent.token() + body = {"token": token, "password": NEW_PASSWORD} + + check = await ctx.anon.post("/api/auth/password/reset/check", json={"token": token}) + assert check.json()["totp_required"] is True + missing = await ctx.anon.post("/api/auth/password/reset", json=body) + assert missing.json()["error"]["code"] == "totp_required" + wrong = await ctx.anon.post("/api/auth/password/reset", json={**body, "totp": "000000"}) + assert wrong.json()["error"]["code"] == "totp_invalid" + # Nothing changed yet, and the link still works. + assert await _login(ctx, user.handle, PASSWORD) == 401 # 2FA account: needs a code + good = await ctx.anon.post( + "/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()} + ) + assert good.status_code == 200 + login = await ctx.anon.post( + "/api/auth/login", + json={"handle": user.handle, "password": NEW_PASSWORD, "totp": pyotp.TOTP(secret).now()}, + ) + assert login.status_code == 200 + + +async def test_too_many_wrong_codes_burn_the_link(ctx: Ctx) -> None: + user = await ctx.user() + secret = pyotp.random_base32() + await ctx.session.execute( + update(User).where(User.id == user.id).values(totp_secret=encrypt_totp_secret(secret)) + ) + await ctx.session.commit() + await _request(ctx, user.handle) + token = ctx.sent.token() + body = {"token": token, "password": NEW_PASSWORD, "totp": "000000"} + for _ in range(MAX_TOTP_FAILURES): + await ctx.anon.post("/api/auth/password/reset", json=body) + late = await ctx.anon.post( + "/api/auth/password/reset", json={**body, "totp": pyotp.TOTP(secret).now()} + ) + assert late.json()["error"]["code"] == "invalid_token" + + +# --- the recovery address ---------------------------------------------------------- + + +async def test_changing_the_recovery_email_takes_the_password(ctx: Ctx) -> None: + user = await ctx.user(recovery=None) + async with await ctx.signed_in(user) as c: + wrong = await c.put( + "/api/auth/recovery-email", + json={"password": "not-it", "recovery_email": "new@example.com"}, + ) + assert wrong.status_code == 403 + bad = await c.put( + "/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": "nope"} + ) + assert bad.json()["error"]["code"] == "invalid_email" + ok = await c.put( + "/api/auth/recovery-email", + json={"password": PASSWORD, "recovery_email": " New@Example.com "}, + ) + assert ok.json()["recovery_email"] == "new@example.com" + removed = await c.put( + "/api/auth/recovery-email", json={"password": PASSWORD, "recovery_email": ""} + ) + assert removed.json()["recovery_email"] is None + + +# --- admin: system mail settings ----------------------------------------------------- + + +@pytest_asyncio.fixture +async def restore_settings(ctx: Ctx) -> AsyncIterator[None]: + row = await app_settings(ctx.session) + saved = { + k: getattr(row, k) + for k in ( + "smtp_host", + "smtp_port", + "smtp_security", + "smtp_username", + "smtp_secret_ct", + "smtp_from", + "public_url", + ) + } + await ctx.session.commit() + yield + await ctx.session.execute(update(AppSettings).values(**saved)) + await ctx.session.commit() + + +async def test_admin_configures_system_mail_without_ever_seeing_the_password( + ctx: Ctx, restore_settings: None +) -> None: + admin = await ctx.user(admin=True) + async with await ctx.signed_in(admin) as c: + resp = await c.put( + "/api/admin/mail", + json={ + "smtp_host": "smtp.example.com", + "smtp_port": 465, + "smtp_security": "ssl", + "smtp_username": "postmaster@example.com", + "smtp_password": "s3cret", + "smtp_from": "postmaster@example.com", + "public_url": "https://mail.example.com/", + }, + ) + out = resp.json() + assert out["has_password"] is True and out["configured"] is True + assert out["public_url"] == "https://mail.example.com" + assert "s3cret" not in resp.text + # Omitting the password keeps it; an empty one removes it. + kept = (await c.put("/api/admin/mail", json={"smtp_port": 587})).json() + assert kept["has_password"] is True + gone = (await c.put("/api/admin/mail", json={"smtp_password": ""})).json() + assert gone["has_password"] is False + bad = await c.put("/api/admin/mail", json={"public_url": "mail.example.com"}) + assert bad.json()["error"]["code"] == "invalid_url" + 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") diff --git a/frontend/admin.html b/frontend/admin.html index f5e66f2..a6a2ab4 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -4,7 +4,7 @@ - Webmail admin + Ultimo Email admin
diff --git a/frontend/icons/ultimo-logo-bimi.svg b/frontend/icons/ultimo-logo-bimi.svg new file mode 100644 index 0000000..808c5cd --- /dev/null +++ b/frontend/icons/ultimo-logo-bimi.svg @@ -0,0 +1,14 @@ + +Ultimo Email +Ultimo Email logo, for BIMI (SVG Tiny Portable/Secure) + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index f40cb86..5d8022c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - Webmail + Ultimo Email
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4930104..7ac4235 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,9 +6,11 @@ import { useMe } from "./lib/auth"; import { connectWs } from "./lib/ws"; import { PluginPage } from "./plugins/PluginPage"; import { ComposeWindow } from "./routes/ComposeWindow"; +import { ForgotPassword } from "./routes/ForgotPassword"; import { Login } from "./routes/Login"; import { MailLayout } from "./routes/MailLayout"; import { Register } from "./routes/Register"; +import { ResetPassword } from "./routes/ResetPassword"; import { Settings } from "./routes/settings/Settings"; function RequireAuth({ children }: { children: JSX.Element }) { @@ -35,6 +37,8 @@ export function App() { } /> } /> + } /> + } />

{t("admin.title")}

-
+ {/* Two columns once there is room: instance settings and users on the + left, outgoing system mail on the right. Signed out, it stays narrow. */} +
{me.isPending &&

{t("common.loading")}

} {!me.isPending && !me.data && } {me.data && !me.data.is_admin && } - {me.data?.is_admin && } + {me.data?.is_admin && ( +
+
+ + +
+ {/* The divider runs the full height of the taller column. */} +
+ +
+
+ )}
); @@ -235,3 +249,487 @@ function Panel({ handle }: { handle: string }) { ); } + +type MailDraft = Omit; + +/** The SMTP account the server sends password-reset links from. */ +function SystemMail() { + const t = useT(); + const queryClient = useQueryClient(); + const mail = useQuery({ queryKey: ["adminMail"], queryFn: api.adminMail }); + // Edits are a draft until Save; null means "show what the server has". + const [draft, setDraft] = useState(null); + const [password, setPassword] = useState(""); + const [testTo, setTestTo] = useState(""); + + const save = useMutation({ + mutationFn: (body: Parameters[0]) => api.saveAdminMail(body), + onSuccess: (data) => { + queryClient.setQueryData(["adminMail"], data); + setDraft(null); + setPassword(""); + }, + }); + const test = useMutation({ mutationFn: () => api.testAdminMail(testTo.trim()) }); + + if (!mail.data) { + return mail.error instanceof ApiError ? ( +

{mail.error.message}

+ ) : null; + } + const server = mail.data; + const value: MailDraft = draft ?? { + smtp_host: server.smtp_host, + smtp_port: server.smtp_port, + smtp_security: server.smtp_security, + smtp_username: server.smtp_username, + smtp_from: server.smtp_from, + public_url: server.public_url, + }; + const set = (patch: Partial) => setDraft({ ...value, ...patch }); + const dirty = draft !== null || password !== ""; + const field = + "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none"; + const label = "block space-y-1 text-sm"; + + return ( +
+

{t("admin.mail")}

+

{t("admin.mailHelp")}

+

+ {server.configured ? t("admin.mailOn") : t("admin.mailOff")} +

+
{ + e.preventDefault(); + save.mutate({ ...value, ...(password ? { smtp_password: password } : {}) }); + }} + > +
+ + + +
+
+ + +
+ + +
+ {server.has_password ? ( + + ) : ( + + )} + +
+ {save.error instanceof ApiError &&

{save.error.message}

} +
+ +
{ + e.preventDefault(); + test.mutate(); + }} + > + {t("admin.testTo")} + { + setTestTo(e.target.value); + test.reset(); + }} + /> + +
+ {test.isSuccess &&

{t("admin.testSent")}

} + {test.error instanceof ApiError &&

{test.error.message}

} +
+ ); +} + +function formatBytes(bytes: number): string { + if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(2))} GiB`; + return `${Number((bytes / 1024 ** 2).toFixed(1))} MiB`; +} + +/** Find a user by login, mail address or name, and open them to manage. */ +function Users({ selfId }: { selfId: string }) { + const t = useT(); + const [term, setTerm] = useState(""); + const [query, setQuery] = useState(""); + const [openId, setOpenId] = useState(null); + + // Search as you type, but not on every keystroke. + useEffect(() => { + const id = window.setTimeout(() => setQuery(term.trim()), 250); + return () => window.clearTimeout(id); + }, [term]); + + // Only search results are listed: nothing until something is typed. + const users = useQuery({ + queryKey: ["adminUsers", query], + queryFn: () => api.adminUsers(query), + enabled: query !== "", + placeholderData: keepPreviousData, + }); + const results = query ? users.data : undefined; + const open = results?.find((u) => u.id === openId) ?? null; + + return ( +
+

{t("admin.users")}

+ setTerm(e.target.value)} + /> + {users.error instanceof ApiError && ( +

{users.error.message}

+ )} + {results && results.length === 0 && ( +

{t("admin.noUsers")}

+ )} + {results && results.length > 0 && ( +
    + {results.map((u) => ( +
  • + +
  • + ))} +
+ )} + {open && setOpenId(null)} />} +
+ ); +} + +function Badges({ user, self }: { user: AdminUser; self: boolean }) { + const t = useT(); + const badge = "shrink-0 rounded px-1.5 text-[11px] font-medium"; + return ( + <> + {self && {t("admin.you")}} + {user.is_admin && ( + {t("admin.adminBadge")} + )} + {user.blocked_at && ( + {t("admin.blockedBadge")} + )} + + ); +} + +/** One user: change their quota, block or unblock them, or delete them. */ +function UserDialog({ + user, + self, + onClose, +}: { + user: AdminUser; + self: boolean; + onClose: () => void; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const [quotaDraft, setQuotaDraft] = useState(null); + const [confirm, setConfirm] = useState(null); + + const refresh = () => queryClient.invalidateQueries({ queryKey: ["adminUsers"] }); + const update = useMutation({ + mutationFn: (body: { quota_bytes?: number; blocked?: boolean }) => + api.updateAdminUser(user.id, body), + onSuccess: () => { + setQuotaDraft(null); + setConfirm(null); + void refresh(); + }, + }); + const remove = useMutation({ + mutationFn: () => api.deleteAdminUser(user.id), + onSuccess: () => { + onClose(); + void refresh(); + }, + }); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (confirm) setConfirm(null); + else onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [confirm, onClose]); + + const serverGib = user.quota_bytes / GIB; + const quotaValue = quotaDraft ?? String(Number(serverGib.toFixed(3))); + const quotaGib = Number(quotaValue); + const quotaValid = Number.isFinite(quotaGib) && quotaGib > 0; + const quotaDirty = quotaDraft !== null && quotaGib !== serverGib; + const busy = update.isPending || remove.isPending; + const error = [update.error, remove.error].find((e) => e instanceof ApiError) as + | ApiError + | undefined; + + return ( +
+
e.stopPropagation()} + > +
+
+

+ {user.handle} + +

+

+ {user.accounts.length ? user.accounts.join(", ") : t("admin.noMailAccounts")} +

+
+ +
+ +
+
+ {t("admin.userQuota")} +
+ setQuotaDraft(e.target.value)} + /> + GiB + +
+
+

+ {t("admin.usage", { + used: formatBytes(user.used_bytes), + quota: formatBytes(user.quota_bytes), + })} +

+ {!quotaValid &&

{t("admin.quotaInvalid")}

} +
+ + {user.blocked_at && ( +

+ {t("admin.blockedSince", { date: new Date(user.blocked_at).toLocaleString() })} +

+ )} + {self ? ( +

{t("admin.selfNote")}

+ ) : ( +
+ {user.blocked_at ? ( + + ) : ( + + )} + +
+ )} + {error &&

{error.message}

} + + {confirm && ( +
setConfirm(null)} + > +
e.stopPropagation()} + > +

+ {t(confirm === "block" ? "admin.blockConfirm" : "admin.deleteConfirm", { + handle: user.handle, + })} +

+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/TypedPasswordInput.tsx b/frontend/src/components/TypedPasswordInput.tsx new file mode 100644 index 0000000..16b4f9c --- /dev/null +++ b/frontend/src/components/TypedPasswordInput.tsx @@ -0,0 +1,66 @@ +import { useId, useState } from "react"; + +// Keystrokes and IME composition; everything else that changes the field — a +// browser or password-manager autofill (an input event without an inputType, or +// "insertReplacementText"), a paste, a drop — is not the user typing. +const TYPED = /^(insertText|insertCompositionText|insertLineBreak|delete)/; + +/** + * A password field that only accepts what is typed into it: for confirmations + * where the point is that the person present knows the password, which an + * autofilled saved password does not prove. + * + * `autoComplete="new-password"` and the password managers' opt-out attributes + * keep most autofill away to begin with; whatever still gets in is thrown out + * here, and the field says to type it. + */ +export function TypedPasswordInput({ + value, + onChange, + className, + hint, +}: { + value: string; + onChange: (value: string) => void; + className?: string; + /** Shown when a fill or paste was refused. */ + hint: string; +}) { + const id = useId(); + const [refused, setRefused] = useState(false); + return ( + <> + { + e.preventDefault(); + setRefused(true); + }} + onDrop={(e) => { + e.preventDefault(); + setRefused(true); + }} + onChange={(e) => { + const inputType = (e.nativeEvent as InputEvent).inputType ?? ""; + if (!TYPED.test(inputType)) { + onChange(""); + setRefused(true); + return; + } + setRefused(false); + onChange(e.target.value); + }} + /> + {refused && {hint}} + + ); +} diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 02d6e9f..0f48d83 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -3,7 +3,7 @@ // Interpolate with {name} placeholders. export const en = { - "common.appName": "Webmail", + "common.appName": "Ultimo Email", "common.loading": "Loading…", "common.cancel": "Cancel", "common.save": "Save", @@ -34,6 +34,26 @@ export const en = { "auth.create": "Create account", "auth.creating": "Creating…", "auth.noAccount": "No account?", + "auth.forgotPassword": "Forgot password?", + "auth.forgotTitle": "Reset your password", + "auth.forgotHint": + "Enter your user name. We'll email a link for choosing a new password to the recovery email address of the account.", + "auth.changePassword": "Change password", + "auth.sending": "Sending…", + "auth.forgotSent": "If this account exists you will receive an email to your recovery email address.", + "auth.backToSignIn": "Back to sign in", + "auth.resetTitle": "Choose a new password", + "auth.newPassword": "New password", + "auth.repeatPassword": "Repeat the new password", + "auth.passwordsDiffer": "The two passwords are different.", + "auth.passwordTooShort": "Use at least 8 characters.", + "auth.resetTotpPrompt": + "This account uses 2-step verification. Enter the 6-digit code from your authenticator app to finish.", + "auth.confirm": "Confirm", + "auth.resetDone": "Your password has been changed. You have been signed out everywhere — sign in with the new password.", + "auth.resetInvalid": + "This link is invalid, was already used, or has expired. Request a new one to change your password.", + "auth.requestNewLink": "Request a new link", "auth.haveAccount": "Already have an account?", "auth.register": "Register", "auth.checking": "Checking…", @@ -327,6 +347,15 @@ export const en = { "settings.appearance": "Appearance", "settings.handle": "Handle", "settings.recoveryEmail": "Recovery email", + "settings.recoveryEmailHint": + "If you forget your password, a link to choose a new one is sent here.", + "settings.recoveryEmailNew": "Recovery email address", + "settings.recoveryEmailRemoveHint": "Leave empty to remove it.", + "settings.currentPassword": "Current password", + "settings.typePassword": "Type your password — for this confirmation it can't be filled in or pasted.", + "settings.change": "Change", + "settings.add": "Add", + "settings.saving": "Saving…", "settings.twoFactor": "Two-factor authentication", "settings.twoFactorOn": "2FA is enabled.", "settings.twoFactorOff": "2FA is not enabled.", @@ -412,7 +441,7 @@ export const en = { "settings.alsoFile": "Also mark the message as read and file it", "settings.integrations": "Integrations", - "admin.title": "Webmail administration", + "admin.title": "Ultimo Email administration", "admin.signInTitle": "Administrator sign-in", "admin.signInHint": "Sign in with an administrator account to manage this server.", "admin.signOut": "Sign out", @@ -432,6 +461,47 @@ export const en = { "admin.saving": "Saving…", "admin.saved": "Saved", "admin.quotaInvalid": "Enter a size greater than 0.", + "admin.mail": "Outgoing system mail", + "admin.mailHelp": + "The mail account the server sends password-reset links from. Without it, “Forgot password?” sends nothing.", + "admin.mailOn": "Set up — password-reset links can be sent.", + "admin.mailOff": "Not set up — password-reset links cannot be sent yet.", + "admin.smtpHost": "SMTP server", + "admin.smtpPort": "Port", + "admin.smtpSecurity": "Security", + "admin.smtpStarttls": "STARTTLS", + "admin.smtpSsl": "SSL/TLS", + "admin.smtpNone": "None", + "admin.smtpUsername": "Login", + "admin.smtpPassword": "Password", + "admin.smtpPasswordKept": "(stored — leave empty to keep)", + "admin.smtpPasswordRemove": "Remove the stored password", + "admin.smtpFrom": "From address", + "admin.publicUrl": "Public address of this server", + "admin.publicUrlHint": "Where users open Ultimo Email, e.g. https://mail.example.com — reset links point here.", + "admin.testTo": "Send a test email to", + "admin.sendTest": "Send test", + "admin.testSent": "Test email sent.", + "admin.users": "Users", + "admin.userSearch": "Search by login, email address or name…", + "admin.noUsers": "No users match.", + "admin.you": "you", + "admin.adminBadge": "admin", + "admin.blockedBadge": "blocked", + "admin.usage": "{used} of {quota}", + "admin.noMailAccounts": "No mail accounts connected", + "admin.userQuota": "Quota", + "admin.blockedSince": "Blocked since {date}. This user cannot sign in.", + "admin.block": "Block account", + "admin.unblock": "Unblock account", + "admin.blockConfirm": + "Block {handle}? They are signed out everywhere and cannot sign in until you unblock them. Their mail and data are kept.", + "admin.deleteUser": "Delete account", + "admin.deleteConfirm": + "Permanently delete {handle} and all of their mail, contacts, calendars and settings? This can't be undone.", + "admin.selfNote": "This is your own account: it cannot be blocked or deleted from here.", + "admin.cancel": "Cancel", + "admin.close": "Close", "settings.jitsi": "Jitsi Meet", "settings.jitsiHint": "Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index de3ddc0..0c36fe1 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -3,7 +3,9 @@ import type { AccountDetail, + AdminMail, AdminSettings, + AdminUser, AccountOut, AttachmentOut, CalendarOut, @@ -83,6 +85,38 @@ export const api = { // PUT merges: send only the fields you are changing. saveAdminSettings: (body: Partial) => apiFetch("/admin/settings", { method: "PUT", body: JSON.stringify(body) }), + adminUsers: (q: string) => apiFetch(`/admin/users?q=${encodeURIComponent(q)}`), + // PATCH merges: send only the fields you are changing. + updateAdminUser: (id: string, body: { quota_bytes?: number; blocked?: boolean }) => + apiFetch(`/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(body) }), + deleteAdminUser: (id: string) => + apiFetch<{ status: string }>(`/admin/users/${id}`, { method: "DELETE" }), + adminMail: () => apiFetch("/admin/mail"), + // PUT merges: omit smtp_password to keep the stored one, "" to remove it. + saveAdminMail: (body: Partial> & { smtp_password?: string }) => + apiFetch("/admin/mail", { method: "PUT", body: JSON.stringify(body) }), + testAdminMail: (to: string) => + apiFetch<{ status: string }>("/admin/mail/test", { method: "POST", body: JSON.stringify({ to }) }), + passwordResetRequest: (handle: string) => + apiFetch<{ status: string }>("/auth/password/reset-request", { + method: "POST", + body: JSON.stringify({ handle }), + }), + passwordResetCheck: (token: string) => + apiFetch<{ valid: boolean; totp_required: boolean }>("/auth/password/reset/check", { + method: "POST", + body: JSON.stringify({ token }), + }), + passwordReset: (token: string, password: string, totp?: string) => + apiFetch<{ status: string }>("/auth/password/reset", { + method: "POST", + body: JSON.stringify({ token, password, totp }), + }), + setRecoveryEmail: (password: string, recovery_email: string | null) => + apiFetch("/auth/recovery-email", { + method: "PUT", + body: JSON.stringify({ password, recovery_email }), + }), registrationStatus: () => apiFetch("/auth/registration"), register: (localpart: string, password: string) => apiFetch("/auth/register", { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index a7fb1f8..10488d9 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -1,4 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; +import { type QueryClient, useQuery } from "@tanstack/react-query"; import { api, ApiError } from "./api"; @@ -13,3 +13,21 @@ export function useMe() { }, }); } + +/** + * The server no longer knows this session — signed out elsewhere, or an + * administrator blocked or deleted the account. Drop everything cached and let + * the auth guard send the tab to the sign-in screen. + */ +export function signedOut(queryClient: QueryClient) { + if (!queryClient.getQueryData(["me"])) return; // already signed out + // "me" first, in place: its watchers see it and route to sign-in. Clearing the + // whole cache first would orphan them from the query they watch. + queryClient.setQueryData(["me"], null); + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); +} + +/** Whether an error means "this session is gone" (vs. a wrong password, etc.). */ +export function isSessionGone(error: unknown): boolean { + return error instanceof ApiError && error.status === 401 && error.code === "not_authenticated"; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index c3de467..d719fb6 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -15,6 +15,31 @@ export interface UserOut { is_admin: boolean; } +export interface AdminUser { + id: string; + handle: string; + is_admin: boolean; + /** ISO timestamp while an administrator has blocked the account. */ + blocked_at: string | null; + quota_bytes: number; + used_bytes: number; + created_at: string; + /** The user's connected mail accounts. */ + accounts: string[]; +} + +/** Outgoing system mail (password-reset links). The password is never sent back. */ +export interface AdminMail { + smtp_host: string; + smtp_port: number; + smtp_security: "starttls" | "ssl" | "none"; + smtp_username: string; + has_password: boolean; + smtp_from: string; + public_url: string; + configured: boolean; +} + export interface AdminSettings { registration_open: boolean; /** Quota given to users created from now on; existing mailboxes keep theirs. */ diff --git a/frontend/src/lib/ws.ts b/frontend/src/lib/ws.ts index 18fc1f5..28de199 100644 --- a/frontend/src/lib/ws.ts +++ b/frontend/src/lib/ws.ts @@ -4,6 +4,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { z } from "zod"; +import { signedOut } from "./auth"; + const frameSchema = z.object({ event: z.string(), payload: z.record(z.unknown()), @@ -20,6 +22,7 @@ const ALLOWED_EVENTS = new Set([ "account.state_changed", "draft.saved", "outbox.state_changed", + "user.signed_out", ]); // Lightweight in-process fan-out of WS frames to plugin subscribers (SPEC §11.3). @@ -82,6 +85,12 @@ export function connectWs(queryClient: QueryClient): () => void { } } if (!isCore) return; + if (name === "user.signed_out") { + // An administrator blocked or deleted this account: leave now, rather + // than keep showing mail that was already loaded. + signedOut(queryClient); + return; + } // Coarse invalidation: refetch affected lists (SPEC §9.2 contract). if (name === "sync.progress" || name.startsWith("message.") || name === "thread.updated") { queryClient.invalidateQueries({ queryKey: ["threads"] }); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 33f080c..6a3537a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,15 +1,24 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import { App } from "./App"; import { I18nProvider } from "./i18n"; +import { isSessionGone, signedOut } from "./lib/auth"; import { ThemeProvider } from "./lib/theme"; import "./index.css"; import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3) +// Any request answered "not signed in" means the session is gone (signed out +// elsewhere, or the account was blocked): go to sign-in instead of leaving the +// page up with failing requests behind it. +const onError = (error: unknown) => { + if (isSessionGone(error)) signedOut(queryClient); +}; const queryClient = new QueryClient({ + queryCache: new QueryCache({ onError }), + mutationCache: new MutationCache({ onError }), defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, }); diff --git a/frontend/src/routes/ForgotPassword.tsx b/frontend/src/routes/ForgotPassword.tsx new file mode 100644 index 0000000..5df76a3 --- /dev/null +++ b/frontend/src/routes/ForgotPassword.tsx @@ -0,0 +1,61 @@ +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { Link } from "react-router-dom"; + +import { useT } from "../i18n"; +import { api, ApiError } from "../lib/api"; + +const inputClass = + "w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"; + +// "Forgot password?": ask for the user name and have a reset link mailed to the +// account's recovery address. The answer is the same whether or not the account +// exists — the page never tells a stranger which user names are real. +export function ForgotPassword() { + const t = useT(); + const [handle, setHandle] = useState(""); + const request = useMutation({ mutationFn: () => api.passwordResetRequest(handle.trim()) }); + + return ( +
+

{t("auth.forgotTitle")}

+ {request.isSuccess ? ( +

{t("auth.forgotSent")}

+ ) : ( + <> +

{t("auth.forgotHint")}

+
{ + e.preventDefault(); + if (handle.trim()) request.mutate(); + }} + > + setHandle(e.target.value)} + autoFocus + /> + + {request.error instanceof ApiError && ( +

{request.error.message}

+ )} +
+ + )} +

+ + {t("auth.backToSignIn")} + +

+
+ ); +} diff --git a/frontend/src/routes/Login.tsx b/frontend/src/routes/Login.tsx index c7faaba..d315a87 100644 --- a/frontend/src/routes/Login.tsx +++ b/frontend/src/routes/Login.tsx @@ -82,6 +82,11 @@ export function Login() { {showError &&

{error.message}

} +

+ + {t("auth.forgotPassword")} + +

{registration.data?.open && (

{t("auth.noAccount")}{" "} diff --git a/frontend/src/routes/ResetPassword.tsx b/frontend/src/routes/ResetPassword.tsx new file mode 100644 index 0000000..bd367d3 --- /dev/null +++ b/frontend/src/routes/ResetPassword.tsx @@ -0,0 +1,130 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { Link } from "react-router-dom"; + +import { useT } from "../i18n"; +import { api, ApiError } from "../lib/api"; + +const inputClass = + "w-full rounded-lg border border-app-border bg-app-surface px-3 py-2 text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"; + +// The page a reset link opens. The token rides in the URL fragment (#token=…), +// which the browser never sends to a server. With 2-step verification on, the +// new password is only set once the code has been entered too. +export function ResetPassword() { + const t = useT(); + const [token] = useState(() => new URLSearchParams(window.location.hash.slice(1)).get("token") ?? ""); + const [password, setPassword] = useState(""); + const [repeat, setRepeat] = useState(""); + const [totp, setTotp] = useState(""); + const [needTotp, setNeedTotp] = useState(false); + const [problem, setProblem] = useState(""); + + const check = useQuery({ + queryKey: ["reset-check", token], + queryFn: () => api.passwordResetCheck(token), + enabled: token !== "", + }); + const reset = useMutation({ + mutationFn: () => api.passwordReset(token, password, needTotp ? totp.trim() : undefined), + onError: (error) => { + if (error instanceof ApiError && (error.code === "totp_required" || error.code === "totp_invalid")) { + setNeedTotp(true); + } + }, + }); + + const invalid = + token === "" || + check.data?.valid === false || + (reset.error instanceof ApiError && reset.error.code === "invalid_token"); + + const submit = () => { + setProblem(""); + if (password.length < 8) return setProblem(t("auth.passwordTooShort")); + if (password !== repeat) return setProblem(t("auth.passwordsDiffer")); + // Known up front: ask for the code before the first submit, not after it fails. + if (check.data?.totp_required && !needTotp) return setNeedTotp(true); + reset.mutate(); + }; + + const error = reset.error instanceof ApiError ? reset.error : null; + const showError = + error && error.code !== "invalid_token" && !(needTotp && error.code === "totp_required"); + + return ( +

+

{t("auth.resetTitle")}

+ {reset.isSuccess ? ( +

{t("auth.resetDone")}

+ ) : invalid ? ( + <> +

{t("auth.resetInvalid")}

+

+ + {t("auth.requestNewLink")} + +

+ + ) : check.isPending ? ( +

{t("common.loading")}

+ ) : ( +
{ + e.preventDefault(); + submit(); + }} + > + setPassword(e.target.value)} + disabled={needTotp} + autoFocus + /> + setRepeat(e.target.value)} + disabled={needTotp} + /> + {needTotp && ( +
+

{t("auth.resetTotpPrompt")}

+ setTotp(e.target.value)} + autoFocus + /> +
+ )} + + {problem &&

{problem}

} + {showError &&

{error.message}

} +
+ )} +

+ + {t("auth.backToSignIn")} + +

+
+ ); +} diff --git a/frontend/src/routes/settings/Settings.tsx b/frontend/src/routes/settings/Settings.tsx index 8fa3cc0..20ddd8f 100644 --- a/frontend/src/routes/settings/Settings.tsx +++ b/frontend/src/routes/settings/Settings.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { RichTextEditor } from "../../components/compose/RichTextEditor"; +import { TypedPasswordInput } from "../../components/TypedPasswordInput"; import { useT } from "../../i18n"; import { api, ApiError } from "../../lib/api"; import type { CalendarOut, ReminderPref } from "../../lib/types"; @@ -105,11 +106,103 @@ function ProfileSection() {

{t("settings.profile")}

- +
); } +// Whoever controls the recovery address can take the account over with a reset +// link, so adding, changing or removing it asks for the current password. +function RecoveryEmailRow({ current }: { current: string | null }) { + const t = useT(); + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const save = useMutation({ + mutationFn: () => api.setRecoveryEmail(password, email.trim() || null), + onSuccess: (user) => { + queryClient.setQueryData(["me"], user); + setEditing(false); + setPassword(""); + }, + }); + + if (!editing) { + return ( +
+
+ {t("settings.recoveryEmail")} + + {current ?? "—"} + + +
+

{t("settings.recoveryEmailHint")}

+
+ ); + } + + const field = + "w-full rounded border border-app-border bg-app-surface px-2 py-1.5 text-sm focus:border-app-accent focus:outline-none"; + return ( +
{ + e.preventDefault(); + save.mutate(); + }} + > + + + {save.error instanceof ApiError &&

{save.error.message}

} +
+ + +
+
+ ); +} + function Row({ label, value }: { label: string; value: string }) { return (