route.js 2.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
  1. import bcrypt from "bcryptjs";
  2. import User from "@/models/user";
  3. import { getDb } from "@/lib/db";
  4. import { createSession } from "@/lib/auth/session";
  5. import {
  6. withErrorHandling,
  7. json,
  8. badRequest,
  9. unauthorized,
  10. } from "@/lib/api/errors";
  11. /**
  12. * POST /api/auth/login
  13. *
  14. * Body (JSON):
  15. * {
  16. * "username": "example.user",
  17. * "password": "plain-text-password"
  18. * }
  19. *
  20. * Error contract (standardized):
  21. * {
  22. * "error": { "message": "...", "code": "...", "details"?: {...} }
  23. * }
  24. */
  25. export const POST = withErrorHandling(
  26. async function POST(request) {
  27. // --- 1) Parse body ------------------------------------------------------
  28. // request.json() can throw if the JSON is invalid (e.g. broken body).
  29. let body;
  30. try {
  31. body = await request.json();
  32. } catch {
  33. throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
  34. }
  35. // We only accept objects as JSON body for this endpoint.
  36. if (!body || typeof body !== "object") {
  37. throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
  38. }
  39. // --- 2) Validate credentials input -------------------------------------
  40. const { username, password } = body;
  41. // Keep validation strict and predictable:
  42. // - Must be strings
  43. // - Must not be empty/whitespace
  44. if (
  45. typeof username !== "string" ||
  46. typeof password !== "string" ||
  47. !username.trim() ||
  48. !password.trim()
  49. ) {
  50. throw badRequest(
  51. "VALIDATION_MISSING_FIELD",
  52. "Missing username or password",
  53. {
  54. fields: ["username", "password"],
  55. }
  56. );
  57. }
  58. // Normalize usernames to avoid case/whitespace issues.
  59. const normalizedUsername = username.trim().toLowerCase();
  60. // --- 3) Load user from DB ----------------------------------------------
  61. // Ensure DB (Mongoose) connection is established before using models.
  62. await getDb();
  63. const user = await User.findOne({ username: normalizedUsername }).exec();
  64. // Do not leak whether a username exists; always return "Invalid credentials".
  65. if (!user) {
  66. throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
  67. }
  68. // Defensive: never let missing/invalid passwordHash crash the endpoint.
  69. // Treat it like invalid credentials.
  70. if (typeof user.passwordHash !== "string" || !user.passwordHash) {
  71. throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
  72. }
  73. // --- 4) Verify password -------------------------------------------------
  74. const passwordMatches = await bcrypt.compare(password, user.passwordHash);
  75. if (!passwordMatches) {
  76. throw unauthorized("AUTH_INVALID_CREDENTIALS", "Invalid credentials");
  77. }
  78. // --- 5) Create session cookie ------------------------------------------
  79. await createSession({
  80. userId: user._id.toString(),
  81. role: user.role,
  82. branchId: user.branchId ?? null,
  83. });
  84. // Happy path response stays unchanged:
  85. return json({ ok: true }, 200);
  86. },
  87. { logPrefix: "[api/auth/login]" }
  88. );