feat: phase 3 admin magic-link auth — tokens, sessions, rate limits, audit
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.
This commit is contained in:
76
app/templates/admin/base.html
Normal file
76
app/templates/admin/base.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{#
|
||||
Minimal admin layout shell.
|
||||
|
||||
Reuses the public site's CSS (palette + typography) so Head Hen sees a
|
||||
consistent visual language without us maintaining a second stylesheet.
|
||||
Intentionally simpler than the public base: no marketing hero,
|
||||
no multi-link primary nav — just the brand mark, the admin context
|
||||
label, and a logout control when the viewer is authenticated.
|
||||
|
||||
Context the child template may override:
|
||||
- title : <title> content
|
||||
- content : main body
|
||||
- user : app.models.entities.User | None
|
||||
(passed by the index route; omitted on pre-auth pages)
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Admin — Chicken Babies R Us{% endblock %}</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.ico') }}" sizes="any">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/site.css') }}">
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<a class="skip-link" href="#main-content">Skip to main content</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="wrap site-header__wrap">
|
||||
<a class="site-header__brand" href="/" aria-label="Chicken Babies R Us home">
|
||||
<picture>
|
||||
<source srcset="{{ url_for('static', path='img/logo.webp') }}" type="image/webp">
|
||||
<img src="{{ url_for('static', path='img/logo.png') }}"
|
||||
alt="Chicken Babies R Us"
|
||||
height="48"
|
||||
class="site-header__logo">
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<nav class="site-nav" aria-label="Admin">
|
||||
<ul class="site-nav__list">
|
||||
<li class="site-nav__item">
|
||||
<span class="site-nav__link" aria-current="page">Admin</span>
|
||||
</li>
|
||||
{% if user is defined and user %}
|
||||
<li class="site-nav__item">
|
||||
{#
|
||||
Plain POST form — no CSRF token yet. Phase 6 adds a
|
||||
double-submit token; SameSite=Lax is sufficient in the
|
||||
meantime.
|
||||
#}
|
||||
<form action="/admin/logout" method="post" class="site-nav__logout-form">
|
||||
<button type="submit" class="btn btn--link">Log out</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main-content" class="site-main" tabindex="-1">
|
||||
<div class="wrap">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="wrap site-footer__wrap">
|
||||
<p class="site-footer__tag">
|
||||
Chicken Babies R Us · Admin
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
29
app/templates/admin/index.html
Normal file
29
app/templates/admin/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{#
|
||||
Admin landing page (post-login).
|
||||
|
||||
Phase 3 keeps this minimal — Phase 4 turns it into the CMS
|
||||
dashboard (pages + posts + media). For now it's a welcome screen +
|
||||
logout control (rendered in admin/base.html's nav since ``user`` is
|
||||
present in context).
|
||||
|
||||
Context:
|
||||
- user : app.models.entities.User (guaranteed by require_admin)
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Welcome — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Welcome, {{ user.display_name }}</h1>
|
||||
</header>
|
||||
<p>
|
||||
You're logged in as <code>{{ user.email }}</code>. The dashboard
|
||||
(pages, posts, media) lands in Phase 4.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/">« Back to the public site</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
48
app/templates/admin/login.html
Normal file
48
app/templates/admin/login.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{#
|
||||
Admin login form.
|
||||
|
||||
Single email input, no JS. If the submitted address is on the
|
||||
allowlist (ADMIN_EMAILS env var), the POST handler emails a
|
||||
one-time magic link; otherwise it silently succeeds without
|
||||
sending (anti-enumeration).
|
||||
|
||||
Context:
|
||||
- error : str | None (format validation errors only)
|
||||
- email : str (pre-fill on re-render after format error)
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Log in — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Admin log in</h1>
|
||||
</header>
|
||||
|
||||
<p>
|
||||
Enter your admin email and we'll send a one-time login link
|
||||
that expires after 15 minutes.
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<p class="admin-flash admin-flash--error" role="alert">{{ error }}</p>
|
||||
{% endif %}
|
||||
|
||||
<form class="contact-form" action="/admin/login" method="post" novalidate>
|
||||
<div class="contact-form__field">
|
||||
<label for="admin-email">Email</label>
|
||||
<input type="email"
|
||||
id="admin-email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
value="{{ email or '' }}"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="contact-form__actions">
|
||||
<button type="submit" class="btn btn--primary">Send login link</button>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
{% endblock %}
|
||||
28
app/templates/admin/login_failed.html
Normal file
28
app/templates/admin/login_failed.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{#
|
||||
Generic failure page for GET /admin/auth/consume/{token}.
|
||||
|
||||
Deliberately does NOT distinguish between:
|
||||
- token not found
|
||||
- token expired
|
||||
- token already consumed
|
||||
The audit log has the real reason; the visitor only needs to know
|
||||
they need a fresh link.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Login link invalid — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Login link invalid or expired</h1>
|
||||
</header>
|
||||
<p>
|
||||
That login link isn't valid any more. Links expire after 15
|
||||
minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Request a new link</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
25
app/templates/admin/login_sent.html
Normal file
25
app/templates/admin/login_sent.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Post-submission page rendered after POST /admin/login.
|
||||
|
||||
Same copy for every outcome (allowlisted vs. not) to avoid leaking
|
||||
which emails are real admins.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Check your inbox — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Check your inbox</h1>
|
||||
</header>
|
||||
<p>
|
||||
If your email is on the allowlist, you'll receive a login link
|
||||
shortly. The link expires after 15 minutes and can only be used
|
||||
once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Request another link</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
25
app/templates/admin/rate_limited.html
Normal file
25
app/templates/admin/rate_limited.html
Normal file
@@ -0,0 +1,25 @@
|
||||
{#
|
||||
Rendered at HTTP 429 when either the SlowAPI IP limit or the DB-side
|
||||
per-email limit trips on POST /admin/login.
|
||||
|
||||
Same template for both trigger paths — we don't want to tell the
|
||||
submitter whether the limit was IP-wide or email-specific.
|
||||
#}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Too many attempts — Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article class="page-article">
|
||||
<header class="page-article__header">
|
||||
<h1 class="page-article__title">Too many attempts</h1>
|
||||
</header>
|
||||
<p>
|
||||
You've requested too many login links recently. Please wait a
|
||||
few minutes before trying again.
|
||||
</p>
|
||||
<p>
|
||||
<a href="/admin/login">Back to login</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
36
app/templates/emails/magic_link.html
Normal file
36
app/templates/emails/magic_link.html
Normal file
@@ -0,0 +1,36 @@
|
||||
{#
|
||||
HTML body for the magic-link email.
|
||||
|
||||
Plain, minimal inline-styled markup — no external CSS because email
|
||||
clients are hostile to it. The link text IS the URL so users can
|
||||
verify the destination before clicking.
|
||||
|
||||
Context:
|
||||
- display_name : str
|
||||
- magic_link_url : str
|
||||
- expires_at : ISO-8601 str
|
||||
- ttl_min : int
|
||||
#}<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Admin login link — Chicken Babies R Us</title>
|
||||
</head>
|
||||
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #2B3A42; max-width: 560px; margin: 0 auto; padding: 24px;">
|
||||
<h1 style="font-size: 20px; margin: 0 0 16px;">Admin login link</h1>
|
||||
<p>Hi {{ display_name }},</p>
|
||||
<p>
|
||||
Use the link below to log in to the Chicken Babies R Us admin.
|
||||
The link works for {{ ttl_min }} minutes and can only be used once.
|
||||
</p>
|
||||
<p>
|
||||
<a href="{{ magic_link_url }}" style="color: #5D8AA8; word-break: break-all;">
|
||||
{{ magic_link_url }}
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #6b7a80; font-size: 13px;">
|
||||
Expires at {{ expires_at }}. If you didn't request this, you can
|
||||
safely ignore the email — no action will be taken.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
12
app/templates/emails/magic_link.txt
Normal file
12
app/templates/emails/magic_link.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
{# Plaintext body for the magic-link email. Mirrors the HTML version. #}
|
||||
Hi {{ display_name }},
|
||||
|
||||
Use the link below to log in to the Chicken Babies R Us admin.
|
||||
The link works for {{ ttl_min }} minutes and can only be used once.
|
||||
|
||||
{{ magic_link_url }}
|
||||
|
||||
Expires at {{ expires_at }}.
|
||||
|
||||
If you didn't request this, you can safely ignore the email -- no
|
||||
action will be taken.
|
||||
Reference in New Issue
Block a user