1
0

11 کامیت‌ها 5ebf7ed5de ... b19950f085

نویسنده SHA1 پیام تاریخ
  Code_Uwe b19950f085 RHL-003-docs(auth): update authentication documentation for clarity and completeness 1 هفته پیش
  Code_Uwe e5d3161d75 RHL-003-test(auth): add unit tests for logout endpoint to verify session destruction and error handling 1 هفته پیش
  Code_Uwe aeb518ff09 RHL-003-feat(auth): implement logout endpoint to destroy session and clear auth cookie 1 هفته پیش
  Code_Uwe 4dbc667bda RHL-003-test(auth): add unit tests for login functionality with various scenarios 1 هفته پیش
  Code_Uwe 576f6488b1 RHL-003-feat(auth): implement login endpoint with request validation and session creation 1 هفته پیش
  Code_Uwe c8567d1879 RHL-003-chore(dependencies): add bcryptjs and mongoose for enhanced authentication and data management 1 هفته پیش
  Code_Uwe f9dbe68bf5 RHL-003-doc(auth): add comprehensive authentication and authorization documentation 1 هفته پیش
  Code_Uwe 1777337204 RHL-003-test(auth): add unit tests for session management and cookie handling 1 هفته پیش
  Code_Uwe 34d95103ac RHL-003-feat(auth): implement session management with JWT and cookie handling 1 هفته پیش
  Code_Uwe 27131b2375 RHL-003-chore(dependencies): add jose library for JWT handling 1 هفته پیش
  Code_Uwe 00027bb0fc RHL-003-feat(user): implement user model with roles and password management 1 هفته پیش
10فایلهای تغییر یافته به همراه1357 افزوده شده و 1 حذف شده
  1. 614 0
      Docs/auth.md
  2. 76 0
      app/api/auth/login/route.js
  3. 171 0
      app/api/auth/login/route.test.js
  4. 29 0
      app/api/auth/logout/route.js
  5. 40 0
      app/api/auth/logout/route.test.js
  6. 114 0
      lib/auth/session.js
  7. 146 0
      lib/auth/session.test.js
  8. 89 0
      models/user.js
  9. 75 1
      package-lock.json
  10. 3 0
      package.json

+ 614 - 0
Docs/auth.md

