Add password reset feature for admin users, enforce password change on login, and enhance session management
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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] ??
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type SessionUser = {
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
mustChangePassword: boolean;
|
||||
};
|
||||
|
||||
export type MotorcycleSummary = {
|
||||
|
||||
Reference in New Issue
Block a user