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;
|
EVENT_DATE?: string;
|
||||||
PUBLIC_BASE_URL: string;
|
PUBLIC_BASE_URL: string;
|
||||||
|
|
||||||
TURNSTILE_SECRET: string;
|
TURNSTILE_SECRET?: string;
|
||||||
CF_ACCESS_TEAM: string;
|
|
||||||
CF_ACCESS_AUD: string;
|
ADMIN_PASSWORD: string;
|
||||||
|
SESSION_SECRET: string;
|
||||||
ALLOWED_ADMIN_EMAILS: string;
|
ALLOWED_ADMIN_EMAILS: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,92 +1,51 @@
|
|||||||
import type { Context, Next } from 'hono';
|
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';
|
import type { Bindings } from '../env.js';
|
||||||
|
|
||||||
interface AccessJwtPayload {
|
const COOKIE = 'wedding_admin';
|
||||||
email?: string;
|
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||||
sub?: string;
|
|
||||||
aud?: string | string[];
|
|
||||||
exp?: number;
|
|
||||||
iat?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface JwtHeader {
|
interface SessionPayload {
|
||||||
alg: string;
|
email: string;
|
||||||
kid: string;
|
exp: number;
|
||||||
typ?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function requireAdmin(c: Context<Bindings>, next: Next) {
|
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);
|
if (!token) return c.json({ error: 'unauthorized' }, 401);
|
||||||
|
try {
|
||||||
const payload = await verifyAccessJwt(token, c.env.CF_ACCESS_TEAM, c.env.CF_ACCESS_AUD);
|
const payload = (await verify(token, c.env.SESSION_SECRET, 'HS256')) as unknown as SessionPayload;
|
||||||
if (!payload?.email) return c.json({ error: 'unauthorized' }, 401);
|
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);
|
c.set('adminEmail', payload.email);
|
||||||
await next();
|
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;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return c.json({ error: 'unauthorized' }, 401);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const audOk = Array.isArray(payload.aud)
|
export async function createSession(c: Context<Bindings>, email: string) {
|
||||||
? payload.aud.includes(expectedAud)
|
const exp = Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS;
|
||||||
: payload.aud === expectedAud;
|
const token = await sign({ email, exp }, c.env.SESSION_SECRET, 'HS256');
|
||||||
if (!audOk) return null;
|
setCookie(c, COOKIE, token, {
|
||||||
if (payload.exp && payload.exp * 1000 < Date.now()) return null;
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
const certsRes = await fetch(`https://${team}/cdn-cgi/access/certs`);
|
sameSite: 'Lax',
|
||||||
if (!certsRes.ok) return null;
|
path: '/',
|
||||||
const certs = (await certsRes.json()) as { keys: Array<JsonWebKey & { kid?: string }> };
|
maxAge: SESSION_TTL_SECONDS,
|
||||||
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> {
|
export function destroySession(c: Context<Bindings>) {
|
||||||
const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4));
|
deleteCookie(c, COOKIE, { path: '/' });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function b64urlDecodeString(s: string): string {
|
export async function timingSafeEqual(a: string, b: string): Promise<boolean> {
|
||||||
return new TextDecoder().decode(b64urlDecode(s));
|
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 { Hono } from 'hono';
|
||||||
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
type AdminStats,
|
type AdminStats,
|
||||||
type AdminUploadsResponse,
|
type AdminUploadsResponse,
|
||||||
@ -8,15 +9,48 @@ import {
|
|||||||
import { getDb } from '../db/client.js';
|
import { getDb } from '../db/client.js';
|
||||||
import { eventConfig, uploads } from '../db/schema.js';
|
import { eventConfig, uploads } from '../db/schema.js';
|
||||||
import type { Bindings } from '../env.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';
|
import { createStorage } from '../lib/storage-factory.js';
|
||||||
|
|
||||||
export const adminRoutes = new Hono<Bindings>();
|
export const adminRoutes = new Hono<Bindings>();
|
||||||
|
|
||||||
adminRoutes.use('*', requireAdmin);
|
|
||||||
|
|
||||||
const ADMIN_PAGE_SIZE = 30;
|
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('/me', (c) => c.json({ email: c.get('adminEmail') }));
|
||||||
|
|
||||||
adminRoutes.get('/event', async (c) => {
|
adminRoutes.get('/event', async (c) => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Route, Routes } from 'react-router-dom';
|
import { Route, Routes } from 'react-router-dom';
|
||||||
import AdminDashboard from './routes/admin/Dashboard.js';
|
import AdminDashboard from './routes/admin/Dashboard.js';
|
||||||
|
import AdminLogin from './routes/admin/Login.js';
|
||||||
import Gallery from './routes/Gallery.js';
|
import Gallery from './routes/Gallery.js';
|
||||||
import Home from './routes/Home.js';
|
import Home from './routes/Home.js';
|
||||||
import Upload from './routes/Upload.js';
|
import Upload from './routes/Upload.js';
|
||||||
@ -10,6 +11,7 @@ export default function App() {
|
|||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/enviar" element={<Upload />} />
|
<Route path="/enviar" element={<Upload />} />
|
||||||
<Route path="/galeria" element={<Gallery />} />
|
<Route path="/galeria" element={<Gallery />} />
|
||||||
|
<Route path="/admin/login" element={<AdminLogin />} />
|
||||||
<Route path="/admin/*" element={<AdminDashboard />} />
|
<Route path="/admin/*" element={<AdminDashboard />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import type {
|
import type {
|
||||||
AdminStats,
|
AdminStats,
|
||||||
AdminUpload,
|
AdminUpload,
|
||||||
@ -36,8 +36,8 @@ function formatDate(ts: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default function AdminDashboard() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState<string | null>(null);
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
|
||||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||||
const [event, setEvent] = useState<AdminEvent | null>(null);
|
const [event, setEvent] = useState<AdminEvent | null>(null);
|
||||||
const [uploads, setUploads] = useState<AdminUpload[]>([]);
|
const [uploads, setUploads] = useState<AdminUpload[]>([]);
|
||||||
@ -54,14 +54,14 @@ export default function AdminDashboard() {
|
|||||||
fetch('/api/admin/me', { credentials: 'include' })
|
fetch('/api/admin/me', { credentials: 'include' })
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
if (r.status === 401 || r.status === 403) {
|
if (r.status === 401 || r.status === 403) {
|
||||||
setAuthError('Você não tem acesso a este painel.');
|
navigate('/admin/login', { replace: true });
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return r.json() as Promise<{ email: string }>;
|
return r.json() as Promise<{ email: string }>;
|
||||||
})
|
})
|
||||||
.then((d) => d && setEmail(d.email))
|
.then((d) => d && setEmail(d.email))
|
||||||
.catch(() => setAuthError('Falha ao verificar identidade.'));
|
.catch(() => navigate('/admin/login', { replace: true }));
|
||||||
}, []);
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!email) return;
|
if (!email) return;
|
||||||
@ -160,20 +160,6 @@ export default function AdminDashboard() {
|
|||||||
setEventDirty(true);
|
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) {
|
if (!email) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-full flex items-center justify-center p-6 text-stone-500">
|
<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 (
|
return (
|
||||||
<main className="min-h-full max-w-4xl mx-auto px-4 py-8">
|
<main className="min-h-full max-w-4xl mx-auto px-4 py-8">
|
||||||
<header className="flex items-center justify-between mb-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>
|
<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>
|
<p className="text-xs text-stone-500 mt-1">Logado como {email}</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<button
|
||||||
href="/cdn-cgi/access/logout"
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
className="text-sm text-stone-500 hover:text-stone-700 underline"
|
className="text-sm text-stone-500 hover:text-stone-700 underline"
|
||||||
>
|
>
|
||||||
Sair
|
Sair
|
||||||
</a>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{stats && (
|
{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