Add WebAuthn support for admin login and kiosk mode

This commit is contained in:
2026-07-26 21:12:59 +02:00
parent 3c8d9297d7
commit 2b85152bb9
13 changed files with 466 additions and 9 deletions
+60 -1
View File
@@ -1,7 +1,9 @@
import Link from "next/link";
import { changePasswordAction } from "@/app/actions/auth";
import { changePasswordAction, removeAdminSecurityKeyAction } from "@/app/actions/auth";
import { AdminSecurityKeys } from "@/components/admin-security-keys";
import { Flash } from "@/components/flash";
import { requireSessionUser } from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { queryMessage } from "@/lib/format";
export const runtime = "nodejs";
@@ -12,7 +14,18 @@ export default async function AccountPage({
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireSessionUser();
initDatabase();
const params = await searchParams;
const securityKeys = user.role === "admin"
? (db
.prepare(
`SELECT id, label, created_at, last_used_at
FROM webauthn_credentials
WHERE user_id = ?
ORDER BY created_at ASC`,
)
.all(user.id) as { id: number; label: string | null; created_at: string; last_used_at: string | null }[])
: [];
return (
<>
@@ -64,6 +77,52 @@ export default async function AccountPage({
</div>
</form>
</section>
{user.role === "admin" && (
<section className="section-block">
<div className="section-heading">
<div>
<p className="eyebrow">Admin-Schutz</p>
<h2>YubiKeys</h2>
</div>
</div>
<div className="panel stack-form">
<p className="muted">
Du kannst bis zu zwei YubiKeys hinterlegen. Sobald mindestens ein Schlüssel aktiv ist, wird er beim Admin-Login verpflichtend.
</p>
{securityKeys.length === 0 ? (
<p className="muted">Noch kein YubiKey registriert.</p>
) : (
<table className="data-table">
<thead>
<tr>
<th>Schlüssel</th>
<th>Registriert</th>
<th>Zuletzt genutzt</th>
<th></th>
</tr>
</thead>
<tbody>
{securityKeys.map((key) => (
<tr key={key.id}>
<td>{key.label ?? `YubiKey #${key.id}`}</td>
<td>{key.created_at.replace("T", " ").slice(0, 16)}</td>
<td>{key.last_used_at ? key.last_used_at.replace("T", " ").slice(0, 16) : "—"}</td>
<td>
<form action={removeAdminSecurityKeyAction} className="inline-form">
<input type="hidden" name="id" value={key.id} />
<button className="link-button danger" type="submit">Entfernen</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
)}
<AdminSecurityKeys disabled={securityKeys.length >= 2} />
</div>
</section>
)}
</>
);
}
+7 -1
View File
@@ -19,6 +19,8 @@ type BikeRow = {
year: number | null;
plate: string | null;
current_km: number;
created_by: number;
owner_name: string;
tuev_due_date: string | null;
tuev_due_km: number | null;
inspection_due_date: string | null;
@@ -47,7 +49,7 @@ export default async function Dashboard({
const visible = motorcycleAccessFilter(user);
const motorcycles = db
.prepare(
`SELECT m.*,
`SELECT m.*, owner.name AS owner_name,
(SELECT due_date FROM maintenance_events e
WHERE e.motorcycle_id = m.id AND e.type = 'tuev_hu'
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
@@ -65,6 +67,7 @@ export default async function Dashboard({
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
FROM motorcycles m
JOIN users owner ON owner.id = m.created_by
WHERE ${visible.clause}
ORDER BY m.nickname COLLATE NOCASE`,
)
@@ -140,6 +143,9 @@ export default async function Dashboard({
<p className="eyebrow">{bike.brand} · {bike.model}</p>
<h2>{bike.nickname}</h2>
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}{bike.year ? ` · ${bike.year}` : ""}</p>
<p className="muted">
Besitzer: {bike.created_by === user.id ? "Du" : bike.owner_name}
</p>
</div>
<InspectionSticker dueDate={bike.tuev_due_date} status={tuevStatus} />
</div>
+30 -3
View File
@@ -4,12 +4,15 @@ import { compareSync, hashSync } from "bcryptjs";
import { redirect } from "next/navigation";
import {
authenticateUser,
clearPendingAdminMfaCookie,
clearSessionCookie,
setPendingAdminMfaCookie,
requireSessionUser,
setSessionCookie,
requireAdminUser,
} from "@/lib/auth";
import { db, initDatabase } from "@/lib/db";
import { passwordValue, text } from "@/lib/validation";
import { idValue, passwordValue, usernameValue } from "@/lib/validation";
function messageUrl(path: string, kind: "error" | "success", message: string) {
return `${path}?${kind}=${encodeURIComponent(message)}`;
@@ -19,19 +22,31 @@ export async function loginAction(formData: FormData) {
let username: string;
let password: string;
try {
username = text(formData, "username", { required: true, max: 100 });
password = String(formData.get("password") ?? "");
username = usernameValue(formData);
password = passwordValue(formData, "password");
} catch (error) {
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
}
const user = authenticateUser(username, password);
if (!user) redirect(messageUrl("/login", "error", "Benutzername oder Passwort ist falsch."));
initDatabase();
if (user.role === "admin") {
const keyCount = db
.prepare("SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?")
.get(user.id) as { count: number };
if (keyCount.count > 0) {
await setPendingAdminMfaCookie(user.id);
redirect("/login/yubikey");
}
}
await clearPendingAdminMfaCookie();
await setSessionCookie(user);
redirect("/");
}
export async function logoutAction() {
await clearPendingAdminMfaCookie();
await clearSessionCookie();
redirect("/login");
}
@@ -65,3 +80,15 @@ export async function changePasswordAction(formData: FormData) {
db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashSync(newPassword, 12), user.id);
redirect(messageUrl("/account", "success", "Passwort wurde geändert."));
}
export async function removeAdminSecurityKeyAction(formData: FormData) {
const user = await requireAdminUser();
initDatabase();
try {
const id = idValue(formData);
db.prepare("DELETE FROM webauthn_credentials WHERE id = ? AND user_id = ?").run(id, user.id);
redirect(messageUrl("/account", "success", "Sicherheitsschlüssel entfernt."));
} catch (error) {
redirect(messageUrl("/account", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
}
}
+2 -1
View File
@@ -15,6 +15,7 @@ import {
integer,
maintenanceType,
text,
usernameValue,
} from "@/lib/validation";
const MAX_FILE_SIZE = 8 * 1024 * 1024;
@@ -490,7 +491,7 @@ export async function shareMotorcycleAction(formData: FormData) {
let motorcycleId = 0;
try {
motorcycleId = idValue(formData, "motorcycle_id");
const username = text(formData, "username", { required: true, max: 100 });
const username = usernameValue(formData, "username");
if (!canManageShares(user, motorcycleId)) {
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
}
+2 -2
View File
@@ -5,7 +5,7 @@ import { hashSync } from "bcryptjs";
import { redirect, unstable_rethrow } from "next/navigation";
import { requireAdminUser } from "@/lib/auth";
import { db, initDatabase, transaction } from "@/lib/db";
import { idValue, passwordValue, roleValue, text } from "@/lib/validation";
import { idValue, passwordValue, roleValue, usernameValue } from "@/lib/validation";
import type { UserRole } from "@/lib/types";
function go(kind: "error" | "success", message: string): never {
@@ -23,7 +23,7 @@ export async function createUserAction(formData: FormData) {
await requireAdminUser();
initDatabase();
try {
const name = text(formData, "name", { required: true, max: 100 });
const name = usernameValue(formData, "name");
const existing = db
.prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1")
.get(name);
+2
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { loginAction } from "@/app/actions/auth";
import { Flash } from "@/components/flash";
@@ -30,6 +31,7 @@ export default async function LoginPage({
<p className="eyebrow">Willkommen zurück</p>
<h1>Anmelden</h1>
<p className="muted">Zugang nur für eingerichtete Familienmitglieder.</p>
<p className="muted"><Link href="/kiosk">Kiosk-Modus: Wartungsdaten per Kennzeichen</Link></p>
</div>
<Flash error={queryMessage(params, "error")} />
<form action={loginAction} className="stack-form">
+43
View File
@@ -11,8 +11,16 @@ type SessionPayload = {
exp: number;
};
type AdminMfaPayload = {
uid: number;
purpose: "admin-mfa";
exp: number;
};
export const SESSION_COOKIE = "moped_session";
export const ADMIN_MFA_COOKIE = "moped_admin_mfa";
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
const ADMIN_MFA_TTL_SECONDS = 60 * 5;
function getSecretKey() {
return new TextEncoder().encode(getAuthSecret());
@@ -43,6 +51,41 @@ export async function clearSessionCookie() {
(await cookies()).delete(SESSION_COOKIE);
}
export async function setPendingAdminMfaCookie(userId: number) {
const token = await new SignJWT({ uid: userId, purpose: "admin-mfa" })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${ADMIN_MFA_TTL_SECONDS}s`)
.sign(getSecretKey());
const cookieStore = await cookies();
cookieStore.set(ADMIN_MFA_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: ADMIN_MFA_TTL_SECONDS,
path: "/",
priority: "high",
});
}
export async function getPendingAdminMfaUserId() {
const token = (await cookies()).get(ADMIN_MFA_COOKIE)?.value;
if (!token) return null;
try {
const result = await jwtVerify(token, getSecretKey());
const payload = result.payload as unknown as AdminMfaPayload;
return Number.isSafeInteger(payload.uid) && payload.uid > 0 && payload.purpose === "admin-mfa"
? payload.uid
: null;
} catch {
return null;
}
}
export async function clearPendingAdminMfaCookie() {
(await cookies()).delete(ADMIN_MFA_COOKIE);
}
export async function getSessionUser(): Promise<SessionUser | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
+28
View File
@@ -105,6 +105,28 @@ function ensureSchema() {
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE TABLE IF NOT EXISTS webauthn_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0 CHECK (counter >= 0),
transports TEXT,
label TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_used_at TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS webauthn_challenges (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
challenge TEXT NOT NULL,
purpose TEXT NOT NULL CHECK (purpose IN ('registration', 'authentication')),
expires_at TEXT NOT NULL,
used_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
) STRICT;
CREATE INDEX IF NOT EXISTS idx_maintenance_motorcycle
ON maintenance_events(motorcycle_id, event_date DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_maintenance_due
@@ -113,6 +135,10 @@ function ensureSchema() {
ON trips(motorcycle_id, trip_date DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
ON motorcycle_shares(user_id, motorcycle_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_credentials_user
ON webauthn_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_webauthn_challenges_lookup
ON webauthn_challenges(user_id, purpose, expires_at, used_at);
`);
const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
@@ -196,6 +222,8 @@ function ensureSchema() {
}
db.exec("CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, name COLLATE NOCASE)");
db.prepare("DELETE FROM webauthn_challenges WHERE used_at IS NOT NULL OR expires_at < ?")
.run(new Date().toISOString());
}
function ensureInitialAdmin() {
+9
View File
@@ -8,6 +8,15 @@ export function text(
const value = String(formData.get(key) ?? "").trim();
if (options.required && !value) throw new Error(`${key} ist erforderlich.`);
if (value.length > (options.max ?? 5000)) throw new Error(`${key} ist zu lang.`);
if (value.includes("\u0000")) throw new Error(`${key} enthält ungültige Zeichen.`);
return value;
}
export function usernameValue(formData: FormData, key = "username") {
const value = text(formData, key, { required: true, max: 100 });
if (!/^[\p{L}\p{N}][\p{L}\p{N} ._-]{0,99}$/u.test(value)) {
throw new Error("Benutzername enthält ungültige Zeichen.");
}
return value;
}