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 = {