@@ -0,0 +1,614 @@
+# Authentication & Authorization
+
+This document describes the authentication and authorization model for the internal delivery note browser.
+
+The system uses:
+
+- MongoDB to store users.
+- Cookie-based sessions with a signed JWT payload.
+- Role-aware access control (`branch`, `admin`, `dev`).
+- Extensible password management and recovery flows.
+
+> NOTE: This document is a living document. As we extend the auth system (sessions, routes, policies, password flows), we will update this file.
+
+---
+
+## 1. Goals & Scope
+
+The main goals of the authentication system are:
+
+- Only authenticated users can access the application.
+- Branch users can only see delivery notes for **their own branch**.
+- Admin and dev users can access data across branches.
+- Passwords are never stored in plaintext.
+- Sessions are stored as signed JWTs in HTTP-only cookies.
+- The system is ready for password change and password recovery functionality.
+
+This document covers:
+
+- User model and roles.
+- Environment variables related to auth.
+- Session payload and cookie configuration.
+- Login and logout endpoints.
+- Planned endpoints for password management and recovery.
+- Security considerations and implementation guidelines.
+
+---
+
+## 2. Environment Variables
+
+The authentication system depends on the following environment variables:
+
+- `SESSION_SECRET` (required)
+
+  - Strong, random string used to sign and verify JWT session tokens.
+  - Must be kept secret and should differ between environments (dev, staging, prod).
+
+Example for `.env.local.example`:
+
+```env
+# Session / JWT
+SESSION_SECRET=change-me-to-a-long-random-string
+```
+
+If `SESSION_SECRET` is not set, session utilities will throw an error.
+
+---
+
+## 3. User Model
+
+Users are stored in MongoDB using the `User` collection.
+
+### 3.1 Fields
+
+- **username** (`String`, required, unique, lowercased)
+
+  - Human-chosen login name.
+  - Stored in lowercase to enable case-insensitive login.
+  - Trimmed, minimum length 3 characters.
+  - Unique index to enforce one user per username.
+
+- **email** (`String`, required, unique, lowercased)
+
+  - Contact address used for password recovery and notifications.
+  - For branch accounts, this is typically the branch email address.
+  - For individual accounts, this can be the personal work email.
+  - Stored in lowercase.
+  - Unique per user.
+
+- **passwordHash** (`String`, required)
+
+  - Hashed password (e.g. using bcrypt).
+  - Plaintext passwords are never stored.
+  - Always excluded from JSON serialization.
+
+- **role** (`String`, required, enum: `"branch" | "admin" | "dev"`)
+
+  - Controls the type of access a user has.
+  - See **Roles** section below.
+
+- **branchId** (`String | null`)
+
+  - Identifies the branch (e.g. `"NL01"`) that the user belongs to.
+  - Required for `role = "branch"`.
+  - Must be `null` or unused for non-branch users (`admin`, `dev`).
+
+- **mustChangePassword** (`Boolean`, default: `false`)
+
+  - When `true`, the user should be forced to set a new password on the next login.
+  - Useful for first-time login or admin-enforced password resets.
+
+- **passwordResetToken** (`String | null`)
+
+  - Token used for password reset flows.
+  - Generated and validated by the backend.
+  - Not exposed via public APIs.
+  - May be `null` if there is no active reset request.
+
+- **passwordResetExpiresAt** (`Date | null`)
+
+  - Expiry timestamp for the `passwordResetToken`.
+  - Used to ensure that reset links are only valid for a limited time.
+  - May be `null` if there is no active reset request.
+
+- **createdAt** (`Date`, auto-generated)
+
+  - Timestamp when the user record was created.
+
+- **updatedAt** (`Date`, auto-generated)
+
+  - Timestamp when the user record was last updated.
+
+### 3.2 Validation Rules & Invariants
+
+- `username` must be unique and is stored in lowercase.
+- `email` must be unique and is stored in lowercase.
+- `passwordHash` must be present for all users.
+- When `role = "branch"`, `branchId` must be a non-empty string.
+- For `role = "admin"` and `role = "dev"`, `branchId` is optional and usually `null`.
+- `passwordResetToken` and `passwordResetExpiresAt` should be consistent:
+
+  - If one is set, the other should also be set.
+  - Once a reset is completed or expired, both should be cleared.
+
+### 3.3 Serialization Rules
+
+When converting `User` documents to JSON or plain objects (e.g. in API responses), the following fields must be hidden:
+
+- `passwordHash`
+- `passwordResetToken`
+
+This ensures that sensitive information is not exposed via API responses or logs.
+
+### 3.4 Role Assignment & User Provisioning
+
+- Users are **created by an admin** (no public self-registration).
+- When a user is created:
+
+  - `role` is set by the admin.
+  - `branchId` is set by the admin and cannot be chosen or changed by the user.
+
+- For branch accounts, we typically create one or more users per branch with:
+
+  - `role = "branch"`
+  - `branchId` set to the respective branch identifier (e.g. `"NL01"`).
+
+- The user is provided with an initial password and is encouraged (or forced via `mustChangePassword`) to change it after the first login.
+
+---
+
+## 4. Roles
+
+### 4.1 `branch`
+
+- Represents a user who belongs to a specific branch/location.
+- Must have a valid `branchId` (e.g. `"NL01"`).
+- Intended access pattern (high-level):
+
+  - Can only access delivery notes for their own branch.
+  - Cannot access other branches.
+  - No global configuration or system-wide administration.
+
+### 4.2 `admin`
+
+- System administrator.
+- Typically not bound to any single branch (`branchId = null`).
+- Intended access pattern (high-level):
+
+  - Can access delivery notes across all branches.
+  - Can perform user administration (create/update users).
+  - Can perform configuration-level changes.
+
+### 4.3 `dev`
+
+- Development/engineering account.
+- Used for debugging, maintenance, and operational tooling.
+- Typically not bound to any single branch (`branchId = null`).
+- Intended access pattern (high-level):
+
+  - Full or near-full access to the system.
+  - Can be used in development/staging environments.
+  - Production use should be limited and auditable.
+
+---
+
+## 5. Sessions & Cookies
+
+Sessions are implemented as signed JWTs stored in HTTP-only cookies.
+
+### 5.1 Session Payload Format
+
+A session payload has the following structure:
+
+```json
+{
+	"userId": "<MongoDB ObjectId as string>",
+	"role": "branch | admin | dev",
+	"branchId": "NL01 | null",
+	"iat": 1700000000,
+	"exp": 1700003600
+}
+```
+
+- `userId` (string): MongoDB `_id` of the user.
+- `role` (string): One of `"branch"`, `"admin"`, `"dev"`.
+- `branchId` (string or `null`): Branch identifier for branch users, or `null` for admin/dev users.
+- `iat` (number): Issued-at timestamp (UNIX time).
+- `exp` (number): Expiration timestamp (UNIX time).
+
+The `iat` and `exp` fields are managed by the JWT library.
+
+### 5.2 JWT Signing
+
+- JWTs are signed using a symmetric secret (`SESSION_SECRET`).
+- Algorithm: `HS256` (HMAC using SHA-256).
+- Secret is defined via environment variable `SESSION_SECRET`.
+- Token lifetime:
+
+  - `SESSION_MAX_AGE_SECONDS = 60 * 60 * 8` (8 hours).
+  - Configured in `lib/auth/session.js`.
+
+### 5.3 Cookie Settings
+
+The session token is stored in an HTTP-only cookie with the following properties:
+
+- **Cookie name**: `auth_session`
+- **Attributes**:
+
+  - `httpOnly: true`
+  - `secure: process.env.NODE_ENV === "production"`
+  - `sameSite: "lax"`
+  - `path: "/"` (cookie is sent for all paths)
+  - `maxAge: 8 hours` (matching `SESSION_MAX_AGE_SECONDS`)
+
+Cookies are written and cleared using Next.js `cookies()` from `next/headers` inside `lib/auth/session.js`:
+
+- `createSession({ userId, role, branchId })`:
+
+  - Creates and signs a JWT.
+  - Sets the `auth_session` cookie.
+
+- `getSession()`:
+
+  - Reads the `auth_session` cookie.
+  - Verifies the JWT and returns `{ userId, role, branchId }` or `null`.
+  - If the token is invalid or expired, clears the cookie and returns `null`.
+
+- `destroySession()`:
+
+  - Clears the `auth_session` cookie by setting an empty value with `maxAge: 0`.
+
+---
+
+## 6. Core Auth Endpoints
+
+### 6.1 `POST /api/auth/login`
+
+**Purpose**
+Authenticate a user using `username` and `password`, create a session, and set the session cookie.
+
+**Method & URL**
+
+- `POST /api/auth/login`
+
+**Request Body (JSON)**
+
+```json
+{
+	"username": "example.user",
+	"password": "plain-text-password"
+}
+```
+
+- `username` (string): Login name (case-insensitive).
+- `password` (string): Plaintext password entered by the user.
+
+**Behavior**
+
+1. Normalize `username`:
+
+   - Trim whitespace and convert to lowercase.
+
+2. Parse and validate request body:
+
+   - If body is missing or invalid JSON → `400 { "error": "Invalid request body" }`.
+   - If `username` or `password` is missing or empty → `400 { "error": "Missing username or password" }`.
+
+3. Connect to MongoDB.
+4. Look up the user in MongoDB by normalized `username`.
+
+   - If no user is found → `401 { "error": "Invalid credentials" }`.
+
+5. Verify the password using bcrypt:
+
+   - Compare provided `password` with `user.passwordHash`.
+   - If password does not match → `401 { "error": "Invalid credentials" }`.
+
+6. On success:
+
+   - Create a session payload `{ userId, role, branchId }`.
+   - Call `createSession({ userId, role, branchId })`:
+
+     - Signs a JWT with the session payload.
+     - Sets the `auth_session` HTTP-only cookie.
+
+   - Return `200 { "ok": true }`.
+
+**Possible Responses**
+
+- `200 OK`:
+
+  ```json
+  {
+  	"ok": true
+  }
+  ```
+
+  (Session cookie is set in the response headers.)
+
+- `400 Bad Request`:
+
+  ```json
+  {
+  	"error": "Invalid request body"
+  }
+  ```
+
+  or
+
+  ```json
+  {
+  	"error": "Missing username or password"
+  }
+  ```
+
+- `401 Unauthorized`:
+
+  ```json
+  {
+  	"error": "Invalid credentials"
+  }
+  ```
+
+- `500 Internal Server Error`:
+
+  ```json
+  {
+  	"error": "Internal server error"
+  }
+  ```
+
+### 6.2 `GET /api/auth/logout`
+
+**Purpose**
+Destroy the current session by clearing the session cookie.
+
+**Method & URL**
+
+- `GET /api/auth/logout`
+
+**Request**
+
+- No request body.
+- Uses the current session cookie (if present).
+
+**Behavior**
+
+1. Call `destroySession()`:
+
+   - Clears the `auth_session` cookie by setting an empty value with `maxAge: 0`.
+
+2. Return `200 { "ok": true }`.
+
+Logout is **idempotent**:
+
+- If the cookie does not exist, the endpoint still returns `{ "ok": true }`.
+
+**Responses**
+
+- `200 OK`:
+
+  ```json
+  {
+  	"ok": true
+  }
+  ```
+
+- `500 Internal Server Error` (if `destroySession` throws):
+
+  ```json
+  {
+  	"error": "Internal server error"
+  }
+  ```
+
+---
+
+## 7. Password Management & Recovery (Planned)
+
+The database model is already prepared for password management and password recovery flows, but the respective endpoints may be implemented in a separate epic.
+
+### 7.1 Change Password
+
+**Endpoint**
+`POST /api/auth/change-password` (planned)
+
+**Purpose**
+Allow logged-in users to change their password by providing the current password and a new password.
+
+**Method & URL**
+
+- `POST /api/auth/change-password`
+
+**Authentication**
+
+- Requires a valid session (user must be logged in).
+
+**Request Body (JSON)**
+
+```json
+{
+	"currentPassword": "old-password",
+	"newPassword": "new-password"
+}
+```
+
+**Planned Behavior**
+
+1. Extract `userId` from the current session (`getSession()`).
+2. Load user from MongoDB.
+3. Verify `currentPassword` against `passwordHash` using bcrypt.
+4. If verification fails → return a generic error (e.g. `400` or `401` with `{ "error": "Invalid password" }`).
+5. Hash `newPassword` with bcrypt.
+6. Update `passwordHash` in the database.
+7. Optionally set `mustChangePassword = false`.
+8. Optionally update a `passwordChangedAt` field if introduced later.
+9. Return `{ "ok": true }`.
+
+### 7.2 Request Password Reset
+
+**Endpoint**
+`POST /api/auth/request-password-reset` (planned)
+
+**Purpose**
+Start the "forgot password" flow by sending a reset link to the user's email address.
+
+**Method & URL**
+
+- `POST /api/auth/request-password-reset`
+
+**Request Body (JSON)**
+
+```json
+{
+	"usernameOrEmail": "nl01@company.com"
+}
+```
+
+- The frontend may allow either username or email. The backend resolves it accordingly.
+
+**Planned Behavior**
+
+1. Normalize the identifier (trim + lowercase).
+
+2. Try to find a user by `email` (and optionally by `username`).
+
+3. If no user is found:
+
+   - Do **not** reveal this to the caller.
+   - Return a generic success response (e.g. `{ "ok": true }`) to avoid user enumeration.
+
+4. If a user is found:
+
+   - Generate a secure random token (or a signed token).
+
+   - Store it in `passwordResetToken`.
+
+   - Set `passwordResetExpiresAt` to a timestamp in the near future (e.g. now + 30 minutes).
+
+   - Send an email to `user.email` containing a link like:
+
+     ```
+     https://<app-domain>/reset-password?token=<passwordResetToken>
+     ```
+
+   - The email is sent using a mailer (e.g. `nodemailer`).
+
+5. Always return `{ "ok": true }` to the client, regardless of whether a user was found.
+
+### 7.3 Reset Password
+
+**Endpoint**
+`POST /api/auth/reset-password` (planned)
+
+**Purpose**
+Complete the password reset process using a valid reset token.
+
+**Method & URL**
+
+- `POST /api/auth/reset-password`
+
+**Request Body (JSON)**
+
+```json
+{
+	"token": "reset-token-from-email",
+	"newPassword": "new-password"
+}
+```
+
+**Planned Behavior**
+
+1. Find user by `passwordResetToken`.
+2. If no user is found → return a generic error (e.g. `{ "error": "Invalid or expired token" }`).
+3. Check that `passwordResetExpiresAt` is in the future.
+4. If the token has expired:
+
+   - Return a generic error.
+   - Clear `passwordResetToken` and `passwordResetExpiresAt`.
+
+5. If the token is valid:
+
+   - Hash `newPassword` with bcrypt.
+   - Update `passwordHash` in the database.
+   - Clear `passwordResetToken` and `passwordResetExpiresAt`.
+   - Optionally set `mustChangePassword = false`.
+
+6. Optionally invalidate other active sessions if a "global logout on password change" is implemented.
+7. Return `{ "ok": true }`.
+
+### 7.4 Email Sending
+
+Password reset emails will be sent using a mailer library (e.g. `nodemailer`), configured for the environment.
+
+Key points:
+
+- Emails are sent to `user.email`.
+- The content includes:
+
+  - A short explanation of the password reset process.
+  - A one-time link containing the `passwordResetToken`.
+  - Information about the expiration time.
+
+- No confidential data (like passwords) is ever sent via email.
+
+---
+
+## 8. Security Considerations
+
+1. **Never trust client-provided `branchId`.**
+
+   - The effective `branchId` for authorization must always come from the **session payload** (derived from the user record), not from query parameters or request bodies.
+   - Even if routes use `branch` parameters for URL structure, the backend must enforce access based on the `branchId` in the session.
+
+2. **Password handling.**
+
+   - Always hash passwords using a strong algorithm (e.g. bcrypt with a reasonable cost factor).
+   - Never log plaintext passwords.
+   - Never expose `passwordHash` or `passwordResetToken` in API responses.
+
+3. **Session security.**
+
+   - Use `httpOnly` cookies to protect the session token from JavaScript access.
+   - Use `secure` cookies in production.
+   - Use `sameSite: "lax"` or stricter unless cross-site needs are explicitly identified.
+   - Use a strong `SESSION_SECRET`, rotated when necessary.
+
+4. **Brute force and enumeration.**
+
+   - Login and password reset endpoints should:
+
+     - Respond with generic error messages (e.g. “Invalid credentials”).
+     - Not leak information on whether a user exists.
+     - Optionally implement rate limiting or throttling.
+
+5. **Auditing and logging.**
+
+   - Sensitive operations (login failures, password changes, password reset requests) should be logged with appropriate details, without exposing secrets.
+   - Logs must not contain plaintext passwords or reset tokens.
+
+---
+
+## 9. Future Work & Integration
+
+- Protect existing filesystem APIs (`/api/branches/*`, `/api/files`, etc.) by:
+
+  - Calling `getSession()` at the start of each route.
+  - Returning `401` if no valid session exists.
+  - Resolving the effective `branchId` from the session and enforcing that branch users only see their own branch.
+
+- Implement password management endpoints:
+
+  - `POST /api/auth/change-password`
+  - `POST /api/auth/request-password-reset`
+  - `POST /api/auth/reset-password`
+
+- Integrate an email provider using `nodemailer` or similar for password reset.
+- Build frontend UI for:
+
+  - Login
+  - Logout
+  - Change password
+  - “Forgot password” / reset password flows.
+
+- Optionally extend auditing and logging for security-relevant events.

