Add password reset feature for admin users, enforce password change on login, and enhance session management

This commit is contained in:
2026-07-27 22:02:48 +02:00
parent c63d1a641f
commit e422bf6506
10 changed files with 259 additions and 20 deletions
+13 -1
View File
@@ -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,12 +97,20 @@ export default async function AdminUsersPage({
</td>
<td>{formatDate(row.created_at.slice(0, 10))}</td>
<td>
{!isSelf && !lastAdmin && (
{!isSelf && (
<div className="user-admin-actions">
<ResetUserPassword
userId={row.id}
requiresYubiKey={currentAdminKeyCount.count > 0}
/>
{!lastAdmin && (
<form action={deleteUserAction} className="inline-form">
<input type="hidden" name="id" value={row.id} />
<button className="link-button danger" type="submit">Löschen</button>
</form>
)}
</div>
)}
</td>
</tr>
);
+38 -2
View File
@@ -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();
+61 -2
View File
@@ -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<ResetUserPasswordState> {
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();
@@ -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] ??
+1 -1
View File
@@ -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 });
}
+41
View File
@@ -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);
+2 -1
View File
@@ -15,7 +15,8 @@ export default async function LoginPage({
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
initDatabase();
if (await getSessionUser()) redirect("/");
const user = await getSessionUser();
if (user) redirect(user.mustChangePassword ? "/change-password" : "/");
const params = await searchParams;
return (
<main className="login-page">
+73 -6
View File
@@ -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<SessionUser | null> {
const token = (await cookies()).get(SESSION_COOKIE)?.value;
if (!token) return null;
@@ -148,7 +188,8 @@ export async function getSessionUser(): Promise<SessionUser | null> {
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<SessionUser | null> {
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<SessionUser | null> {
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),
};
}
+6
View File
@@ -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")) {
+1
View File
@@ -15,6 +15,7 @@ export type SessionUser = {
name: string;
email: string;
role: UserRole;
mustChangePassword: boolean;
};
export type MotorcycleSummary = {