feat: replace Cloudflare Access with email+password admin login

Cloudflare Access self-hosted apps require a Cloudflare-managed
zone, which workers.dev is not, so the JWT path never worked. Swap
in a small login flow that fits the two-person scope:

- POST /api/admin/login validates email against ALLOWED_ADMIN_EMAILS
  and password against ADMIN_PASSWORD (constant-time compare), then
  issues an HS256-signed JWT in an httpOnly cookie.
- POST /api/admin/logout clears the cookie.
- requireAdmin verifies the cookie via hono/jwt and exposes the
  email through context.
- New /admin/login page; the dashboard redirects there on 401 and
  uses /api/admin/logout for the Sair button.

Two new env vars are required (set as secrets in the dashboard):
ADMIN_PASSWORD and SESSION_SECRET. CF_ACCESS_* are no longer used.

https://claude.ai/code/session_01TPBqgcSJMppgrpiq7fLywL
This commit is contained in:
Claude 2026-05-04 20:31:09 +00:00
parent c7900c60d0
commit 9294b152b8
No known key found for this signature in database
6 changed files with 190 additions and 105 deletions

View File

@ -16,9 +16,10 @@ export interface AppEnv {
EVENT_DATE?: string;
PUBLIC_BASE_URL: string;
TURNSTILE_SECRET: string;
CF_ACCESS_TEAM: string;
CF_ACCESS_AUD: string;
TURNSTILE_SECRET?: string;
ADMIN_PASSWORD: string;
SESSION_SECRET: string;
ALLOWED_ADMIN_EMAILS: string;
}

View File

@ -1,92 +1,51 @@
import type { Context, Next } from 'hono';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { sign, verify } from 'hono/jwt';
import type { Bindings } from '../env.js';
interface AccessJwtPayload {
email?: string;
sub?: string;
aud?: string | string[];
exp?: number;
iat?: number;
}
const COOKIE = 'wedding_admin';
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
interface JwtHeader {
alg: string;
kid: string;
typ?: string;
interface SessionPayload {
email: string;
exp: number;
}
export async function requireAdmin(c: Context<Bindings>, next: Next) {
const token = c.req.header('cf-access-jwt-assertion');
const token = getCookie(c, COOKIE);
if (!token) return c.json({ error: 'unauthorized' }, 401);
const payload = await verifyAccessJwt(token, c.env.CF_ACCESS_TEAM, c.env.CF_ACCESS_AUD);
if (!payload?.email) return c.json({ error: 'unauthorized' }, 401);
const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((s) => s.trim().toLowerCase());
if (!allowed.includes(payload.email.toLowerCase())) {
return c.json({ error: 'forbidden' }, 403);
}
c.set('adminEmail', payload.email);
await next();
}
async function verifyAccessJwt(
token: string,
team: string,
expectedAud: string,
): Promise<AccessJwtPayload | null> {
const parts = token.split('.');
if (parts.length !== 3) return null;
const [headerB64, payloadB64, sigB64] = parts as [string, string, string];
let header: JwtHeader;
let payload: AccessJwtPayload;
try {
header = JSON.parse(b64urlDecodeString(headerB64)) as JwtHeader;
payload = JSON.parse(b64urlDecodeString(payloadB64)) as AccessJwtPayload;
const payload = (await verify(token, c.env.SESSION_SECRET, 'HS256')) as unknown as SessionPayload;
if (!payload.email) return c.json({ error: 'unauthorized' }, 401);
c.set('adminEmail', payload.email);
await next();
} catch {
return null;
return c.json({ error: 'unauthorized' }, 401);
}
const audOk = Array.isArray(payload.aud)
? payload.aud.includes(expectedAud)
: payload.aud === expectedAud;
if (!audOk) return null;
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
const certsRes = await fetch(`https://${team}/cdn-cgi/access/certs`);
if (!certsRes.ok) return null;
const certs = (await certsRes.json()) as { keys: Array<JsonWebKey & { kid?: string }> };
const jwk = certs.keys.find((k) => k.kid === header.kid);
if (!jwk) return null;
const cryptoKey = await crypto.subtle.importKey(
'jwk',
jwk,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify'],
);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
cryptoKey,
b64urlDecode(sigB64),
data,
);
return valid ? payload : null;
}
function b64urlDecode(s: string): Uint8Array<ArrayBuffer> {
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
const bin = atob(b64);
const buf = new ArrayBuffer(bin.length);
const out = new Uint8Array(buf);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
export async function createSession(c: Context<Bindings>, email: string) {
const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
const token = await sign({ email, exp }, c.env.SESSION_SECRET, 'HS256');
setCookie(c, COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
maxAge: SESSION_TTL_SECONDS,
});
}
function b64urlDecodeString(s: string): string {
return new TextDecoder().decode(b64urlDecode(s));
export function destroySession(c: Context<Bindings>) {
deleteCookie(c, COOKIE, { path: '/' });
}
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
if (a.length !== b.length) return false;
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
let result = 0;
for (let i = 0; i < aBytes.length; i++) result |= aBytes[i]! ^ bBytes[i]!;
return result === 0;
}

View File

