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:
@@ -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
|
||||
|
||||
|
||||
@@ -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'<!doctype html>\n<html><body style="{body}">\n'
|
||||
f"<p>Someone asked to change the password of the Ultimo Email account <b>{h}</b>.</p>\n"
|
||||
"<p>To choose a new password, open this link within 30 minutes:</p>\n"
|
||||
f'<p><a href="{u}" style="{button}">Change password</a></p>\n'
|
||||
f'<p style="{muted}">Or paste this address into your browser:<br>{u}</p>\n'
|
||||
f'<p style="{muted}">The link works once. If you did not ask for this, ignore this '
|
||||
"email — your password stays as it is.</p>\n</body></html>\n"
|
||||
)
|
||||
return subject, text, html
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user