+ 76 - 0
app/api/auth/login/route.js

@@ -0,0 +1,76 @@
+import bcrypt from "bcryptjs";
+import User from "@/models/user";
+import dbConnect from "@/lib/db";
+import { createSession } from "@/lib/auth/session";
+
+/**
+ * POST /api/auth/login
+ *
+ * Body (JSON):
+ * {
+ *   "username": "example.user",
+ *   "password": "plain-text-password"
+ * }
+ */
+export async function POST(request) {
+	try {
+		let body;
+
+		try {
+			body = await request.json();
+		} catch {
+			return jsonResponse({ error: "Invalid request body" }, 400);
+		}
+
+		if (!body || typeof body !== "object") {
+			return jsonResponse({ error: "Invalid request body" }, 400);
+		}
+
+		const { username, password } = body;
+
+		if (
+			typeof username !== "string" ||
+			typeof password !== "string" ||
+			!username.trim() ||
+			!password.trim()
+		) {
+			return jsonResponse({ error: "Missing username or password" }, 400);
+		}
+
+		const normalizedUsername = username.trim().toLowerCase();
+
+		await dbConnect();
+
+		const user = await User.findOne({ username: normalizedUsername }).exec();
+
+		if (!user) {
+			return jsonResponse({ error: "Invalid credentials" }, 401);
+		}
+
+		const passwordMatches = await bcrypt.compare(password, user.passwordHash);
+
+		if (!passwordMatches) {
+			return jsonResponse({ error: "Invalid credentials" }, 401);
+		}
+
+		await createSession({
+			userId: user._id.toString(),
+			role: user.role,
+			branchId: user.branchId ?? null,
+		});
+
+		return jsonResponse({ ok: true }, 200);
+	} catch (error) {
+		console.error("Login error:", error);
+		return jsonResponse({ error: "Internal server error" }, 500);
+	}
+}
+
+function jsonResponse(data, status = 200) {
+	return new Response(JSON.stringify(data), {
+		status,
+		headers: {
+			"Content-Type": "application/json",
+		},
+	});
+}

