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>
60 lines
2.3 KiB
Python
60 lines
2.3 KiB
Python
"""Auth dependencies: current user and CSRF enforcement (SPEC §6.1, §10.3)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Annotated
|
|
|
|
from fastapi import Depends, Request
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.errors import raise_api_error
|
|
from app.db import get_session
|
|
from app.identity.cookies import CSRF_HEADER, csrf_cookie_name, read_session_token
|
|
from app.identity.sessions import Session, resolve_session
|
|
from app.models import User
|
|
|
|
|
|
async def current_session(request: Request) -> Session:
|
|
"""Resolve the caller's session or raise 401."""
|
|
token = read_session_token(request)
|
|
session = await resolve_session(token)
|
|
if session is None:
|
|
raise_api_error(401, "not_authenticated", "authentication required")
|
|
return session
|
|
|
|
|
|
async def current_user(
|
|
session: Annotated[Session, Depends(current_session)],
|
|
db: Annotated[AsyncSession, Depends(get_session)],
|
|
) -> 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 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
|
|
|
|
|
|
async def require_csrf(request: Request) -> None:
|
|
"""Enforce the double-submit CSRF token on mutating requests (SPEC §10.3)."""
|
|
if request.method in ("GET", "HEAD", "OPTIONS"):
|
|
return
|
|
header = request.headers.get(CSRF_HEADER, "")
|
|
cookie = request.cookies.get(csrf_cookie_name(request), "")
|
|
if not header or not cookie or header != cookie:
|
|
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
|
|
|
|
|
|
async def current_admin(user: Annotated[User, Depends(current_user)]) -> User:
|
|
"""Load the authenticated user and require the administrator flag."""
|
|
if not user.is_admin:
|
|
raise_api_error(403, "not_admin", "administrator privileges required")
|
|
return user
|
|
|
|
|
|
CurrentUser = Annotated[User, Depends(current_user)]
|
|
CurrentAdmin = Annotated[User, Depends(current_admin)]
|
|
CurrentSession = Annotated[Session, Depends(current_session)]
|