Admin user management, password reset by email, and the Ultimo Email name
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user