@ -1,5 +1,6 @@
import { and, count, desc, eq, lt, sum } from 'drizzle-orm';
import { and, desc, eq, lt } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import {
type AdminStats,
type AdminUploadsResponse,
@ -8,15 +9,48 @@ import {
import { getDb } from '../db/client.js';
import { eventConfig, uploads } from '../db/schema.js';
import type { Bindings } from '../env.js';
import { requireAdmin } from '../lib/auth.js';
import {
createSession,
destroySession,
requireAdmin,
timingSafeEqual,
} from '../lib/auth.js';
import { createStorage } from '../lib/storage-factory.js';
export const adminRoutes = new Hono<Bindings>();
adminRoutes.use('*', requireAdmin);
const ADMIN_PAGE_SIZE = 30;
const loginSchema = z.object({
email: z.string().email().toLowerCase(),
password: z.string().min(1),
});
adminRoutes.post('/login', async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return c.json({ error: 'invalid_body' }, 400);
}
const allowed = c.env.ALLOWED_ADMIN_EMAILS.split(',').map((e) => e.trim().toLowerCase());
const emailOk = allowed.includes(parsed.data.email);
const passwordOk = await timingSafeEqual(parsed.data.password, c.env.ADMIN_PASSWORD);
if (!emailOk || !passwordOk) {
return c.json({ error: 'unauthorized' }, 401);
}
await createSession(c, parsed.data.email);
return c.json({ ok: true, email: parsed.data.email });
});
adminRoutes.post('/logout', async (c) => {
destroySession(c);
return c.json({ ok: true });
});
adminRoutes.use('*', requireAdmin);
adminRoutes.get('/me', (c) => c.json({ email: c.get('adminEmail') }));
adminRoutes.get('/event', async (c) => {

View File

@ -1,5 +1,6 @@
import { Route, Routes } from 'react-router-dom';
import AdminDashboard from './routes/admin/Dashboard.js';
import AdminLogin from './routes/admin/Login.js';
import Gallery from './routes/Gallery.js';
import Home from './routes/Home.js';
import Upload from './routes/Upload.js';
@ -10,6 +11,7 @@ export default function App() {
<Route path="/" element={<Home />} />
<Route path="/enviar" element={<Upload />} />
<Route path="/galeria" element={<Gallery />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin/*" element={<AdminDashboard />} />
</Routes>
);

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import type {
AdminStats,
AdminUpload,
@ -36,8 +36,8 @@ function formatDate(ts: number): string {
}
export default function AdminDashboard() {
const navigate = useNavigate();
const [email, setEmail] = useState<string | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [stats, setStats] = useState<AdminStats | null>(null);
const [event, setEvent] = useState<AdminEvent | null>(null);
const [uploads, setUploads] = useState<AdminUpload[]>([]);
@ -54,14 +54,14 @@ export default function AdminDashboard() {
fetch('/api/admin/me', { credentials: 'include' })
.then(async (r) => {
if (r.status === 401 || r.status === 403) {
setAuthError('Você não tem acesso a este painel.');
navigate('/admin/login', { replace: true });
return null;
}
return r.json() as Promise<{ email: string }>;
})
.then((d) => d && setEmail(d.email))
.catch(() => setAuthError('Falha ao verificar identidade.'));
}, []);
.catch(() => navigate('/admin/login', { replace: true }));
}, [navigate]);
useEffect(() => {
if (!email) return;
@ -160,20 +160,6 @@ export default function AdminDashboard() {
setEventDirty(true);
}
if (authError) {
return (
<main className="min-h-full flex items-center justify-center p-6 text-center">
<div>
<h2 className="font-display text-2xl text-stone-800 mb-2">Sem acesso</h2>
<p className="text-stone-600">{authError}</p>
<Link to="/" className="mt-4 inline-block text-sm text-stone-500 underline">
Voltar pra página inicial
</Link>
</div>
</main>
);
}
if (!email) {
return (
<main className="min-h-full flex items-center justify-center p-6 text-stone-500">
@ -182,6 +168,11 @@ export default function AdminDashboard() {
);
}
async function logout() {
await fetch('/api/admin/logout', { method: 'POST', credentials: 'include' });
navigate('/admin/login', { replace: true });
}
return (
<main className="min-h-full max-w-4xl mx-auto px-4 py-8">
<header className="flex items-center justify-between mb-8">
@ -189,12 +180,13 @@ export default function AdminDashboard() {
<h1 className="font-display text-3xl text-stone-800">Painel dos noivos</h1>
<p className="text-xs text-stone-500 mt-1">Logado como {email}</p>
</div>
<a
href="/cdn-cgi/access/logout"
<button
type="button"
onClick={logout}
className="text-sm text-stone-500 hover:text-stone-700 underline"
>
Sair
</a>
</button>
</header>
{stats && (

View File

@ -0,0 +1,97 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
export default function AdminLogin() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError(null);
try {
const r = await fetch('/api/admin/login', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
if (r.status === 401) {
setError('Email ou senha incorretos.');
return;
}
if (!r.ok) {
setError('Erro inesperado. Tenta de novo.');
return;
}
navigate('/admin', { replace: true });
} catch {
setError('Falha de rede.');
} finally {
setLoading(false);
}
}
return (
<main className="min-h-full flex items-center justify-center px-6 py-12">
<form
onSubmit={submit}
className="w-full max-w-sm bg-white border border-stone-200 rounded-lg p-6 space-y-4"
>
<h1 className="font-display text-2xl text-stone-800 text-center">
Painel dos noivos
</h1>
<p className="text-sm text-stone-500 text-center -mt-2">
Acesso restrito a Stefanie & Leandro.
</p>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Email</span>
<input
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
<label className="block">
<span className="block text-xs font-medium text-stone-600 mb-1">Senha</span>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full rounded border border-stone-300 px-3 py-2"
/>
</label>
{error && (
<p className="text-sm text-rose-700 bg-rose-50 border border-rose-200 rounded p-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading || !email || !password}
className="w-full bg-stone-800 text-cream rounded-full py-3 disabled:bg-stone-400 hover:bg-stone-700 transition"
>
{loading ? 'Entrando…' : 'Entrar'}
</button>
<Link
to="/"
className="block text-center text-xs text-stone-500 hover:text-stone-700"
>
Voltar pra página pública
</Link>
</form>
</main>
);
}