Просмотр исходного кода

RHL-043 feat(admin-users): add temporary password reset API

Code_Uwe 1 месяц назад
Родитель
Сommit
b861e93b76

+ 78 - 30
app/api/admin/users/[userId]/route.js

@@ -1,7 +1,10 @@
+import bcrypt from "bcryptjs";
+
 import User, { USER_ROLES } from "@/models/user";
 import { getDb } from "@/lib/db";
 import { getSession } from "@/lib/auth/session";
 import { requireUserManagement } from "@/lib/auth/permissions";
+import { generateAdminTemporaryPassword } from "@/lib/auth/adminTempPassword";
 import {
 	withErrorHandling,
 	json,
@@ -17,6 +20,7 @@ const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
 
 const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
 const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+const BCRYPT_SALT_ROUNDS = 12;
 
 const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
 const ALLOWED_UPDATE_FIELDS = Object.freeze([
@@ -97,6 +101,27 @@ function pickDuplicateField(err) {
 	return null;
 }
 
+async function resolveValidatedUserId(ctx) {
+	const { userId } = await ctx.params;
+
+	if (!userId) {
+		throw badRequest(
+			"VALIDATION_MISSING_PARAM",
+			"Missing required route parameter(s)",
+			{ params: ["userId"] },
+		);
+	}
+
+	if (!OBJECT_ID_RE.test(String(userId))) {
+		throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
+			field: "userId",
+			value: userId,
+		});
+	}
+
+	return String(userId);
+}
+
 export const PATCH = withErrorHandling(
 	async function PATCH(request, ctx) {
 		const session = await getSession();
@@ -106,23 +131,7 @@ export const PATCH = withErrorHandling(
 		}
 
 		requireUserManagement(session);
-
-		const { userId } = await ctx.params;
-
-		if (!userId) {
-			throw badRequest(
-				"VALIDATION_MISSING_PARAM",
-				"Missing required route parameter(s)",
-				{ params: ["userId"] },
-			);
-		}
-
-		if (!OBJECT_ID_RE.test(String(userId))) {
-			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
-				field: "userId",
-				value: userId,
-			});
-		}
+		const userId = await resolveValidatedUserId(ctx);
 
 		let body;
 		try {
@@ -350,8 +359,8 @@ export const PATCH = withErrorHandling(
 	{ logPrefix: "[api/admin/users/[userId]]" },
 );
 
-export const DELETE = withErrorHandling(
-	async function DELETE(request, ctx) {
+export const POST = withErrorHandling(
+	async function POST(_request, ctx) {
 		const session = await getSession();
 
 		if (!session) {
@@ -360,25 +369,64 @@ export const DELETE = withErrorHandling(
 
 		requireUserManagement(session);
 
-		const { userId } = await ctx.params;
+		const userId = await resolveValidatedUserId(ctx);
 
-		if (!userId) {
+		if (String(session.userId) === userId) {
 			throw badRequest(
-				"VALIDATION_MISSING_PARAM",
-				"Missing required route parameter(s)",
-				{ params: ["userId"] },
+				"VALIDATION_INVALID_FIELD",
+				"Cannot reset current user password",
+				{
+					field: "userId",
+					reason: "SELF_PASSWORD_RESET_FORBIDDEN",
+				},
 			);
 		}
 
-		if (!OBJECT_ID_RE.test(String(userId))) {
-			throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
-				field: "userId",
-				value: userId,
-			});
+		await getDb();
+
+		const user = await User.findById(userId).exec();
+		if (!user) {
+			throw notFound("USER_NOT_FOUND", "Not found", { userId });
 		}
 
+		const temporaryPassword = generateAdminTemporaryPassword();
+		const passwordHash = await bcrypt.hash(
+			temporaryPassword,
+			BCRYPT_SALT_ROUNDS,
+		);
+
+		user.passwordHash = passwordHash;
+		user.mustChangePassword = true;
+		user.passwordResetToken = null;
+		user.passwordResetExpiresAt = null;
+
+		await user.save();
+
+		return json(
+			{
+				ok: true,
+				user: toSafeUser(user),
+				temporaryPassword,
+			},
+			200,
+		);
+	},
+	{ logPrefix: "[api/admin/users/[userId]]" },
+);
+
+export const DELETE = withErrorHandling(
+	async function DELETE(request, ctx) {
+		const session = await getSession();
+
+		if (!session) {
+			throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
+		}
+
+		requireUserManagement(session);
+		const userId = await resolveValidatedUserId(ctx);
+
 		// Safety: prevent deleting your own currently active account.
-		if (String(session.userId) === String(userId)) {
+		if (String(session.userId) === userId) {
 			throw badRequest(
 				"VALIDATION_INVALID_FIELD",
 				"Cannot delete current user",

+ 179 - 1
app/api/admin/users/[userId]/route.test.js

@@ -28,11 +28,25 @@ vi.mock("@/models/user", () => {
 	};
 });
 
+vi.mock("bcryptjs", () => {
+	const hash = vi.fn();
+	return {
+		default: { hash },
+		hash,
+	};
+});
+
+vi.mock("@/lib/auth/adminTempPassword", () => ({
+	generateAdminTemporaryPassword: vi.fn(),
+}));
+
 import { getSession } from "@/lib/auth/session";
 import { getDb } from "@/lib/db";
 import User from "@/models/user";
+import { hash as bcryptHash } from "bcryptjs";
+import { generateAdminTemporaryPassword } from "@/lib/auth/adminTempPassword";
 
-import { PATCH, DELETE, dynamic } from "./route.js";
+import { PATCH, POST, DELETE, dynamic } from "./route.js";
 
 function createRequestStub(body) {
 	return {
@@ -335,6 +349,170 @@ describe("PATCH /api/admin/users/[userId]", () => {
 	});
 });
 
+describe("POST /api/admin/users/[userId]", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		getDb.mockResolvedValue({});
+	});
+
+	it("returns 401 when unauthenticated", async () => {
+		getSession.mockResolvedValue(null);
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(401);
+		expect(await res.json()).toEqual({
+			error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" },
+		});
+	});
+
+	it("returns 403 when authenticated but not allowed (admin)", async () => {
+		getSession.mockResolvedValue({
+			userId: "u1",
+			role: "admin",
+			branchId: null,
+			email: "admin@example.com",
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(403);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Forbidden",
+				code: "AUTH_FORBIDDEN_USER_MANAGEMENT",
+			},
+		});
+	});
+
+	it("returns 400 for invalid userId", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "nope" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toMatchObject({
+			error: { code: "VALIDATION_INVALID_FIELD" },
+		});
+	});
+
+	it("returns 400 when trying to reset the current user password", async () => {
+		getSession.mockResolvedValue({
+			userId: "507f1f77bcf86cd799439011",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439011" }),
+		});
+
+		expect(res.status).toBe(400);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Cannot reset current user password",
+				code: "VALIDATION_INVALID_FIELD",
+				details: { field: "userId", reason: "SELF_PASSWORD_RESET_FORBIDDEN" },
+			},
+		});
+	});
+
+	it("returns 404 when user does not exist", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "dev",
+			branchId: null,
+			email: "dev@example.com",
+		});
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(null),
+		});
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
+		});
+
+		expect(res.status).toBe(404);
+		expect(await res.json()).toEqual({
+			error: {
+				message: "Not found",
+				code: "USER_NOT_FOUND",
+				details: { userId: "507f1f77bcf86cd799439099" },
+			},
+		});
+	});
+
+	it("returns 200 and resets password with mustChangePassword=true", async () => {
+		getSession.mockResolvedValue({
+			userId: "u2",
+			role: "superadmin",
+			branchId: null,
+			email: "superadmin@example.com",
+		});
+
+		const user = {
+			_id: "507f1f77bcf86cd799439099",
+			username: "branch2",
+			email: "branch2@example.com",
+			role: "branch",
+			branchId: "NL02",
+			passwordHash: "old-hash",
+			mustChangePassword: false,
+			passwordResetToken: "token",
+			passwordResetExpiresAt: new Date("2030-01-01"),
+			createdAt: new Date("2026-02-01T10:00:00.000Z"),
+			updatedAt: new Date("2026-02-02T10:00:00.000Z"),
+			save: vi.fn().mockResolvedValue(true),
+		};
+
+		User.findById.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		generateAdminTemporaryPassword.mockReturnValue("TempPass123!");
+		bcryptHash.mockResolvedValue("hashed-temp");
+
+		const res = await POST(new Request("http://localhost/api/admin/users/x"), {
+			params: Promise.resolve({ userId: "507f1f77bcf86cd799439099" }),
+		});
+
+		expect(res.status).toBe(200);
+		expect(generateAdminTemporaryPassword).toHaveBeenCalledTimes(1);
+		expect(bcryptHash).toHaveBeenCalledWith("TempPass123!", 12);
+		expect(user.passwordHash).toBe("hashed-temp");
+		expect(user.mustChangePassword).toBe(true);
+		expect(user.passwordResetToken).toBe(null);
+		expect(user.passwordResetExpiresAt).toBe(null);
+		expect(user.save).toHaveBeenCalledTimes(1);
+
+		expect(await res.json()).toMatchObject({
+			ok: true,
+			temporaryPassword: "TempPass123!",
+			user: {
+				id: "507f1f77bcf86cd799439099",
+				username: "branch2",
+				email: "branch2@example.com",
+				role: "branch",
+				branchId: "NL02",
+				mustChangePassword: true,
+			},
+		});
+	});
+});
+
 describe("DELETE /api/admin/users/[userId]", () => {
 	beforeEach(() => {
 		vi.clearAllMocks();

+ 50 - 0
lib/auth/adminTempPassword.js

@@ -0,0 +1,50 @@
+import crypto from "node:crypto";
+
+import { PASSWORD_POLICY, validateNewPassword } from "@/lib/auth/passwordPolicy";
+
+const LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
+const DIGITS = "23456789";
+const SPECIALS = "!@#$%&*_-";
+const CHARSET = `${LETTERS}${DIGITS}${SPECIALS}`;
+
+const DEFAULT_TEMP_PASSWORD_LENGTH = Math.max(PASSWORD_POLICY.minLength, 12);
+const MAX_GENERATION_ATTEMPTS = 20;
+
+function randomChar(chars) {
+	const i = crypto.randomInt(0, chars.length);
+	return chars[i];
+}
+
+function shuffle(input) {
+	const chars = String(input).split("");
+	for (let i = chars.length - 1; i > 0; i -= 1) {
+		const j = crypto.randomInt(0, i + 1);
+		[chars[i], chars[j]] = [chars[j], chars[i]];
+	}
+	return chars.join("");
+}
+
+function normalizeLength(length) {
+	if (!Number.isInteger(length)) return DEFAULT_TEMP_PASSWORD_LENGTH;
+	return Math.max(PASSWORD_POLICY.minLength, length);
+}
+
+export function generateAdminTemporaryPassword({ length } = {}) {
+	const targetLength = normalizeLength(length);
+
+	for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) {
+		const base = [randomChar(LETTERS), randomChar(DIGITS)];
+
+		while (base.length < targetLength) {
+			base.push(randomChar(CHARSET));
+		}
+
+		const candidate = shuffle(base.join(""));
+		if (validateNewPassword({ newPassword: candidate }).ok) {
+			return candidate;
+		}
+	}
+
+	throw new Error("Unable to generate temporary password");
+}
+

+ 31 - 0
lib/auth/adminTempPassword.test.js

@@ -0,0 +1,31 @@
+/* @vitest-environment node */
+
+import { describe, it, expect } from "vitest";
+
+import { PASSWORD_POLICY, validateNewPassword } from "@/lib/auth/passwordPolicy";
+import { generateAdminTemporaryPassword } from "./adminTempPassword.js";
+
+describe("lib/auth/adminTempPassword", () => {
+	it("generates passwords that pass the configured password policy", () => {
+		for (let i = 0; i < 25; i += 1) {
+			const password = generateAdminTemporaryPassword();
+			const result = validateNewPassword({ newPassword: password });
+
+			expect(password.length).toBeGreaterThanOrEqual(PASSWORD_POLICY.minLength);
+			expect(result.ok).toBe(true);
+		}
+	});
+
+	it("respects custom length but never below the policy minimum", () => {
+		const belowMin = generateAdminTemporaryPassword({
+			length: PASSWORD_POLICY.minLength - 3,
+		});
+		const custom = generateAdminTemporaryPassword({
+			length: PASSWORD_POLICY.minLength + 4,
+		});
+
+		expect(belowMin.length).toBeGreaterThanOrEqual(PASSWORD_POLICY.minLength);
+		expect(custom.length).toBe(PASSWORD_POLICY.minLength + 4);
+	});
+});
+

+ 11 - 0
lib/frontend/apiClient.js

@@ -320,3 +320,14 @@ export function adminDeleteUser(userId, options) {
 		...options,
 	});
 }
+
+export function adminResetUserPassword(userId, options) {
+	if (typeof userId !== "string" || !userId.trim()) {
+		throw new Error("adminResetUserPassword requires a userId string");
+	}
+
+	return apiFetch(`/api/admin/users/${encodeURIComponent(userId.trim())}`, {
+		method: "POST",
+		...options,
+	});
+}

+ 16 - 0
lib/frontend/apiClient.test.js

@@ -13,6 +13,7 @@ import {
 	adminCreateUser,
 	adminUpdateUser,
 	adminDeleteUser,
+	adminResetUserPassword,
 } from "./apiClient.js";
 
 beforeEach(() => {
@@ -163,4 +164,19 @@ describe("lib/frontend/apiClient", () => {
 		expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
 		expect(init.method).toBe("DELETE");
 	});
+
+	it("adminResetUserPassword calls POST /api/admin/users/:id", async () => {
+		fetch.mockResolvedValue(
+			new Response(JSON.stringify({ ok: true, temporaryPassword: "x" }), {
+				status: 200,
+				headers: { "Content-Type": "application/json" },
+			}),
+		);
+
+		await adminResetUserPassword("507f1f77bcf86cd799439099");
+
+		const [url, init] = fetch.mock.calls[0];
+		expect(url).toBe("/api/admin/users/507f1f77bcf86cd799439099");
+		expect(init.method).toBe("POST");
+	});
 });