route.js 2.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
  1. import bcrypt from "bcryptjs";
  2. import User from "@/models/user";
  3. import { getDb } from "@/lib/db";
  4. import { getSession } from "@/lib/auth/session";
  5. import { validateNewPassword } from "@/lib/auth/passwordPolicy";
  6. import {
  7. withErrorHandling,
  8. json,
  9. badRequest,
  10. unauthorized,
  11. } from "@/lib/api/errors";
  12. export const dynamic = "force-dynamic";
  13. const BCRYPT_SALT_ROUNDS = 12;
  14. export const POST = withErrorHandling(
  15. async function POST(request) {
  16. const session = await getSession();
  17. if (!session) {
  18. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  19. }
  20. let body;
  21. try {
  22. body = await request.json();
  23. } catch {
  24. throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
  25. }
  26. if (!body || typeof body !== "object") {
  27. throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
  28. }
  29. const { currentPassword, newPassword } = body;
  30. const missing = [];
  31. if (typeof currentPassword !== "string" || !currentPassword.trim()) {
  32. missing.push("currentPassword");
  33. }
  34. if (typeof newPassword !== "string" || !newPassword.trim()) {
  35. missing.push("newPassword");
  36. }
  37. if (missing.length > 0) {
  38. throw badRequest(
  39. "VALIDATION_MISSING_FIELD",
  40. "Missing currentPassword or newPassword",
  41. { fields: missing },
  42. );
  43. }
  44. await getDb();
  45. const user = await User.findById(session.userId).exec();
  46. // Treat missing users like an invalid session (do not leak anything).
  47. if (!user) {
  48. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  49. }
  50. // Defensive: if hash is missing, treat as invalid credentials.
  51. if (typeof user.passwordHash !== "string" || !user.passwordHash) {
  52. throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
  53. }
  54. const currentMatches = await bcrypt.compare(
  55. currentPassword,
  56. user.passwordHash,
  57. );
  58. if (!currentMatches) {
  59. throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
  60. }
  61. const policyCheck = validateNewPassword({
  62. newPassword,
  63. currentPassword,
  64. });
  65. if (!policyCheck.ok) {
  66. throw badRequest("VALIDATION_WEAK_PASSWORD", "Weak password", {
  67. ...policyCheck.policy,
  68. reasons: policyCheck.reasons,
  69. });
  70. }
  71. const newHash = await bcrypt.hash(newPassword, BCRYPT_SALT_ROUNDS);
  72. user.passwordHash = newHash;
  73. user.mustChangePassword = false;
  74. // Defense-in-depth: invalidate any reset token when password changes.
  75. user.passwordResetToken = null;
  76. user.passwordResetExpiresAt = null;
  77. await user.save();
  78. return json({ ok: true }, 200);
  79. },
  80. { logPrefix: "[api/auth/change-password]" },
  81. );