Implement token-based admin login and remove WebAuthn support
This commit is contained in:
+39
-135
@@ -3,7 +3,6 @@ export const prerender = false;
|
||||
|
||||
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||
import { getSession, SESSION_COOKIE } from '../../lib/auth';
|
||||
import { hasCredentials } from '../../lib/webauthn';
|
||||
|
||||
// Redirect if already logged in
|
||||
const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value;
|
||||
@@ -12,151 +11,47 @@ if (session) {
|
||||
return Astro.redirect('/admin');
|
||||
}
|
||||
|
||||
const registrationMode = !hasCredentials();
|
||||
const tokenAuthEnabled = Boolean(process.env.ADMIN_SECRET_TOKEN?.trim());
|
||||
const tokenError = Astro.url.searchParams.get('tokenError') === '1';
|
||||
---
|
||||
|
||||
<AdminLayout title="Login">
|
||||
<div class="login-wrap">
|
||||
<h1>admin access</h1>
|
||||
|
||||
{registrationMode ? (
|
||||
{tokenAuthEnabled ? (
|
||||
<div class="card">
|
||||
<h2>Register your security key</h2>
|
||||
<h2>Token login</h2>
|
||||
<p class="info-text">
|
||||
No admin credentials are registered yet. Connect your YubiKey and click the button
|
||||
below to register it as the admin credential for this site.
|
||||
Enter your admin token to access moderation.
|
||||
</p>
|
||||
<p class="warning-text">
|
||||
Do this immediately after deployment — once registered, no further registrations
|
||||
will be accepted without resetting the database.
|
||||
</p>
|
||||
<button id="register-btn" class="btn btn-primary" type="button">
|
||||
Register YubiKey
|
||||
</button>
|
||||
<p id="register-status" class="status-text" aria-live="polite"></p>
|
||||
{tokenError && (
|
||||
<p class="warning-text">Invalid token. Try again.</p>
|
||||
)}
|
||||
<form method="post" action="/api/admin/token-login" class="token-form">
|
||||
<label for="token-input" class="token-label">Admin token</label>
|
||||
<input
|
||||
id="token-input"
|
||||
name="token"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="token-input"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">Sign in with token</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div class="card">
|
||||
<h2>Login with your security key</h2>
|
||||
<p class="info-text">
|
||||
Insert your YubiKey and click the button below to authenticate.
|
||||
<h2>Token not configured</h2>
|
||||
<p class="warning-text">
|
||||
<code>ADMIN_SECRET_TOKEN</code> is not set. Configure it in your environment, then reload this page.
|
||||
</p>
|
||||
<button id="login-btn" class="btn btn-primary" type="button">
|
||||
Authenticate with YubiKey
|
||||
</button>
|
||||
<p id="login-status" class="status-text" aria-live="polite"></p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
{registrationMode ? (
|
||||
<script>
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
const btn = document.getElementById('register-btn') as HTMLButtonElement;
|
||||
const status = document.getElementById('register-status') as HTMLParagraphElement;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Requesting registration options...';
|
||||
|
||||
try {
|
||||
const optRes = await fetch('/api/admin/webauthn/register-options');
|
||||
if (!optRes.ok) {
|
||||
const err = await optRes.json();
|
||||
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const options = await optRes.json();
|
||||
|
||||
status.textContent = 'Touch your YubiKey when it flashes...';
|
||||
let credential;
|
||||
try {
|
||||
credential = await startRegistration(options);
|
||||
} catch (e: any) {
|
||||
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Verifying credential...';
|
||||
const verifyRes = await fetch('/api/admin/webauthn/register-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credential),
|
||||
});
|
||||
const result = await verifyRes.json();
|
||||
|
||||
if (result.verified) {
|
||||
status.textContent = 'Success! Redirecting to login...';
|
||||
setTimeout(() => { window.location.href = '/admin/login'; }, 1000);
|
||||
} else {
|
||||
status.textContent = `Registration failed: ${result.error ?? 'Unknown error'}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
) : (
|
||||
<script>
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
const btn = document.getElementById('login-btn') as HTMLButtonElement;
|
||||
const status = document.getElementById('login-status') as HTMLParagraphElement;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
status.textContent = 'Requesting authentication options...';
|
||||
|
||||
try {
|
||||
const optRes = await fetch('/api/admin/webauthn/login-options');
|
||||
if (!optRes.ok) {
|
||||
const err = await optRes.json();
|
||||
status.textContent = `Error: ${err.error ?? 'Could not get options'}`;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
const options = await optRes.json();
|
||||
|
||||
status.textContent = 'Touch your YubiKey when it flashes...';
|
||||
let credential;
|
||||
try {
|
||||
credential = await startAuthentication(options);
|
||||
} catch (e: any) {
|
||||
status.textContent = `Cancelled or failed: ${e.message ?? 'Unknown error'}`;
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
status.textContent = 'Verifying...';
|
||||
const verifyRes = await fetch('/api/admin/webauthn/login-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credential),
|
||||
});
|
||||
const result = await verifyRes.json();
|
||||
|
||||
if (result.verified) {
|
||||
status.textContent = 'Authenticated! Redirecting...';
|
||||
window.location.href = '/admin';
|
||||
} else {
|
||||
status.textContent = `Authentication failed: ${result.error ?? 'Unknown error'}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (e: any) {
|
||||
status.textContent = `Unexpected error: ${e.message ?? 'Unknown error'}`;
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
)}
|
||||
|
||||
<style>
|
||||
<style>
|
||||
.login-wrap {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
@@ -195,10 +90,19 @@ const registrationMode = !hasCredentials();
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-primary { color: var(--color-accent); }
|
||||
|
||||
.status-text {
|
||||
margin-top: var(--space-sm);
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-dim);
|
||||
min-height: 1.4em;
|
||||
.token-form {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
.token-label {
|
||||
color: var(--color-text-dim);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.token-input {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
</style>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { createSession, SESSION_COOKIE, sessionCookieOptions } from '../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
function tokenMatches(input: string, expected: string): boolean {
|
||||
const a = Buffer.from(input);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
const expectedToken = process.env.ADMIN_SECRET_TOKEN?.trim();
|
||||
if (!expectedToken) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const token = String(formData.get('token') ?? '').trim();
|
||||
|
||||
if (!tokenMatches(token, expectedToken)) {
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: { Location: '/admin/login?tokenError=1' },
|
||||
});
|
||||
}
|
||||
|
||||
const sessionId = createSession('admin');
|
||||
cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60));
|
||||
|
||||
return new Response(null, {
|
||||
status: 303,
|
||||
headers: { Location: '/admin' },
|
||||
});
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createAuthenticationOptions, hasCredentials } from '../../../../lib/webauthn';
|
||||
import { generateId, CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ cookies }) => {
|
||||
if (!hasCredentials()) {
|
||||
return new Response(JSON.stringify({ error: 'No credentials registered' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const challengeId = generateId();
|
||||
const options = await createAuthenticationOptions(challengeId);
|
||||
|
||||
cookies.set(CHALLENGE_COOKIE, challengeId, sessionCookieOptions(5 * 60));
|
||||
|
||||
return new Response(JSON.stringify(options), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { verifyAuthentication } from '../../../../lib/webauthn';
|
||||
import {
|
||||
createSession,
|
||||
CHALLENGE_COOKIE,
|
||||
SESSION_COOKIE,
|
||||
sessionCookieOptions,
|
||||
} from '../../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
const challengeId = cookies.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!challengeId) {
|
||||
return new Response(JSON.stringify({ error: 'No challenge found' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let credential;
|
||||
try {
|
||||
credential = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await verifyAuthentication(credential, challengeId);
|
||||
|
||||
// Clear challenge cookie
|
||||
cookies.delete(CHALLENGE_COOKIE, { path: '/' });
|
||||
|
||||
if (result.verified && result.userId) {
|
||||
const sessionId = createSession(result.userId);
|
||||
cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60));
|
||||
|
||||
return new Response(JSON.stringify({ verified: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ verified: false, error: result.error ?? 'Authentication failed' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createRegistrationOptions, hasCredentials } from '../../../../lib/webauthn';
|
||||
import { generateId, CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const GET: APIRoute = async ({ cookies }) => {
|
||||
// Only allow registration when no credentials exist yet
|
||||
if (hasCredentials()) {
|
||||
return new Response(JSON.stringify({ error: 'Admin credential already registered' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const challengeId = generateId();
|
||||
const options = await createRegistrationOptions(challengeId);
|
||||
|
||||
cookies.set(CHALLENGE_COOKIE, challengeId, sessionCookieOptions(5 * 60));
|
||||
|
||||
return new Response(JSON.stringify(options), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { verifyRegistration, hasCredentials } from '../../../../lib/webauthn';
|
||||
import { CHALLENGE_COOKIE, sessionCookieOptions } from '../../../../lib/auth';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
if (hasCredentials()) {
|
||||
return new Response(JSON.stringify({ error: 'Admin credential already registered' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const challengeId = cookies.get(CHALLENGE_COOKIE)?.value;
|
||||
if (!challengeId) {
|
||||
return new Response(JSON.stringify({ error: 'No challenge found' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
let credential;
|
||||
try {
|
||||
credential = await request.json();
|
||||
} catch {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const result = await verifyRegistration(credential, challengeId);
|
||||
|
||||
// Clear the challenge cookie
|
||||
cookies.delete(CHALLENGE_COOKIE, { path: '/' });
|
||||
|
||||
if (result.verified) {
|
||||
return new Response(JSON.stringify({ verified: true }), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ verified: false, error: result.error ?? 'Verification failed' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user