+ 171 - 0
app/api/auth/login/route.test.js

@@ -0,0 +1,171 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// 1) Mocks
+
+vi.mock("@/lib/db", () => ({
+	default: vi.fn(),
+}));
+
+vi.mock("@/models/user", () => ({
+	default: {
+		findOne: vi.fn(),
+	},
+}));
+
+vi.mock("@/lib/auth/session", () => ({
+	createSession: vi.fn(),
+}));
+
+vi.mock("bcryptjs", () => {
+	const compare = vi.fn();
+	return {
+		default: { compare },
+		compare,
+	};
+});
+
+// 2) Imports NACH den Mocks
+
+import dbConnect from "@/lib/db";
+import User from "@/models/user";
+import { createSession } from "@/lib/auth/session";
+import { compare as bcryptCompare } from "bcryptjs";
+import { POST } from "./route";
+
+function createRequestStub(body) {
+	return {
+		async json() {
+			return body;
+		},
+	};
+}
+
+describe("POST /api/auth/login", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+		dbConnect.mockResolvedValue(undefined);
+	});
+
+	it("logs in successfully with correct credentials", async () => {
+		const user = {
+			_id: "507f1f77bcf86cd799439011",
+			username: "branchuser",
+			passwordHash: "hashed-password",
+			role: "branch",
+			branchId: "NL01",
+		};
+
+		User.findOne.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(true);
+
+		const request = createRequestStub({
+			username: "BranchUser", // mixed case, should be normalized
+			password: "secret-password",
+		});
+
+		const response = await POST(request);
+		const json = await response.json();
+
+		expect(response.status).toBe(200);
+		expect(json).toEqual({ ok: true });
+
+		expect(dbConnect).toHaveBeenCalledTimes(1);
+		expect(User.findOne).toHaveBeenCalledWith({ username: "branchuser" });
+
+		expect(bcryptCompare).toHaveBeenCalledWith(
+			"secret-password",
+			"hashed-password"
+		);
+
+		expect(createSession).toHaveBeenCalledWith({
+			userId: "507f1f77bcf86cd799439011",
+			role: "branch",
+			branchId: "NL01",
+		});
+	});
+
+	it("returns 401 when user does not exist", async () => {
+		User.findOne.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(null),
+		});
+
+		const request = createRequestStub({
+			username: "unknownuser",
+			password: "some-password",
+		});
+
+		const response = await POST(request);
+		const json = await response.json();
+
+		expect(response.status).toBe(401);
+		expect(json).toEqual({ error: "Invalid credentials" });
+
+		expect(createSession).not.toHaveBeenCalled();
+	});
+
+	it("returns 401 when password is incorrect", async () => {
+		const user = {
+			_id: "507f1f77bcf86cd799439012",
+			username: "branchuser",
+			passwordHash: "hashed-password",
+			role: "branch",
+			branchId: "NL02",
+		};
+
+		User.findOne.mockReturnValue({
+			exec: vi.fn().mockResolvedValue(user),
+		});
+
+		bcryptCompare.mockResolvedValue(false);
+
+		const request = createRequestStub({
+			username: "branchuser",
+			password: "wrong-password",
+		});
+
+		const response = await POST(request);
+		const json = await response.json();
+
+		expect(response.status).toBe(401);
+		expect(json).toEqual({ error: "Invalid credentials" });
+
+		expect(createSession).not.toHaveBeenCalled();
+	});
+
+	it("returns 400 when username or password is missing", async () => {
+		const request = createRequestStub({
+			username: "only-username",
+		});
+
+		const response = await POST(request);
+		const json = await response.json();
+
+		expect(response.status).toBe(400);
+		expect(json).toEqual({ error: "Missing username or password" });
+
+		expect(User.findOne).not.toHaveBeenCalled();
+		expect(createSession).not.toHaveBeenCalled();
+	});
+
+	it("returns 500 when an unexpected error occurs", async () => {
+		User.findOne.mockImplementation(() => {
+			throw new Error("DB failure");
+		});
+
+		const request = createRequestStub({
+			username: "branchuser",
+			password: "secret-password",
+		});
+
+		const response = await POST(request);
+		const json = await response.json();
+
+		expect(response.status).toBe(500);
+		expect(json).toEqual({ error: "Internal server error" });
+
+		expect(createSession).not.toHaveBeenCalled();
+	});
+});

