Implement token-based admin login and remove WebAuthn support

This commit is contained in:
2026-03-07 21:19:00 +01:00
parent 88e00e5d41
commit 57c1478cb5
15 changed files with 158 additions and 654 deletions
+39 -135
View File
@@ -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>