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:
parent
c7900c60d0
commit
9294b152b8
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 && (
|
||||
|
||||
97
apps/web/src/routes/admin/Login.tsx
Normal file
97
apps/web/src/routes/admin/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user