+ 29 - 0
app/api/auth/logout/route.js

@@ -0,0 +1,29 @@
+import { destroySession } from "@/lib/auth/session";
+
+/**
+ * GET /api/auth/logout
+ *
+ * Destroys the current session by clearing the auth cookie.
+ * Always returns { ok: true } on success.
+ */
+export async function GET() {
+	try {
+		destroySession();
+
+		return new Response(JSON.stringify({ ok: true }), {
+			status: 200,
+			headers: {
+				"Content-Type": "application/json",
+			},
+		});
+	} catch (error) {
+		console.error("Logout error:", error);
+
+		return new Response(JSON.stringify({ error: "Internal server error" }), {
+			status: 500,
+			headers: {
+				"Content-Type": "application/json",
+			},
+		});
+	}
+}

+ 40 - 0
app/api/auth/logout/route.test.js

@@ -0,0 +1,40 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// 1) Mock for destroySession
+vi.mock("@/lib/auth/session", () => ({
+	destroySession: vi.fn(),
+}));
+
+// 2) Import after mock
+import { destroySession } from "@/lib/auth/session";
+import { GET } from "./route";
+
+describe("GET /api/auth/logout", () => {
+	beforeEach(() => {
+		vi.clearAllMocks();
+	});
+
+	it("calls destroySession and returns ok: true", async () => {
+		const response = await GET();
+		const json = await response.json();
+
+		expect(destroySession).toHaveBeenCalledTimes(1);
+
+		expect(response.status).toBe(200);
+		expect(json).toEqual({ ok: true });
+	});
+
+	it("returns 500 when destroySession throws an error", async () => {
+		destroySession.mockImplementation(() => {
+			throw new Error("boom");
+		});
+
+		const response = await GET();
+		const json = await response.json();
+
+		expect(destroySession).toHaveBeenCalledTimes(1);
+
+		expect(response.status).toBe(500);
+		expect(json).toEqual({ error: "Internal server error" });
+	});
+});

