From e422bf65063ba2ede7a84c6bc096d7575ef506e8 Mon Sep 17 00:00:00 2001 From: linus Date: Mon, 27 Jul 2026 22:02:48 +0200 Subject: [PATCH] Add password reset feature for admin users, enforce password change on login, and enhance session management --- src/app/(app)/admin/users/page.tsx | 22 ++++-- src/app/actions/auth.ts | 40 +++++++++- src/app/actions/users.ts | 63 ++++++++++++++- .../api/auth/webauthn/login/verify/route.ts | 22 +++++- src/app/attachments/[id]/route.ts | 2 +- src/app/globals.css | 41 ++++++++++ src/app/login/page.tsx | 3 +- src/lib/auth.ts | 79 +++++++++++++++++-- src/lib/db.ts | 6 ++ src/lib/types.ts | 1 + 10 files changed, 259 insertions(+), 20 deletions(-) diff --git a/src/app/(app)/admin/users/page.tsx b/src/app/(app)/admin/users/page.tsx index 08178e5..bf32ea3 100644 --- a/src/app/(app)/admin/users/page.tsx +++ b/src/app/(app)/admin/users/page.tsx @@ -5,6 +5,7 @@ import { deleteUserAction, } from "@/app/actions/users"; import { Flash } from "@/components/flash"; +import { ResetUserPassword } from "@/components/reset-user-password"; import { requireAdminUser } from "@/lib/auth"; import { db, initDatabase } from "@/lib/db"; import { formatDate, queryMessage } from "@/lib/format"; @@ -34,6 +35,9 @@ export default async function AdminUsersPage({ ) .all() as UserRow[]; const adminCount = users.filter((u) => u.role === "admin").length; + const currentAdminKeyCount = db + .prepare("SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?") + .get(currentUser.id) as { count: number }; return ( <> @@ -93,11 +97,19 @@ export default async function AdminUsersPage({ {formatDate(row.created_at.slice(0, 10))} - {!isSelf && !lastAdmin && ( -
- - -
+ {!isSelf && ( +
+ 0} + /> + {!lastAdmin && ( +
+ + +
+ )} +
)} diff --git a/src/app/actions/auth.ts b/src/app/actions/auth.ts index cb845ea..13d8ec7 100644 --- a/src/app/actions/auth.ts +++ b/src/app/actions/auth.ts @@ -1,11 +1,12 @@ "use server"; import { compareSync, hashSync } from "bcryptjs"; -import { redirect } from "next/navigation"; +import { redirect, unstable_rethrow } from "next/navigation"; import { authenticateUser, clearPendingAdminMfaCookie, clearSessionCookie, + getSessionUser, setPendingAdminMfaCookie, requireSessionUser, setSessionCookie, @@ -77,10 +78,45 @@ export async function changePasswordAction(formData: FormData) { redirect(messageUrl("/account", "error", "Das aktuelle Passwort ist falsch.")); } - db.prepare("UPDATE users SET password_hash = ? WHERE id = ?").run(hashSync(newPassword, 12), user.id); + db.prepare( + "UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?", + ).run(hashSync(newPassword, 12), user.id); redirect(messageUrl("/account", "success", "Passwort wurde geändert.")); } +export async function completeRequiredPasswordChangeAction(formData: FormData) { + const user = await getSessionUser(); + if (!user) redirect("/login"); + if (!user.mustChangePassword) redirect("/"); + initDatabase(); + + try { + const newPassword = passwordValue(formData, "new_password"); + if (newPassword !== String(formData.get("confirm_password") ?? "")) { + throw new Error("Die neuen Passwörter stimmen nicht überein."); + } + const row = db.prepare("SELECT password_hash FROM users WHERE id = ?").get(user.id) as + | { password_hash: string } + | undefined; + if (!row) throw new Error("Benutzer wurde nicht gefunden."); + if (compareSync(newPassword, row.password_hash)) { + throw new Error("Das neue Passwort muss sich vom temporären Passwort unterscheiden."); + } + + db.prepare( + "UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ? AND active = 1", + ).run(hashSync(newPassword, 12), user.id); + redirect("/"); + } catch (error) { + unstable_rethrow(error); + redirect(messageUrl( + "/change-password", + "error", + error instanceof Error ? error.message : "Das Passwort konnte nicht geändert werden.", + )); + } +} + export async function removeAdminSecurityKeyAction(formData: FormData) { const user = await requireAdminUser(); initDatabase(); diff --git a/src/app/actions/users.ts b/src/app/actions/users.ts index 518725b..e2756c5 100644 --- a/src/app/actions/users.ts +++ b/src/app/actions/users.ts @@ -1,13 +1,22 @@ "use server"; -import { randomUUID } from "node:crypto"; +import { randomBytes, randomUUID } from "node:crypto"; import { hashSync } from "bcryptjs"; import { redirect, unstable_rethrow } from "next/navigation"; -import { requireAdminUser } from "@/lib/auth"; +import { + consumePasswordResetMfaAuthorization, + requireAdminUser, +} from "@/lib/auth"; import { db, initDatabase, transaction } from "@/lib/db"; import { idValue, passwordValue, roleValue, usernameValue } from "@/lib/validation"; import type { UserRole } from "@/lib/types"; +export type ResetUserPasswordState = { + error?: string; + password?: string; + userName?: string; +}; + function go(kind: "error" | "success", message: string): never { redirect(`/admin/users?${kind}=${encodeURIComponent(message)}`); } @@ -70,6 +79,56 @@ export async function changeUserRoleAction(formData: FormData) { } } +export async function resetUserPasswordAction( + _previousState: ResetUserPasswordState, + formData: FormData, +): Promise { + const currentUser = await requireAdminUser(); + initDatabase(); + + try { + const id = idValue(formData); + if (id === currentUser.id) { + throw new Error("Das eigene Passwort kann unter „Konto“ geändert werden."); + } + const keyCount = db + .prepare("SELECT COUNT(*) AS count FROM webauthn_credentials WHERE user_id = ?") + .get(currentUser.id) as { count: number }; + if ( + keyCount.count > 0 + && !await consumePasswordResetMfaAuthorization(currentUser.id, id) + ) { + throw new Error("Der Passwort-Reset muss mit deinem YubiKey bestätigt werden."); + } + + const password = randomBytes(18).toString("base64url"); + const passwordHash = hashSync(password, 12); + let userName = ""; + transaction(() => { + const target = db + .prepare("SELECT name FROM users WHERE id = ? AND active = 1") + .get(id) as { name: string } | undefined; + if (!target) throw new Error("Benutzer wurde nicht gefunden."); + + userName = target.name; + db.prepare( + "UPDATE users SET password_hash = ?, must_change_password = 1 WHERE id = ?", + ) + .run(passwordHash, id); + db.prepare( + `UPDATE user_sessions + SET revoked_at = CURRENT_TIMESTAMP + WHERE user_id = ? AND revoked_at IS NULL`, + ).run(id); + }); + + return { password, userName }; + } catch (error) { + unstable_rethrow(error); + return { error: errorMessage(error) }; + } +} + export async function deleteUserAction(formData: FormData) { const currentUser = await requireAdminUser(); initDatabase(); diff --git a/src/app/api/auth/webauthn/login/verify/route.ts b/src/app/api/auth/webauthn/login/verify/route.ts index 9ce494d..8f197fe 100644 --- a/src/app/api/auth/webauthn/login/verify/route.ts +++ b/src/app/api/auth/webauthn/login/verify/route.ts @@ -71,11 +71,27 @@ export async function POST(request: Request) { ); const user = db - .prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1 AND role = 'admin'") - .get(userId) as { id: number; name: string; email: string; role: "admin" } | undefined; + .prepare( + `SELECT id, name, email, role, + must_change_password AS mustChangePassword + FROM users + WHERE id = ? AND active = 1 AND role = 'admin'`, + ) + .get(userId) as + | { + id: number; + name: string; + email: string; + role: "admin"; + mustChangePassword: number; + } + | undefined; if (!user) return NextResponse.json({ error: "Benutzer wurde nicht gefunden." }, { status: 401 }); - const token = await createSessionToken(user, { + const token = await createSessionToken({ + ...user, + mustChangePassword: user.mustChangePassword === 1, + }, { userAgent: request.headers.get("user-agent"), ipAddress: request.headers.get("x-forwarded-for")?.split(",")[0] ?? diff --git a/src/app/attachments/[id]/route.ts b/src/app/attachments/[id]/route.ts index c0425ed..04bc180 100644 --- a/src/app/attachments/[id]/route.ts +++ b/src/app/attachments/[id]/route.ts @@ -16,7 +16,7 @@ type AttachmentRow = { export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments/[id]">) { const user = await getSessionUser(); - if (!user) { + if (!user || user.mustChangePassword) { return new Response("Nicht angemeldet.", { status: 401 }); } diff --git a/src/app/globals.css b/src/app/globals.css index 3ebe14b..292cffe 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -675,6 +675,47 @@ input[type="file"] { padding: 0.4rem; background: var(--paper); } .inline-form { display: inline-flex; align-items: center; gap: 0.4rem; margin: 0; } .inline-form select { width: auto; } +.user-admin-actions, +.password-reset { + display: flex; + align-items: flex-start; + gap: 0.65rem; +} + +.user-admin-actions { + flex-direction: column; +} + +.password-reset { + flex-direction: column; +} + +.password-reset-error { + margin: 0; + color: var(--rust); + font-size: 0.82rem; +} + +.temporary-password { + display: grid; + gap: 0.3rem; + min-width: 17rem; + padding: 0.65rem; + border: 1px solid var(--green); + border-radius: 6px; + background: var(--green-soft); +} + +.temporary-password code { + user-select: all; + overflow-wrap: anywhere; + font-size: 1rem; +} + +.temporary-password small { + color: var(--steel); +} + .badge { margin-left: 0.5rem; font-family: var(--font-head); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 335f225..620d89f 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -15,7 +15,8 @@ export default async function LoginPage({ searchParams: Promise>; }) { initDatabase(); - if (await getSessionUser()) redirect("/"); + const user = await getSessionUser(); + if (user) redirect(user.mustChangePassword ? "/change-password" : "/"); const params = await searchParams; return (
diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 65940dc..337d520 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -19,8 +19,16 @@ type AdminMfaPayload = { exp: number; }; +type PasswordResetMfaPayload = { + uid: number; + target: number; + purpose: "password-reset"; + exp: number; +}; + export const SESSION_COOKIE = "moped_session"; export const ADMIN_MFA_COOKIE = "moped_admin_mfa"; +export const PASSWORD_RESET_MFA_COOKIE = "moped_password_reset_mfa"; const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; const ADMIN_MFA_TTL_SECONDS = 60 * 5; @@ -137,6 +145,38 @@ export async function clearPendingAdminMfaCookie() { (await cookies()).delete(ADMIN_MFA_COOKIE); } +export async function createPasswordResetMfaToken(adminId: number, targetUserId: number) { + return new SignJWT({ + uid: adminId, + target: targetUserId, + purpose: "password-reset", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(`${ADMIN_MFA_TTL_SECONDS}s`) + .sign(getSecretKey()); +} + +export async function consumePasswordResetMfaAuthorization( + adminId: number, + targetUserId: number, +) { + const cookieStore = await cookies(); + const token = cookieStore.get(PASSWORD_RESET_MFA_COOKIE)?.value; + cookieStore.delete(PASSWORD_RESET_MFA_COOKIE); + if (!token) return false; + + try { + const result = await jwtVerify(token, getSecretKey()); + const payload = result.payload as unknown as PasswordResetMfaPayload; + return payload.uid === adminId + && payload.target === targetUserId + && payload.purpose === "password-reset"; + } catch { + return false; + } +} + export async function getSessionUser(): Promise { const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; @@ -148,7 +188,8 @@ export async function getSessionUser(): Promise { initDatabase(); const user = db .prepare( - `SELECT u.id, u.name, u.email, u.role + `SELECT u.id, u.name, u.email, u.role, + u.must_change_password AS mustChangePassword FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.user_id = ? @@ -157,7 +198,13 @@ export async function getSessionUser(): Promise { AND u.active = 1`, ) .get(payload.sid, Number(payload.id)) as - | { id: number; name: string; email: string; role: UserRole } + | { + id: number; + name: string; + email: string; + role: UserRole; + mustChangePassword: number; + } | undefined; if (!user) return null; db.prepare( @@ -166,7 +213,9 @@ export async function getSessionUser(): Promise { WHERE id = ? AND last_seen_at < datetime('now', '-30 seconds')`, ).run(payload.sid); - return user ?? null; + return user + ? { ...user, mustChangePassword: user.mustChangePassword === 1 } + : null; } catch { return null; } @@ -187,6 +236,7 @@ export async function getCurrentSessionId() { export async function requireSessionUser() { const user = await getSessionUser(); if (!user) redirect("/login"); + if (user.mustChangePassword) redirect("/change-password"); return user; } @@ -200,12 +250,29 @@ export function authenticateUser(username: string, password: string): SessionUse initDatabase(); const users = db .prepare( - "SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC", + `SELECT id, name, email, role, password_hash, + must_change_password AS mustChangePassword + FROM users + WHERE name = ? COLLATE NOCASE AND active = 1 + ORDER BY id ASC`, ) - .all(username.trim()) as (SessionUser & { password_hash: string })[]; + .all(username.trim()) as { + id: number; + name: string; + email: string; + role: UserRole; + password_hash: string; + mustChangePassword: number; + }[]; if (users.length !== 1) return null; const user = users[0]; if (!user || !compareSync(password, user.password_hash)) return null; - return { id: user.id, name: user.name, email: user.email, role: user.role }; + return { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + mustChangePassword: Boolean(user.mustChangePassword), + }; } diff --git a/src/lib/db.ts b/src/lib/db.ts index 4fe722f..a0ef24c 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -29,6 +29,7 @@ function ensureSchema() { password_hash TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('admin', 'member')), active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), + must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)), deleted_at TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ) STRICT; @@ -226,6 +227,11 @@ function ensureSchema() { if (!userColumns.some((column) => column.name === "deleted_at")) { db.exec("ALTER TABLE users ADD COLUMN deleted_at TEXT"); } + if (!userColumns.some((column) => column.name === "must_change_password")) { + db.exec( + "ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1))", + ); + } const motorcycleColumns = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[]; if (!motorcycleColumns.some((column) => column.name === "tire_pressure_front_bar")) { diff --git a/src/lib/types.ts b/src/lib/types.ts index de86b91..7c994f4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -15,6 +15,7 @@ export type SessionUser = { name: string; email: string; role: UserRole; + mustChangePassword: boolean; }; export type MotorcycleSummary = {