"use server" import { revalidatePath } from "next/cache" import { compare, hash } from "bcryptjs" import { z } from "zod" import type { ActionState } from "@/shared/types/action-state" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" import { validatePassword } from "@/shared/lib/password-policy" import { rateLimit, rateLimitKey, RATE_LIMIT_RULES } from "@/shared/lib/rate-limit" import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils" import { getPasswordSecurityByUserId, getUserPasswordHash, updateUserPassword, upsertPasswordSecurityOnPasswordChange, } from "./data-access" const ChangePasswordSchema = z.object({ currentPassword: z.string().min(1, "Current password is required"), newPassword: z.string().min(1, "New password is required"), confirmPassword: z.string().min(1, "Password confirmation is required"), }) /** * Change the current user's password. Requires self-service profile update * permission (every authenticated user has it). Rate-limited to slow * brute-force of the current password. */ export async function changePasswordAction( prevState: ActionState, formData: FormData ): Promise> { try { const ctx = await requirePermission(Permissions.USER_PROFILE_UPDATE) const userId = ctx.userId const limitKey = rateLimitKey("pwd-change", userId) const limit = rateLimit({ key: limitKey, ...RATE_LIMIT_RULES.PASSWORD_CHANGE }) if (!limit.success) { return { success: false, message: "Too many attempts. Please try again later." } } const parsed = ChangePasswordSchema.safeParse({ currentPassword: formData.get("currentPassword"), newPassword: formData.get("newPassword"), confirmPassword: formData.get("confirmPassword"), }) if (!parsed.success) { return { success: false, message: parsed.error.issues[0]?.message ?? "Invalid form data", } } const { currentPassword, newPassword, confirmPassword } = parsed.data if (newPassword !== confirmPassword) { return { success: false, message: "New passwords do not match" } } if (newPassword === currentPassword) { return { success: false, message: "New password must be different from current password" } } const validation = validatePassword(newPassword) if (!validation.valid) { return { success: false, message: validation.errors[0] ?? "Password does not meet requirements" } } // Parallelize user and passwordSecurity queries const [userRecord, existingSecurity] = await Promise.all([ getUserPasswordHash(userId), getPasswordSecurityByUserId(userId), ]) if (!userRecord || !userRecord.password) { return { success: false, message: "User not found or no password set" } } const storedHash = normalizeBcryptHash(userRecord.password) if (!storedHash.startsWith("$2")) { return { success: false, message: "Stored password is invalid" } } const ok = await compare(currentPassword, storedHash) if (!ok) { return { success: false, message: "Current password is incorrect" } } const newHash = await hash(newPassword, 10) const now = new Date() await updateUserPassword(userId, newHash, now) await upsertPasswordSecurityOnPasswordChange(userId, now, existingSecurity) revalidatePath("/settings") return { success: true, message: "Password changed successfully", data: null } } catch (error) { if (error instanceof PermissionDeniedError) return { success: false, message: error.message } return { success: false, message: "Failed to change password" } } }