+ 114 - 0
lib/auth/session.js

@@ -0,0 +1,114 @@
+import { cookies } from "next/headers";
+import { SignJWT, jwtVerify } from "jose";
+
+export const SESSION_COOKIE_NAME = "auth_session";
+export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; // 8 hours
+
+function getSessionSecretKey() {
+	const secret = process.env.SESSION_SECRET;
+
+	if (!secret) {
+		throw new Error("SESSION_SECRET environment variable is not set");
+	}
+
+	return new TextEncoder().encode(secret);
+}
+
+/**
+ * Create a signed session JWT and store it in a HTTP-only cookie.
+ *
+ * @param {Object} params
+ * @param {string} params.userId - MongoDB user id as string.
+ * @param {string} params.role - User role ("branch" | "admin" | "dev").
+ * @param {string|null} params.branchId - Branch id or null.
+ * @returns {Promise<string>} The signed JWT.
+ */
+export async function createSession({ userId, role, branchId }) {
+	if (!userId || !role) {
+		throw new Error("createSession requires userId and role");
+	}
+
+	const payload = {
+		userId,
+		role,
+		branchId: branchId ?? null,
+	};
+
+	const jwt = await new SignJWT(payload)
+		.setProtectedHeader({ alg: "HS256", typ: "JWT" })
+		.setIssuedAt()
+		.setExpirationTime(`${SESSION_MAX_AGE_SECONDS}s`)
+		.sign(getSessionSecretKey());
+
+	const cookieStore = cookies();
+
+	cookieStore.set(SESSION_COOKIE_NAME, jwt, {
+		httpOnly: true,
+		secure: process.env.NODE_ENV === "production",
+		sameSite: "lax",
+		path: "/",
+		maxAge: SESSION_MAX_AGE_SECONDS,
+	});
+
+	return jwt;
+}
+
+/**
+ * Read the current session from the HTTP-only cookie.
+ *
+ * @returns {Promise<{ userId: string, role: string, branchId: string | null } | null>}
+ *          The session payload, or null if no valid session exists.
+ */
+export async function getSession() {
+	const cookieStore = cookies();
+	const cookie = cookieStore.get(SESSION_COOKIE_NAME);
+
+	if (!cookie?.value) {
+		return null;
+	}
+
+	const secretKey = getSessionSecretKey();
+
+	try {
+		const { payload } = await jwtVerify(cookie.value, secretKey);
+
+		const { userId, role, branchId } = payload;
+
+		if (typeof userId !== "string" || typeof role !== "string") {
+			return null;
+		}
+
+		return {
+			userId,
+			role,
+			branchId: typeof branchId === "string" ? branchId : null,
+		};
+	} catch (error) {
+		// Invalid or expired token: clear the cookie for hygiene and return null
+		const store = cookies();
+		store.set(SESSION_COOKIE_NAME, "", {
+			httpOnly: true,
+			secure: process.env.NODE_ENV === "production",
+			sameSite: "lax",
+			path: "/",
+			maxAge: 0,
+		});
+
+		return null;
+	}
+}
+
+/**
+ * Destroy the current session by clearing the session cookie.
+ */
+export function destroySession() {
+	const cookieStore = cookies();
+
+	cookieStore.set(SESSION_COOKIE_NAME, "", {
+		httpOnly: true,
+		secure: process.env.NODE_ENV === "production",
+		sameSite: "lax",
+		path: "/",
+		maxAge: 0,
+	});
+}

