diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index cd183db..6819d98 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -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; } diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 6a088d9..7030086 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -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, 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 { - 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 }; - 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 { - 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, 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) { + deleteCookie(c, COOKIE, { path: '/' }); +} + +export async function timingSafeEqual(a: string, b: string): Promise { + 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; } diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 94c2d4c..55c76d3 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -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(); -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) => { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2374aff..35c7fb7 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> ); diff --git a/apps/web/src/routes/admin/Dashboard.tsx b/apps/web/src/routes/admin/Dashboard.tsx index cb6c286..719bc8e 100644 --- a/apps/web/src/routes/admin/Dashboard.tsx +++ b/apps/web/src/routes/admin/Dashboard.tsx @@ -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(null); - const [authError, setAuthError] = useState(null); const [stats, setStats] = useState(null); const [event, setEvent] = useState(null); const [uploads, setUploads] = useState([]); @@ -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 ( -
-
-

Sem acesso

-

{authError}

- - Voltar pra página inicial - -
-
- ); - } - if (!email) { return (
@@ -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 (
@@ -189,12 +180,13 @@ export default function AdminDashboard() {

Painel dos noivos

Logado como {email}

- Sair - +
{stats && ( diff --git a/apps/web/src/routes/admin/Login.tsx b/apps/web/src/routes/admin/Login.tsx new file mode 100644 index 0000000..7d3e870 --- /dev/null +++ b/apps/web/src/routes/admin/Login.tsx @@ -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(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 ( +
+
+

+ Painel dos noivos +

+

+ Acesso restrito a Stefanie & Leandro. +

+ + + + + + {error && ( +

+ {error} +

+ )} + + + + + ← Voltar pra página pública + +
+
+ ); +}