Add WebAuthn support for admin login and kiosk mode
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user