+ 146 - 0
lib/auth/session.test.js

@@ -0,0 +1,146 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+// Mock next/headers to provide a simple in-memory cookie store
+vi.mock("next/headers", () => {
+	let store = new Map();
+
+	return {
+		cookies() {
+			return {
+				get(name) {
+					const entry = store.get(name);
+					if (!entry) return undefined;
+					return { name, value: entry.value };
+				},
+				set(name, value, options) {
+					store.set(name, { value, options });
+				},
+			};
+		},
+		__cookieStore: {
+			clear() {
+				store = new Map();
+			},
+			dump() {
+				return store;
+			},
+		},
+	};
+});
+
+// Import after the mock so the module under test uses the mocked cookies()
+import {
+	createSession,
+	getSession,
+	destroySession,
+	SESSION_COOKIE_NAME,
+	SESSION_MAX_AGE_SECONDS,
+} from "./session";
+import { __cookieStore } from "next/headers";
+
+describe("auth session utilities", () => {
+	beforeEach(() => {
+		__cookieStore.clear();
+		process.env.SESSION_SECRET = "test-session-secret";
+		process.env.NODE_ENV = "test";
+	});
+
+	it("creates a session cookie with a signed JWT", async () => {
+		const jwt = await createSession({
+			userId: "user123",
+			role: "branch",
+			branchId: "NL01",
+		});
+
+		expect(typeof jwt).toBe("string");
+		expect(jwt.length).toBeGreaterThan(10);
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie).toBeDefined();
+		expect(cookie.value).toBe(jwt);
+
+		expect(cookie.options).toMatchObject({
+			httpOnly: true,
+			secure: false, // NODE_ENV = "test"
+			sameSite: "lax",
+			path: "/",
+			maxAge: SESSION_MAX_AGE_SECONDS,
+		});
+	});
+
+	it("reads a valid session from cookie", async () => {
+		await createSession({
+			userId: "user456",
+			role: "admin",
+			branchId: null,
+		});
+
+		const session = await getSession();
+
+		expect(session).toEqual({
+			userId: "user456",
+			role: "admin",
+			branchId: null,
+		});
+	});
+
+	it("returns null when no session cookie is present", async () => {
+		const session = await getSession();
+		expect(session).toBeNull();
+	});
+
+	it("returns null and clears cookie when token is invalid", async () => {
+		// Manually set an invalid JWT value
+		const store = __cookieStore.dump();
+		store.set(SESSION_COOKIE_NAME, {
+			value: "not-a-valid-jwt",
+			options: {
+				httpOnly: true,
+				secure: false,
+				sameSite: "lax",
+				path: "/",
+				maxAge: SESSION_MAX_AGE_SECONDS,
+			},
+		});
+
+		const session = await getSession();
+		expect(session).toBeNull();
+
+		const updatedStore = __cookieStore.dump();
+		const cookie = updatedStore.get(SESSION_COOKIE_NAME);
+
+		expect(cookie).toBeDefined();
+		expect(cookie.value).toBe("");
+		expect(cookie.options.maxAge).toBe(0);
+	});
+
+	it("destroySession clears the session cookie when it exists", async () => {
+		await createSession({
+			userId: "user789",
+			role: "branch",
+			branchId: "NL02",
+		});
+
+		destroySession();
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie).toBeDefined();
+		expect(cookie.value).toBe("");
+		expect(cookie.options.maxAge).toBe(0);
+	});
+
+	it("destroySession sets an empty cookie even if none existed before", () => {
+		destroySession();
+
+		const store = __cookieStore.dump();
+		const cookie = store.get(SESSION_COOKIE_NAME);
+
+		expect(cookie).toBeDefined();
+		expect(cookie.value).toBe("");
+		expect(cookie.options.maxAge).toBe(0);
+	});
+});

+ 89 - 0
models/user.js

@@ -0,0 +1,89 @@
+import mongoose from "mongoose";
+
+const { Schema, models, model } = mongoose;
+
+export const USER_ROLES = Object.freeze({
+	BRANCH: "branch",
+	ADMIN: "admin",
+	DEV: "dev",
+});
+
+const userSchema = new Schema(
+	{
+		username: {
+			type: String,
+			required: true,
+			unique: true,
+			index: true,
+			trim: true,
+			lowercase: true,
+			minlength: 3,
+			maxlength: 100,
+		},
+		email: {
+			type: String,
+			required: true,
+			unique: true,
+			index: true,
+			trim: true,
+			lowercase: true,
+			maxlength: 200,
+		},
+		passwordHash: {
+			type: String,
+			required: true,
+		},
+		role: {
+			type: String,
+			required: true,
+			enum: Object.values(USER_ROLES),
+		},
+		branchId: {
+			type: String,
+			default: null,
+			validate: {
+				validator: function (value) {
+					if (this.role === USER_ROLES.BRANCH) {
+						return typeof value === "string" && value.trim().length > 0;
+					}
+					return true;
+				},
+				message: "branchId is required for branch users",
+			},
+		},
+		mustChangePassword: {
+			type: Boolean,
+			default: false,
+		},
+		passwordResetToken: {
+			type: String,
+			default: null,
+		},
+		passwordResetExpiresAt: {
+			type: Date,
+			default: null,
+		},
+	},
+	{
+		timestamps: true,
+		toJSON: {
+			transform(doc, ret) {
+				delete ret.passwordHash;
+				delete ret.passwordResetToken;
+				return ret;
+			},
+		},
+		toObject: {
+			transform(doc, ret) {
+				delete ret.passwordHash;
+				delete ret.passwordResetToken;
+				return ret;
+			},
+		},
+	}
+);
+
+// Avoid model overwrite issues in Next.js dev / hot reload
+const User = models.User || model("User", userSchema);
+
+export default User;

