Three strands that only make sense together, so they land together. Registration gate (migration 0016). POST /auth/register was open to anyone who could reach the port. Now the first account on an empty instance is always allowed, becomes the administrator, and closes registration behind itself; after that it takes an admin reopening it. The decision is made under a row lock on app_settings so two simultaneous first signups cannot both see an empty table and both claim administrator. Existing installs keep their oldest account as admin and start closed. Instance default quota (migration 0017). users.quota_bytes is snapshotted at creation from app_settings.default_quota_bytes, so changing the default affects later accounts only and never resizes a mailbox that exists. The panel is a separate app, not a page in the mail client. admin.html is its own Vite entry (5.8 KB next to the client's 1.7 MB; they share only the vendor chunk), with its own administrator sign-in. One nginx now serves two listeners: :80 is the mail client and 404s both /admin.html and /api/admin/*, :81 is the panel. docker-compose publishes them as WEB_PORT and ADMIN_PORT. The boundary is nginx refusing paths on one listener, not process isolation — CurrentAdmin still guards every admin route — so ADMIN_PORT must be restricted to the LAN, which the runbook now covers for both DSM and QNAP. Also QNAP: deploy/webmail.sh finds Container Station's docker under /share/*/.qpkg/container-station/bin, which is off an SSH session's PATH, before anything shells out to it. docs/nas-install.md gains a QuTS hero runbook (§2.6), splits the DSM firewall section into container-to-container (§8.1) and the egress case that makes account-add time out while autodiscovery appears to work (§8.2), and scopes both to Synology. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
59 lines
2.2 KiB
Python
59 lines
2.2 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:
|
|
# Session references a deleted user; treat as unauthenticated.
|
|
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)]
|