End-to-end passwordless admin auth. /admin/login accepts an email, POSTs
mint a 256-bit magic-link token stored only as SHA-256 in
magic_link_tokens (15-min TTL, single-use via atomic rowcount UPDATE).
Resend delivers the link; in dev with no API key, EmailService logs a
structured magic_link_dev_fallback event with the URL so the flow works
offline. /admin/auth/consume/{token} verifies, upserts a users row
(display_name from email local-part), creates a sessions row, and drops
an itsdangerous-signed cb_session cookie (HttpOnly, SameSite=Lax, Secure
in prod). /admin renders a placeholder "Welcome, <name>" page pending
Phase 4 CMS. /admin/logout flips revoked_at rather than deleting the row
to preserve the audit trail.
Rate limits use SlowAPI's in-memory limiter (5/15min/IP on login,
20/15min/IP on consume) plus a DB per-email count to catch
IP-rotating abuse. ADMIN_EMAILS enforces allowlist; non-allowlisted
submissions return the same "check your inbox" page with no token
inserted and no email sent (anti-enumeration). Every event lands in
auth_events via AuditService: link_requested, link_consumed,
consume_failed, session_created, session_revoked, rate_limited.
Add a production config validator refusing empty RESEND_API_KEY,
RESEND_FROM, or ADMIN_EMAILS; add PUBLIC_BASE_URL for email link
construction. CSRF deferred to Phase 6 per roadmap scoping; logout
handler marked # TODO(phase-6-csrf).
Mark Phase 3 complete in docs/ROADMAP.md.
90 lines
2.9 KiB
Python
90 lines
2.9 KiB
Python
"""Auth dependencies for admin routes.
|
|
|
|
Two ``Depends(...)`` helpers:
|
|
|
|
- :func:`get_current_user` — returns a :class:`User` or ``None`` based
|
|
on the signed ``cb_session`` cookie. Never raises.
|
|
- :func:`require_admin` — same lookup but raises an HTTP 303 redirect
|
|
to ``/admin/login`` if no user is authenticated. Used by every route
|
|
that must be logged-in.
|
|
|
|
Cookie handling
|
|
---------------
|
|
The cookie is read via ``request.cookies`` (Starlette strips secure /
|
|
httponly flags off by the time the app sees it; they only affect how
|
|
the browser stores and presents the cookie). Unsigning, hashing, and
|
|
DB lookup are delegated to :class:`app.services.sessions.SessionService`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from fastapi import Depends, HTTPException, Request
|
|
from sqlalchemy import text
|
|
|
|
from app.models.entities import User
|
|
from app.models.mappers import row_to_user
|
|
from app.services.sessions import COOKIE_NAME, SessionService
|
|
|
|
|
|
def _get_session_service(request: Request) -> SessionService:
|
|
"""Return the app-scoped :class:`SessionService` for DI.
|
|
|
|
Private to this module — routes and other dependencies resolve the
|
|
service via :func:`get_current_user` / :func:`require_admin` rather
|
|
than reaching across the dependency graph.
|
|
"""
|
|
return request.app.state.session_service
|
|
|
|
|
|
def get_current_user(
|
|
request: Request,
|
|
sessions: SessionService = Depends(_get_session_service),
|
|
) -> Optional[User]:
|
|
"""Return the authenticated :class:`User` or ``None``.
|
|
|
|
Never raises. A malformed / expired / revoked cookie simply
|
|
resolves to ``None`` so that un-authed viewers can hit admin
|
|
login pages without tripping an exception handler.
|
|
"""
|
|
cookie_value = request.cookies.get(COOKIE_NAME)
|
|
session = sessions.lookup(cookie_value)
|
|
if session is None:
|
|
return None
|
|
|
|
# We need the user row to render "Welcome, <display_name>" on the
|
|
# admin index. Query directly here instead of adding a full
|
|
# UserService for one call site.
|
|
engine = request.app.state.engine
|
|
with engine.connect() as conn:
|
|
row = conn.execute(
|
|
text(
|
|
"SELECT id, email, display_name, created_at,"
|
|
" last_login_at, active"
|
|
" FROM users WHERE id = :id AND active = 1 LIMIT 1"
|
|
),
|
|
{"id": session.user_id},
|
|
).mappings().first()
|
|
|
|
if row is None:
|
|
return None
|
|
return row_to_user(row)
|
|
|
|
|
|
def require_admin(
|
|
user: Optional[User] = Depends(get_current_user),
|
|
) -> User:
|
|
"""Return the authenticated user or redirect to login.
|
|
|
|
Uses a 303 "See Other" so the browser switches to GET on the
|
|
followup request — correct behavior for both initial page loads
|
|
and the post-consume redirect chain.
|
|
"""
|
|
if user is None:
|
|
raise HTTPException(
|
|
status_code=303,
|
|
headers={"Location": "/admin/login"},
|
|
)
|
|
return user
|