+ 75 - 1
package-lock.json

@@ -10,10 +10,13 @@
 			"dependencies": {
 				"@radix-ui/react-dropdown-menu": "^2.1.16",
 				"@radix-ui/react-slot": "^1.2.4",
+				"bcryptjs": "^3.0.3",
 				"class-variance-authority": "^0.7.1",
 				"clsx": "^2.1.1",
+				"jose": "^6.1.3",
 				"lucide-react": "^0.555.0",
 				"mongodb": "^7.0.0",
+				"mongoose": "^9.0.1",
 				"next": "16.0.7",
 				"next-themes": "^0.4.6",
 				"react": "19.2.0",
@@ -3950,6 +3953,15 @@
 				"baseline-browser-mapping": "dist/cli.js"
 			}
 		},
+		"node_modules/bcryptjs": {
+			"version": "3.0.3",
+			"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+			"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+			"license": "BSD-3-Clause",
+			"bin": {
+				"bcrypt": "bin/bcrypt"
+			}
+		},
 		"node_modules/brace-expansion": {
 			"version": "1.1.12",
 			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -6073,6 +6085,15 @@
 				"jiti": "lib/jiti-cli.mjs"
 			}
 		},
+		"node_modules/jose": {
+			"version": "6.1.3",
+			"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
+			"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
+			"license": "MIT",
+			"funding": {
+				"url": "https://github.com/sponsors/panva"
+			}
+		},
 		"node_modules/js-tokens": {
 			"version": "4.0.0",
 			"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -6156,6 +6177,15 @@
 				"node": ">=4.0"
 			}
 		},
+		"node_modules/kareem": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/kareem/-/kareem-3.0.0.tgz",
+			"integrity": "sha512-RKhaOBSPN8L7y4yAgNhDT2602G5FD6QbOIISbjN9D6mjHPeqeg7K+EB5IGSU5o81/X2Gzm3ICnAvQW3x3OP8HA==",
+			"license": "Apache-2.0",
+			"engines": {
+				"node": ">=18.0.0"
+			}
+		},
 		"node_modules/keyv": {
 			"version": "4.5.4",
 			"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -6648,11 +6678,49 @@
 				"node": ">=20.19.0"
 			}
 		},
+		"node_modules/mongoose": {
+			"version": "9.0.1",
+			"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.0.1.tgz",
+			"integrity": "sha512-aHPfQx2YX5UwAmMVud7OD4lIz9AEO4jI+oDnRh3lPZq9lrKTiHmOzszVffDMyQHXvrf4NXsJ34kpmAhyYAZGbw==",
+			"license": "MIT",
+			"dependencies": {
+				"kareem": "3.0.0",
+				"mongodb": "~7.0",
+				"mpath": "0.9.0",
+				"mquery": "6.0.0",
+				"ms": "2.1.3",
+				"sift": "17.1.3"
+			},
+			"engines": {
+				"node": ">=20.19.0"
+			},
+			"funding": {
+				"type": "opencollective",
+				"url": "https://opencollective.com/mongoose"
+			}
+		},
+		"node_modules/mpath": {
+			"version": "0.9.0",
+			"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
+			"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=4.0.0"
+			}
+		},
+		"node_modules/mquery": {
+			"version": "6.0.0",
+			"resolved": "https://registry.npmjs.org/mquery/-/mquery-6.0.0.tgz",
+			"integrity": "sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=20.19.0"
+			}
+		},
 		"node_modules/ms": {
 			"version": "2.1.3",
 			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
 			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-			"dev": true,
 			"license": "MIT"
 		},
 		"node_modules/nanoid": {
@@ -7688,6 +7756,12 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
+		"node_modules/sift": {
+			"version": "17.1.3",
+			"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
+			"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
+			"license": "MIT"
+		},
 		"node_modules/siginfo": {
 			"version": "2.0.0",
 			"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",

+ 3 - 0
package.json

@@ -13,10 +13,13 @@
 	"dependencies": {
 		"@radix-ui/react-dropdown-menu": "^2.1.16",
 		"@radix-ui/react-slot": "^1.2.4",
+		"bcryptjs": "^3.0.3",
 		"class-variance-authority": "^0.7.1",
 		"clsx": "^2.1.1",
+		"jose": "^6.1.3",
 		"lucide-react": "^0.555.0",
 		"mongodb": "^7.0.0",
+		"mongoose": "^9.0.1",
 		"next": "16.0.7",
 		"next-themes": "^0.4.6",
 		"react": "19.2.0",