feat: implement identity and security features

- Added account types and privileged account types constants.
- Created admin user controller for updating user security fields.
- Developed role assignment controller for managing user roles.
- Implemented validation middleware for request schemas.
- Defined user role and auth session models for database interactions.
- Created services for authentication, email notifications, and OTP handling.
- Developed OAuth service for Google and Apple authentication.
- Added JWT utility functions for token generation and verification.
- Implemented comprehensive tests for authentication, session management, and password policies.
- Created migration for updating user schema and adding new tables for auth sessions and user identities.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 13:56:18 +05:30
parent 267e80e2ec
commit 9d3d431416
54 changed files with 1389 additions and 1076 deletions
+9 -2
View File
@@ -18,9 +18,14 @@ REDIS_PASSWORD=replace_with_local_redis_password
# Use separate randomly generated values of at least 32 characters.
JWT_SECRET=replace_with_a_random_value_at_least_32_chars
REFRESH_TOKEN_SECRET=replace_with_a_different_random_32_char_value
JWT_EXPIRES_IN=15m
REFRESH_TOKEN_DAYS=7d
JWT_ISSUER=zumri-api
JWT_AUDIENCE=zumri-clients
ACCESS_TOKEN_TTL=15m
REFRESH_TOKEN_TTL_DAYS=7
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS=30
LOGIN_OTP_TTL_SECONDS=900
LOGIN_OTP_MAX_ATTEMPTS=5
# Runtime controls
RUN_CRON=false
@@ -58,3 +63,5 @@ DEFAULT_PASSWORD=
PASSWORD_RESET_TTL_SECONDS=900
EMAIL_VERIFICATION_TTL_SECONDS=86400
PUPPETEER_EXECUTABLE_PATH=
GOOGLE_CLIENT_ID=
APPLE_CLIENT_ID=
+95
View File
@@ -0,0 +1,95 @@
# ZUMRI Authentication API
All endpoints are available under both `/api/auth` and `/api/v1/auth`; new clients should use `/api/v1`. JSON errors use the Phase 0 standard error envelope. Examples contain placeholders only.
## Account states
- `PENDING_VERIFICATION`: registration exists but password/OAuth session creation is denied.
- `ACTIVE`: authentication and refresh are permitted.
- `SUSPENDED` and `DEACTIVATED`: login, refresh, and access-token middleware are denied.
## Register a customer
`POST /api/v1/auth/register`
```json
{"firstName":"Asha","lastName":"Perera","email":"asha@example.com","password":"Strong!1234x","phoneNumber":"+94000000000","address":"Customer-provided value"}
```
Public registration always creates `customer`; supplied privileged account fields are rejected/stripped by strict validation. Business and privileged registration are not public Phase 1 flows.
## Verify or resend email verification
- `POST /api/v1/auth/verify-email` — `{"token":"verification-token-from-email"}`
- `POST /api/v1/auth/resend-verification` — `{"email":"asha@example.com"}`
Tokens are random, stored only by hash in Redis, expire, and are single-use. Resend invalidates the previous token. Resend returns a generic response.
## Password plus OTP login
`POST /api/v1/auth/login`
```json
{"email":"asha@example.com","password":"Strong!1234x","rememberMe":true,"clientType":"WEB","deviceName":"Personal laptop"}
```
Successful credential verification returns HTTP 202 and a `challengeId`; it does not create a session. A hashed six-digit OTP challenge is stored in Redis for the configured TTL and attempt limit.
`POST /api/v1/auth/verify-otp`
```json
{"challengeId":"00000000-0000-4000-8000-000000000000","otp":"000000","clientType":"WEB"}
```
Successful verification consumes the challenge, creates a durable session, and issues tokens. `POST /api/v1/auth/req-otp` remains an alias for login challenge creation. The historical endpoint that submitted email+OTP to `/login` is intentionally superseded by challenge IDs.
## Token transport
`clientType: WEB` sets `access_token` and `refresh_token` as HttpOnly cookies. Production cookies use `Secure` and `SameSite=None` for the intended cross-subdomain frontend/API deployment. The refresh cookie is restricted to `/api`. The response includes the access token for current compatibility but never includes the web refresh token.
`clientType: MOBILE` returns access and refresh tokens in JSON and does not depend on cookies. Mobile clients must store the refresh token in operating-system secure storage. Access tokens remain short-lived regardless of Remember Me.
## Refresh and rotation
`POST /api/v1/auth/refresh`
Web request body may be `{}`; mobile sends `{"clientType":"MOBILE","refreshToken":"opaque-token"}`. Every successful call revokes/replaces the previous session row and returns a new token pair. Replaying a rotated token revokes its entire token family. Only SHA-256 refresh hashes are persisted.
## Current identity
`GET /api/v1/auth/me` requires the access cookie or `Authorization: Bearer <access-token>`. It returns identity fields, account state, verification status, profile, and effective permissions. It omits password, token version, session hashes, and OAuth internals.
## Logout
- `POST /api/v1/auth/logout` revokes the session identified by refresh or access token and is idempotent.
- `POST /api/v1/auth/logout-all` requires authentication, increments `tokenVersion`, revokes every active session, and clears cookies.
## Password recovery and change
- `POST /api/v1/auth/forgot-password` — `{"email":"asha@example.com"}`; response is generic.
- `POST /api/v1/auth/reset-password` — token, `newPassword`, `confirmPassword`.
- `POST /api/v1/auth/change-password` — authenticated; `currentPassword`, `newPassword`, `confirmPassword`.
Reset tokens are random, hash-only Redis records and atomically consumed. Reset/change enforce the same password policy, increment token version, and revoke all sessions. The user must log in again.
## Google and Apple
- `POST /api/v1/auth/google`
- `POST /api/v1/auth/apple`
```json
{"idToken":"provider-signed-id-token","rememberMe":false,"clientType":"WEB"}
```
Google verification validates signature/audience/issuer/expiry through Google's verifier and requires verified email. Apple validates the provider JWKS signature, issuer, audience, expiry, and stable subject. Identities persist `(provider, provider_subject)` uniquely; provider tokens are discarded. Verified email auto-linking is permitted only for customer accounts. Privileged and rider accounts are never automatically linked by email.
## Administrative and rider compatibility
- `POST /api/v1/admin/auth/login` and `/verify-otp` use the shared challenge/session flow but only accept privileged account types.
- `POST /api/v1/rider/auth/login` and `/verify-otp` use the same system and only accept rider accounts.
No separate token implementation exists for these actors.
## Password policy
Minimum 12 characters with uppercase, lowercase, a symbol, and at least four numeric characters. Registration, reset, and change use the same Zod schema.
+14
View File
@@ -373,3 +373,17 @@ New `/health/live` and `/health/ready` routes provide real liveness/readiness be
Deployment additions include a hardened Node 22/Chromium/non-root Dockerfile with healthcheck, API/worker/MySQL/Redis Compose configuration, an Nginx reverse-proxy example, and a Gitea Actions CI baseline. Jest/Supertest tests now cover environment validation, liveness/readiness, errors/404, protected routes, Bull Board denial, and request correlation. The first test run exposed incompatible ESM-only `uuid@13`; it was safely pinned to CommonJS-compatible v11. `nodemon` moved to devDependencies.
Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`.
## Phase 1 Completion Update
**Date:** 2026-09-03
**Authentication completion:** approximately **91%**.
**Revised Day 2 completion:** approximately **90%**.
Phase 1 replaced process-memory OTP and refresh sessions with Redis hash-only login challenges and durable MySQL auth-session families. OTP now uses `crypto.randomInt`, challenge UUIDs, TTL, atomic verification, bounded attempts, and no plaintext logging. Refresh tokens are opaque random values stored only by SHA-256 hash; row-locked transaction rotation detects replay and revokes the family. Access JWTs are short-lived, issuer/audience/algorithm constrained, and bound to `sub`, live session ID, and User token version. Middleware enforces current account state and session revocation.
New `auth_sessions`, `user_identities`, and `user_roles` models/tables support devices, Remember Me, Google/Apple stable subjects, and configurable roles. The User security migration adds token version/last login, expands canonical account types, and adds RBAC unique indexes. Permission routes are mounted behind SUPER_ADMIN, permission resolution now uses UserRole, and cache invalidation covers assignments/grants. Customer self-service update is allowlisted and ID-based mutation is privileged, closing the audited identity IDOR/mass-assignment path.
Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis.
Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion.
@@ -41,7 +41,7 @@ Tests can import `app.js` without opening a TCP port. Startup failures prevent t
## Environment Variables
Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REFRESH_TOKEN_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. JWT secrets must each be at least 32 characters. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication.
Required for API/worker startup: `NODE_ENV`, `PORT`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `JWT_SECRET`, `REDIS_HOST`, `REDIS_PORT`, and `FRONTEND_URL`. The JWT secret must contain at least 32 characters. Phase 1 replaced refresh JWTs with opaque random refresh tokens, so no refresh-token signing secret is required. `REDIS_PASSWORD` is optional at schema level for deployments without Redis authentication.
Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`.
@@ -0,0 +1,119 @@
# ZUMRI Phase 1 Identity and Authorization
## Objective
Complete the identity security boundary without beginning commerce modules: verified registration, password+OTP authentication, durable revocable sessions, social identity verification, account-state enforcement, repaired configurable RBAC, and ownership-safe self service.
## Existing Components Reused
User/Profile and customer-extension models, Sequelize registration, Redis client, bcrypt helper, email templates/transport, hashed verification/reset concepts, activity queue, permission/role/grant models, permission cache, Phase 0 error/rate-limit/request-ID middleware, dual API mounting, tests, and migrations were extended rather than replaced.
## Identity Model
`User` is the account and contains broad `accountType`, state, password hash (nullable for social-only customers), verification/password timestamps, `tokenVersion`, and `lastLoginAt`. `UserIdentity` maps Google/Apple stable subjects to User. `AuthSession` persists refresh credentials/device context and rotation state. `UserRole` assigns configurable Roles independently from account type.
## Account Types
Canonical application constants map to persisted lowercase values: `SUPER_ADMIN=superadmin`, `ADMIN=admin`, `MANAGER=manager`, `CUSTOMER=customer`, `BUSINESS_CUSTOMER=business_customer`, `RIDER=rider`, `SUPPORT_AGENT=support_agent`. Existing lowercase values remain valid.
## Role vs Account Type
Account type is a broad trusted identity category used for hard security boundaries. Role is configurable authorization grouping. Users may have multiple roles through `UserRole`; effective permissions are the union of role grants and direct additive `UserPermission` grants. This removes dependence on undeclared `User.roleID`.
## Session Architecture
```text
Password -> OTP Challenge -> Verify OTP -> AuthSession
-> Access JWT + opaque Refresh Token
-> transactional rotation -> logout/revocation
```
Sessions support multiple devices, user-agent/IP/device metadata, Remember Me, token families, last use, expiry, revocation reason, and replacement linkage. Raw refresh tokens exist only at issuance/transport.
## Access Token
HS256 JWTs are short-lived and contain `sub`, `sid`, and `tokenVersion`, plus `iss`, `aud`, `iat`, and `exp`. Middleware enforces algorithm, signature, issuer, audience, expiry, live User state/token version, and live non-revoked session state. It loads permissions server-side rather than embedding them.
## Refresh Token Rotation
Refresh tokens are opaque `session-id.random-secret` values. The database stores only SHA-256 hashes. Rotation locks the current session row and User in a transaction, creates a same-family replacement, and revokes/links the old row.
## Reuse Detection
Presentation of a revoked/replaced or hash-mismatched known session token revokes all active members of that token family and returns the same invalid-session boundary. Row locks ensure two concurrent refresh calls cannot both succeed.
## Remember Me
Remember Me changes only refresh-session lifetime: `REFRESH_TOKEN_TTL_DAYS` versus `REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS`. Access lifetime remains `ACCESS_TOKEN_TTL`.
## Account Status Enforcement
Only `ACTIVE` can finish login, refresh, or use protected endpoints. Pending, suspended, and deactivated accounts are rejected using current database state, not stale claims. Password/security administration increments token version and revokes sessions.
## Password Policy
One Zod policy requires at least 12 characters, uppercase, lowercase, a symbol, and four digits. It is used by registration, reset, and change-password flows. Reset/change require confirmation and reject reuse of the current password.
## Email Verification
Verification tokens are cryptographically random and hash-only in Redis. They expire, are consumed atomically, and activate the account. Per-user pointers let resend invalidate an earlier token. Resend responses are generic and rate limited.
## Password Recovery
Forgot-password normalizes/validates email and returns a generic response. Reset tokens are random, stored hashed with TTL, atomically consumed using Redis `GETDEL`, and never logged. Successful reset updates the hash/timestamp/tokenVersion and revokes every session.
## Google Authentication
The backend verifies Google ID tokens against configured audience and uses Google's `sub`; verified email is required. Provider access tokens are not stored. Automated tests mock Google's verifier.
## Apple Authentication
The backend obtains/caches Apple's JWKS, selects the signed key, and verifies RS256 signature, Apple issuer, configured audience, expiry, and `sub`. First-login email is used only when verified. Tests use a locally signed RSA token and mocked JWKS response.
## OAuth Account Linking Rules
Existing provider subject wins. Otherwise a strongly verified provider email may link/create a customer. Email-only automatic linking is denied for super admins, admins, managers, support agents, and riders. Provider subject is unique and provider tokens/secrets are not persisted. Manual privileged linking remains deferred.
## Role and Permission Architecture
`/api/v1/permissions` is now mounted and default-denied to SUPER_ADMIN at router level. Existing CRUD is retained behind that boundary. Role names and role/user grant pairs have unique constraints. UserRole pairs are unique. Model hooks and assignment controllers invalidate affected permission caches.
## Ownership Authorization
`GET/PATCH /user/me` provides self service. Self update allowlists only first and last name; account type/status/roles/security fields are rejected. ID-based legacy mutation is restricted to SUPER_ADMIN, and no arbitrary ID read route is exposed. Profile image access uses reusable self-or-admin ownership middleware.
## New Database Tables
- `auth_sessions`
- `user_identities`
- `user_roles`
User adds `tokenVersion` and `lastLoginAt`; password becomes nullable for verified social-only users; the account-type ENUM expands to all canonical types.
## New Migrations
`20260903010000-phase-1-identity-security.js` is forward-only relative to the Phase 0 baseline and was not executed. Before applying to an existing database, diagnose duplicate `roles.roleName`, `(role_id,permission_id)`, and `(user_id,permission_id)` rows because unique indexes intentionally fail on dirty data. Back up and test a restored database first.
## API Endpoints
Auth: register, login challenge, verify OTP, refresh, logout, logout-all, forgot/reset/change password, verify/resend email, Google, Apple, and me. Thin shared-flow admin and rider login routes exist. Admin User status/account-type endpoints and protected permission/UserRole endpoints are mounted. See `Documentation/API_AUTHENTICATION.md`.
## Security Controls
Hash-only OTP/reset/verification/refresh persistence; cryptographic random generation; bounded OTP attempts; single-use challenges; short access TTL; durable revocation; replay-family revocation; DB row locks; account-state/token-version checks; strict Zod bodies; generic enumeration responses; provider signature/audience checks; customer-only public registration; field allowlists; ownership checks; and no token/OTP credential logging.
## Tests
Unit coverage includes password policy, JWT claims/wrong issuer-audience/expiry/malformed input, OTP format/hash-only persistence/failure states, refresh hash-only persistence/rotation/replay, account-status/password-login behavior, and Google/Apple verification. Integration coverage preserves Phase 0 health/error behavior and checks RBAC denial, self mass-assignment, other-user mutation, suspended access, and Bull Board admin access. External DB/Redis/email/OAuth services are mocked.
## Legacy Compatibility
Both `/api` and `/api/v1` remain. `/auth/req-otp` aliases new login challenge creation; `/profile` password endpoints delegate to the same hardened controllers. The old email+OTP `/auth/login` second step is intentionally replaced by `/verify-otp` with challenge IDs because the old email-keyed flow could not meet security requirements.
## Remaining Known Issues
Manual privileged OAuth linking and secure email-change confirmation are deferred. Full email queue/delivery tracking remains Phase 2 infrastructure work. Existing business registration/account approval is not part of this phase. Role/grant uniqueness migration requires deployed-data diagnostics. The baseline test suite mocks MySQL/Redis; staging integration tests must run after migration review. Existing non-auth legacy routes may still contain account-name/ownership debt outside Phase 1 scope.
## Phase 2 Prerequisites
Review and run both migrations on a restored environment, configure mail plus Google/Apple client audiences where those flows are enabled, run staging MySQL/Redis integration tests, and seed the initial SUPER_ADMIN/roles/permissions through a controlled operational process. Once complete, identity is ready for the next non-commerce phase requested by the development roadmap.
+8 -1
View File
@@ -11,7 +11,13 @@ const envSchema = z.object({
DB_USER: z.string().min(1),
DB_PASSWORD: z.string(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"),
REFRESH_TOKEN_SECRET: z.string().min(32, "REFRESH_TOKEN_SECRET must contain at least 32 characters"),
JWT_ISSUER: z.string().min(1).default("zumri-api"),
JWT_AUDIENCE: z.string().min(1).default("zumri-clients"),
ACCESS_TOKEN_TTL: z.string().default("15m"),
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(7),
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
LOGIN_OTP_TTL_SECONDS: z.coerce.number().int().positive().default(900),
LOGIN_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5),
REDIS_HOST: z.string().min(1),
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
REDIS_PASSWORD: z.string().optional(),
@@ -33,6 +39,7 @@ const envSchema = z.object({
AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
}).superRefine((env, context) => {
const requireFeature = (enabled, names) => {
if (!enabled) return;
+15
View File
@@ -0,0 +1,15 @@
const ACCOUNT_TYPES = Object.freeze({
SUPER_ADMIN: "superadmin",
ADMIN: "admin",
MANAGER: "manager",
CUSTOMER: "customer",
BUSINESS_CUSTOMER: "business_customer",
RIDER: "rider",
SUPPORT_AGENT: "support_agent",
});
const PRIVILEGED_ACCOUNT_TYPES = Object.freeze([
ACCOUNT_TYPES.SUPER_ADMIN, ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.MANAGER, ACCOUNT_TYPES.SUPPORT_AGENT,
]);
module.exports = { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES, ACCOUNT_TYPE_VALUES: Object.values(ACCOUNT_TYPES) };
+24
View File
@@ -0,0 +1,24 @@
const db = require("../models");
const { ACCOUNT_TYPE_VALUES } = require("../constants/accountTypes");
const { revokeAllUserSessions } = require("../services/auth/session.service");
const { logActivity } = require("../services/activity.service");
const updateSecurityField = (field) => async (req, res, next) => {
try {
const value = req.body[field];
if (field === "accountType" && !ACCOUNT_TYPE_VALUES.includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_TYPE", message: "Invalid account type" } });
if (field === "accountStatus" && !["PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"].includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_STATUS", message: "Invalid account status" } });
if (req.params.id === req.user.id) return res.status(400).json({ success: false, error: { code: "SELF_SECURITY_CHANGE_DENIED", message: "Security-sensitive self changes are not allowed" } });
let target; let previous;
await db.sequelize.transaction(async (transaction) => {
target = await db.User.findByPk(req.params.id, { transaction, lock: transaction.LOCK.UPDATE });
if (!target) throw Object.assign(new Error("User not found"), { status: 404, code: "USER_NOT_FOUND" });
previous = target[field]; target[field] = value; target.tokenVersion += 1; await target.save({ transaction });
await revokeAllUserSessions(target.id, `ADMIN_${field.toUpperCase()}_CHANGE`, transaction);
});
await logActivity({ user: req.user, description: `${field} changed for ${target.id} from ${previous} to ${value}; reason: ${req.body.reason || "not supplied"}; request: ${req.id}`, type: field === "accountStatus" ? "ACCOUNT_STATUS_CHANGED" : "ACCOUNT_TYPE_CHANGED", module: "Identity Administration" });
res.json({ success: true, data: { id: target.id, [field]: target[field] } });
} catch (error) { next(error); }
};
exports.updateStatus = updateSecurityField("accountStatus");
exports.updateAccountType = updateSecurityField("accountType");
+159 -503
View File
@@ -1,536 +1,192 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/controllers/auth.controller.js
const { checkPassword, hashPassword } = require("../utils/hashPassword.util");
const { sendMail } = require("../utils/mail.util");
const {
validatePassword,
} = require("../utils/validation/validatePassword.util");
const { validateEmail } = require("../utils/validation/validateEmail.util");
const { generateOTP, validateOTP } = require("../utils/otp.util");
const { getCachedUser, clearUserCache } = require("../utils/cache.util");
const { generateToken } = require("../utils/jwt.util");
const {
createRefreshSession,
validateRefreshSession,
deleteRefreshSession,
deleteAllUserSessions,
} = require("../utils/refreshSession.util");
const {
createPasswordReset,
verifyPasswordResetToken,
deletePasswordReset,
sendPasswordResetEmail,
sendPasswordChangedEmail,
} = require("../utils/passwordReset.utill");
const db = require("../models");
const { log } = require("../utils/consoleLog.utill");
const authService = require("../services/auth/auth.service");
const sessionService = require("../services/auth/session.service");
const { hashPassword, checkPassword } = require("../utils/hashPassword.util");
const { createPasswordReset, consumePasswordResetToken, sendPasswordResetEmail } = require("../utils/passwordReset.utill");
const { createEmailVerification, consumeEmailVerificationToken, sendVerificationEmail } = require("../utils/emailVerification.util");
const { sendPasswordChanged } = require("../services/auth/email.service");
const { logActivity } = require("../services/activity.service");
const { verifyToken } = require("../utils/jwt.util");
const appName = process.env.APP_NAME || "Niolla";
const cookieOptions = (maxAge, path = "/") => ({ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", maxAge, path });
const clearCookies = (res) => {
res.clearCookie("access_token", cookieOptions(undefined, "/"));
res.clearCookie("refresh_token", cookieOptions(undefined, "/api"));
};
const projectUser = (user) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt) });
const deliverTokens = (req, res, result, clientType = "WEB") => {
const accessMs = 15 * 60 * 1000;
const refreshMs = Math.max(0, new Date(result.refreshExpiresAt).getTime() - Date.now());
if (clientType === "WEB") {
res.cookie("access_token", result.accessToken, cookieOptions(accessMs));
res.cookie("refresh_token", result.refreshToken, cookieOptions(refreshMs, "/api"));
return { accessToken: result.accessToken };
}
return { accessToken: result.accessToken, refreshToken: result.refreshToken, refreshExpiresAt: result.refreshExpiresAt };
};
const User = db.User;
// Login Step 1: Request OTP
exports.loginReq = async (req, res) => {
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
const result = await authService.beginPasswordLogin(req.validated.body, req);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
exports.loginReq = exports.login;
const user = await getCachedUser(email);
if (!user) {
return res
.status(404)
.send({ success: false, message: "User Not Found" });
}
exports.verifyOtp = async (req, res, next) => {
try {
const input = req.validated.body;
const result = await authService.completeOtpLogin(input, req);
const tokens = deliverTokens(req, res, result, input.clientType);
await logActivity({ user: result.user, description: "Authentication session created", type: "LOGIN_SUCCEEDED", module: "Authentication" });
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
} catch (error) { next(error); }
};
const passwordIsValid = await checkPassword(password, user.password);
if (!passwordIsValid) {
return res
.status(401)
.send({ success: false, message: "Invalid Password" });
}
const otp = generateOTP(email);
await sendMail({
to: email,
subject: `OTP for Your ${appName} Account`,
templateName: "otp",
templateVars: {
firstName: user.firstName,
otp: otp,
},
text: `Hello ${user.firstName}, your otp is ${otp}`,
});
log(`OTP for ${email}: ${otp}`);
log(`OTP sent to ${email} successfully.`);
res.status(201).send({ success: true, message: "OTP Sent Successfully" });
exports.refreshToken = async (req, res, next) => {
try {
const input = req.validated.body;
const token = input.refreshToken || req.cookies?.refresh_token;
if (!token) throw Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" });
const result = await authService.refresh(token, req);
res.json({ success: true, data: deliverTokens(req, res, result, input.clientType) });
} catch (error) {
log("Error occurred while sending OTP:", error);
res.status(500).send({ success: false, message: error.message });
clearCookies(res);
if (error.code === "REFRESH_TOKEN_REUSE") console.warn(`[${req.id}] Refresh token replay detected; token family revoked`);
next(Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" }));
}
};
// Login Step 2: Verify OTP and issue JWT
exports.login = async (req, res) => {
exports.logout = async (req, res, next) => {
try {
const { email, otp } = req.body;
if (!email || !otp) {
return res
.status(400)
.send({ success: false, message: "Email and OTP are required" });
const token = req.body?.refreshToken || req.cookies?.refresh_token;
let id = sessionService.tokenId(token);
if (!id) {
const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; }
}
if (id) await sessionService.revokeSession(id, "LOGOUT");
clearCookies(res);
res.json({ success: true, message: "Logged out successfully" });
} catch (error) { next(error); }
};
const user = await getCachedUser(email);
exports.logoutAll = async (req, res, next) => {
try {
await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
user.tokenVersion += 1; await user.save({ transaction });
await sessionService.revokeAllUserSessions(user.id, "LOGOUT_ALL", transaction);
});
clearCookies(res);
await logActivity({ user: req.user, description: "All authentication sessions revoked", type: "LOGOUT_ALL", module: "Authentication" });
res.json({ success: true, message: "Logged out from all devices" });
} catch (error) { next(error); }
};
if (!user) {
return res
.status(404)
.send({ success: false, message: "User Not Found" });
exports.me = async (req, res, next) => {
try {
const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] });
res.json({ success: true, data: { ...projectUser(user), profile: user.profile, effectivePermissions: req.user.permissions || [] } });
} catch (error) { next(error); }
};
exports.forgotPassword = async (req, res, next) => {
const message = "If an account exists for this email, a password reset link has been sent";
try {
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
if (user) {
const token = await createPasswordReset(user.id);
await sendPasswordResetEmail(user.email, user.firstName, token);
}
if (user.accountStatus !== "ACTIVE") {
return res.status(403).send({
success: false,
message: "Account is not active",
});
}
const isValidOTP = validateOTP(email, String(otp));
if (!isValidOTP) {
return res
.status(401)
.send({ success: false, message: "Invalid or Expired OTP" });
}
// Generate JWT token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
accountType: user.accountType,
});
const { refreshToken } = createRefreshSession(user.id);
// 3. Set JWT as HttpOnly cookie
res.cookie("access_token", token, {
httpOnly: true, // JS cannot access
secure: process.env.NODE_ENV === "production", // HTTPS only in prod
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 15 * 60 * 1000, // 1 day
});
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
log(`JWT issued for ${email}`);
clearUserCache(email);
res.status(200).send({
success: true,
message: "Login Successful",
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
accountType: user.accountType,
accessToken: token,
},
});
res.json({ success: true, message });
} catch (error) {
log("Error occurred during login:", error);
res.status(500).send({ success: false, message: error.message });
console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.refreshToken = async (req, res) => {
exports.resetPassword = async (req, res, next) => {
try {
const refreshToken = req.cookies?.refresh_token;
if (!refreshToken) {
return res.status(401).send({
success: false,
message: "Refresh token is required",
const input = req.validated.body;
const userId = await consumePasswordResetToken(input.token);
if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" });
let changedUser;
await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || (user.password && await checkPassword(input.newPassword, user.password))) throw Object.assign(new Error("Invalid password change"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_RESET", transaction); changedUser = user;
});
sendPasswordChanged(changedUser).catch(() => {});
await logActivity({ user: changedUser, description: "Password reset and sessions revoked", type: "PASSWORD_RESET", module: "Authentication" });
res.json({ success: true, message: "Password reset successfully. Please login again" });
} catch (error) { next(error); }
};
exports.changePassword = async (req, res, next) => {
try {
const input = req.validated.body;
let changedUser;
await db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
if (!user?.password || !await checkPassword(input.currentPassword, user.password)) throw Object.assign(new Error("Current password is incorrect"), { status: 401, code: "INVALID_CREDENTIALS" });
if (await checkPassword(input.newPassword, user.password)) throw Object.assign(new Error("New password must be different"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_CHANGED", transaction); changedUser = user;
});
clearCookies(res); sendPasswordChanged(changedUser).catch(() => {});
await logActivity({ user: changedUser, description: "Password changed and sessions revoked", type: "PASSWORD_CHANGED", module: "Authentication" });
res.json({ success: true, message: "Password changed successfully. Please login again" });
} catch (error) { next(error); }
};
exports.verifyEmail = async (req, res, next) => {
try {
const userId = await consumeEmailVerificationToken(req.validated.body.token);
if (!userId) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
const user = await db.User.findByPk(userId);
if (!user) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
if (!user.emailVerifiedAt) { user.emailVerifiedAt = new Date(); user.accountStatus = "ACTIVE"; await user.save(); }
await logActivity({ user, description: "Email address verified", type: "EMAIL_VERIFIED", module: "Authentication" });
res.json({ success: true, message: "Email verified" });
} catch (error) { next(error); }
};
exports.resendVerification = async (req, res, next) => {
const message = "If verification is required, a new email has been sent";
try {
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
if (user && !user.emailVerifiedAt && user.accountStatus === "PENDING_VERIFICATION") {
const token = await createEmailVerification(user.id); await sendVerificationEmail(user.email, user.firstName, token);
}
const session = validateRefreshSession(refreshToken);
if (!session) {
res.clearCookie("refresh_token");
return res.status(401).send({
success: false,
message: "Invalid or expired session. Please login again.",
});
}
const user = await User.findByPk(session.userId);
if (!user || user.accountStatus !== "ACTIVE") {
deleteRefreshSession(session.sessionId);
return res.status(401).send({
success: false,
message: "Session is no longer valid",
});
}
// Generate new Access Token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
accountType: user.accountType,
});
// Generate new Refresh Token
const { refreshToken: newRefreshToken } = createRefreshSession(user.id);
deleteRefreshSession(session.sessionId);
// Replace access cookie
res.cookie("access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 15 * 60 * 1000,
});
// Replace refresh cookie
res.cookie("refresh_token", newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.status(200).send({
success: true,
message: "Session refreshed successfully",
});
res.json({ success: true, message });
} catch (error) {
console.error("REFRESH ERROR:", error);
return res.status(401).send({
success: false,
message: "Invalid or expired session. Please login again.",
});
console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.forgotPassword = async (req, res) => {
exports.oauth = (provider) => async (req, res, next) => {
try {
let { email } = req.body;
if (!email) {
return res.status(400).send({
success: false,
message: "Email is required",
});
}
email = email.trim().toLowerCase();
if (!validateEmail(email)) {
return res.status(400).send({
success: false,
message: "Invalid email address",
});
}
const user = await User.findOne({
where: { email },
});
if (!user) {
return res.status(200).send({
success: true,
message:
"If an account exists for this email, a password reset link has been sent.",
});
}
const resetToken = await createPasswordReset(user.id);
await sendPasswordResetEmail(user.email, user.firstName, resetToken);
return res.status(200).send({
success: true,
message:
"If an account exists for this email, a password reset link has been sent.",
});
} catch (error) {
console.error("FORGOT PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Unable to process password reset request",
});
}
const input = req.validated.body; const result = await authService.authenticateOAuth(provider, input, req);
const tokens = deliverTokens(req, res, result, input.clientType);
await logActivity({ user: result.user, description: `${provider} identity authenticated`, type: `${provider.toUpperCase()}_ACCOUNT_LINKED`, module: "Authentication" });
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
} catch (error) { next(Object.assign(error, { status: error.status || 401, code: error.code || "OAUTH_FAILED" })); }
};
exports.resetPassword = async (req, res) => {
exports.adminLogin = async (req, res, next) => {
try {
const { token, newPassword, confirmPassword } = req.body;
if (!token || !newPassword || !confirmPassword) {
return res.status(400).send({
success: false,
message: "Token, new password and confirm password are required",
});
}
if (newPassword !== confirmPassword) {
return res.status(400).send({
success: false,
message: "Passwords do not match",
});
}
if (!validatePassword(newPassword)) {
return res.status(400).send({
success: false,
message: "Password does not meet the required criteria",
});
}
const verification = await verifyPasswordResetToken(token);
if (!verification) {
return res.status(400).send({
success: false,
message: "Reset token is invalid or expired",
});
}
const { userId, redisKey } = verification;
const user = await User.findByPk(userId);
if (!user) {
await deletePasswordReset(redisKey);
return res.status(400).send({
success: false,
message: "Reset token is invalid or expired",
});
}
const samePassword = await checkPassword(newPassword, user.password);
if (samePassword) {
return res.status(400).send({
success: false,
message: "New password must be different from the current password",
});
}
const hashedPassword = await hashPassword(newPassword);
user.password = hashedPassword;
user.passwordChangedAt = new Date();
await user.save();
await sendPasswordChangedEmail(user.email, user.firstName);
await deletePasswordReset(redisKey);
deleteAllUserSessions(user.id);
return res.status(200).send({
success: true,
message: "Password reset successfully. Please login again.",
});
} catch (error) {
console.error("RESET PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Failed to reset password",
});
}
const { PRIVILEGED_ACCOUNT_TYPES } = require("../constants/accountTypes");
const result = await authService.beginPasswordLogin(req.validated.body, req, PRIVILEGED_ACCOUNT_TYPES);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
exports.changePassword = async (req, res) => {
exports.riderLogin = async (req, res, next) => {
try {
// User ID comes from authenticate middleware
const userId = req.user.id;
const { currentPassword, newPassword, confirmPassword } = req.body;
// 1. Check required fields
if (!currentPassword || !newPassword || !confirmPassword) {
return res.status(400).send({
success: false,
message:
"Current password, new password and confirm password are required",
});
}
// 2. Check new password and confirmation
if (newPassword !== confirmPassword) {
return res.status(400).send({
success: false,
message: "New password and confirm password do not match",
});
}
// 3. Validate password policy
const passwordValid = validatePassword(newPassword);
if (!passwordValid) {
return res.status(400).send({
success: false,
message: "New password does not meet the required criteria",
});
}
// 4. Get logged-in user from database
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).send({
success: false,
message: "User not found",
});
}
// 5. Check current password
const currentPasswordValid = await checkPassword(
currentPassword,
user.password,
);
if (!currentPasswordValid) {
return res.status(401).send({
success: false,
message: "Current password is incorrect",
});
}
// 6. Make sure new password is different
const sameAsOldPassword = await checkPassword(newPassword, user.password);
if (sameAsOldPassword) {
return res.status(400).send({
success: false,
message: "New password must be different from current password",
});
}
// 7. Hash new password
const hashedPassword = await hashPassword(newPassword);
// 8. Update user
user.password = hashedPassword;
user.passwordChangedAt = new Date();
await user.save();
// 9. Revoke all refresh sessions
deleteAllUserSessions(user.id);
// 10. Clear auth cookies
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
res.clearCookie("refresh_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// 11. Send confirmation email
try {
await sendPasswordChangedEmail(user.email, user.firstName);
} catch (mailError) {
console.error("PASSWORD CHANGED EMAIL ERROR:", mailError);
}
// 12. Response
return res.status(200).send({
success: true,
message: "Password changed successfully. Please login again.",
});
} catch (error) {
console.error("CHANGE PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Failed to change password",
});
}
};
// Logout: Clear the JWT cookie
exports.logout = async (req, res) => {
try {
// 1. Get refresh token from cookie
const refreshToken = req.cookies?.refresh_token;
// 2. If refresh token exists, find its session
if (refreshToken) {
const session = validateRefreshSession(refreshToken);
// 3. Delete refresh session from server RAM
if (session) {
deleteRefreshSession(session.sessionId);
console.log(`Refresh session deleted: ${session.sessionId}`);
}
}
// 4. Clear access token cookie
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// 5. Clear refresh token cookie
res.clearCookie("refresh_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// 6. Send response
return res.status(200).json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
console.error("LOGOUT ERROR:", error);
return res.status(500).json({
success: false,
message: "Failed to logout",
});
}
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
const result = await authService.beginPasswordLogin(req.validated.body, req, [ACCOUNT_TYPES.RIDER]);
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
} catch (error) { next(error); }
};
@@ -0,0 +1,21 @@
const db = require("../models");
const { clearPermissionCache } = require("../utils/cache.util");
const { logActivity } = require("../services/activity.service");
exports.assignRole = async (req, res, next) => {
try {
const [assignment] = await db.UserRole.findOrCreate({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
await clearPermissionCache(req.params.userId);
await logActivity({ user: req.user, description: `Role ${req.params.roleId} assigned to user ${req.params.userId}`, type: "ROLE_ASSIGNED", module: "Authorization" });
res.status(201).json({ success: true, data: { id: assignment.id, userId: assignment.user_id, roleId: assignment.role_id } });
} catch (error) { next(error); }
};
exports.removeRole = async (req, res, next) => {
try {
await db.UserRole.destroy({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
await clearPermissionCache(req.params.userId);
await logActivity({ user: req.user, description: `Role ${req.params.roleId} removed from user ${req.params.userId}`, type: "ROLE_REMOVED", module: "Authorization" });
res.json({ success: true, message: "Role removed" });
} catch (error) { next(error); }
};
+29 -25
View File
@@ -44,7 +44,6 @@ exports.createNewUser = async (req, res) => {
lastName,
email,
password,
accountType,
address,
phoneNumber,
businessName,
@@ -56,23 +55,23 @@ exports.createNewUser = async (req, res) => {
note,
} = req.body;
if (!firstName || !lastName || !email || !password || !accountType) {
if (!firstName || !lastName || !email || !password) {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"First name, last name, email, password and account type are required",
"First name, last name, email and password are required",
});
}
if (accountType !== "customer" && accountType !== "business_customer") {
if (req.body.accountType && req.body.accountType !== "customer") {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"Invalid account type. Must be either 'customer' or 'business_customer'",
"Public registration creates customer accounts only",
});
}
@@ -118,7 +117,7 @@ exports.createNewUser = async (req, res) => {
lastName,
email,
password: hashedPassword,
accountType,
accountType: "customer",
accountStatus: "PENDING_VERIFICATION",
emailVerifiedAt: null,
},
@@ -139,8 +138,8 @@ exports.createNewUser = async (req, res) => {
{ transaction },
);
//create customer and business customer
if (accountType === "customer") {
// Create the customer identity extension for public registration.
{
const customerData = {
address,phoneNumber,
};
@@ -150,23 +149,6 @@ exports.createNewUser = async (req, res) => {
customerData,
transaction,
);
} else if (accountType === "business_customer") {
const businessData = {
businessName,
businessRegistrationNumber,
businessType,
contactName,
phoneNumber,
businessEmail,
expectedMonthlyVolume,
note,
};
await createBusinessCustomerDetails(
newUser.id,
businessData,
transaction,
);
}
await transaction.commit();
@@ -449,6 +431,7 @@ exports.updateUser = async (req, res) => {
});
if (!user) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User not found",
@@ -456,6 +439,7 @@ exports.updateUser = async (req, res) => {
}
if (!UserProfile) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User profile not found",
@@ -533,6 +517,7 @@ exports.deleteUser = async (req, res) => {
where: { id },
});
if (!user) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User not found",
@@ -560,3 +545,22 @@ exports.deleteUser = async (req, res) => {
});
}
};
exports.getCurrentUser = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: Profile, as: "profile" }] });
res.json({ success: true, data: user });
} catch (error) { next(error); }
};
exports.updateCurrentUser = async (req, res, next) => {
try {
const allowed = ["firstName", "lastName"];
const supplied = Object.keys(req.body);
if (supplied.some((key) => !allowed.includes(key))) return res.status(400).json({ success: false, error: { code: "UNSAFE_FIELD", message: "Only firstName and lastName may be updated" } });
const updates = Object.fromEntries(supplied.map((key) => [key, req.body[key]]).filter(([, value]) => typeof value === "string" && value.trim()));
await User.update(updates, { where: { id: req.user.id } });
const user = await User.findByPk(req.user.id, { attributes: ["id", "firstName", "lastName", "email", "accountType", "accountStatus"] });
res.json({ success: true, data: user });
} catch (error) { next(error); }
};
+15 -82
View File
@@ -1,93 +1,26 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/middleware/auth.middleware.js
const { verifyToken } = require("../utils/jwt.util");
const { getEffectivePermissions } = require("../services/permission.service");
const db = require("../models");
const authenticate = async (req, res, next) => {
try {
let token = null;
// Get token from cookie
if (req.cookies?.access_token) {
token = req.cookies.access_token;
}
// Fallback to Bearer token
if (!token && req.headers.authorization?.startsWith("Bearer ")) {
token = req.headers.authorization.split(" ")[1];
}
if (!token) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
// Verify token
const token = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (!token) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
const decoded = verifyToken(token);
if (process.env.NODE_ENV === "development") {
console.log("DECODED:", decoded);
if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing");
const [user, session] = await Promise.all([
db.User.findByPk(decoded.sub),
db.AuthSession.findByPk(decoded.sid),
]);
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== decoded.tokenVersion || !session || session.user_id !== user.id || session.revoked_at || session.expires_at <= new Date() || session.token_version !== user.tokenVersion) {
return res.status(401).json({ success: false, error: { code: "SESSION_INVALID", message: "Session is no longer valid" } });
}
const userId = decoded.sub || decoded.id;
if (!userId) {
throw new Error("User ID missing in token");
}
// Load permissions
const permissions = await getEffectivePermissions(userId);
req.user = {
...decoded,
id: userId,
permissions,
};
req.user = { id: user.id, sessionId: session.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, permissions: await getEffectivePermissions(user.id) };
next();
} catch (err) {
console.error("AUTH ERROR:", err);
// Clear invalid/expired cookie
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// Token expired
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
message: "Session expired. Please login again.",
});
}
// Invalid token
if (err.name === "JsonWebTokenError") {
return res.status(401).json({
success: false,
message: "Invalid token",
});
}
// Default
return res.status(401).json({
success: false,
message: "Authentication failed",
});
} catch (error) {
res.clearCookie("access_token");
return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } });
}
};
module.exports = { authenticate };
module.exports = { authenticate, requireAuth: authenticate };
+19 -94
View File
@@ -1,100 +1,25 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/middleware/permission.middleware.js
const hasPermission = (userPermissions, requiredPermission) => {
return userPermissions.some(p => {
if (p === requiredPermission) return true;
// wildcard support
if (p.endsWith(".*")) {
const prefix = p.slice(0, -2);
return requiredPermission.startsWith(prefix);
}
return false;
});
};
const methodToAction = {
GET: "view",
POST: "create",
PUT: "update",
PATCH: "update",
DELETE: "delete"
};
const checkPermission = (baseOrFull, options = {}) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Unauthorized"
});
}
// admin bypass
if (req.user.accountType === "admin") {
return next();
}
if (typeof baseOrFull !== "string") {
return res.status(500).json({
success: false,
message: "Permission must be a string"
});
}
let requiredPermission;
if (options.custom) {
requiredPermission = baseOrFull;
} else {
const action = methodToAction[req.method];
if (!action) {
return res.status(500).json({
success: false,
message: "Unknown HTTP method"
});
}
requiredPermission = `${baseOrFull}.${action}`;
}
const userPermissions = req.user.permissions || [];
const hasPermission =
userPermissions.includes(requiredPermission) ||
userPermissions.includes(`${baseOrFull}.*`);
if (!hasPermission) {
return res.status(403).json({
success: false,
message: `Forbidden - Missing ${requiredPermission}`
});
}
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
const authorizedAccountType = (allowedTypes) => (req, res, next) => {
if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
if (req.user.accountType !== ACCOUNT_TYPES.SUPER_ADMIN && !allowedTypes.includes(req.user.accountType)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
next();
};
const checkPermission = (baseOrFull, options = {}) => (req, res, next) => {
if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN) return next();
const actions = { GET: "view", POST: "create", PUT: "update", PATCH: "update", DELETE: "delete" };
const required = options.custom ? baseOrFull : `${baseOrFull}.${actions[req.method]}`;
const permissions = req.user.permissions || [];
const allowed = permissions.includes(required) || permissions.some((value) => value.endsWith(".*") && required.startsWith(value.slice(0, -1)));
if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
next();
};
const authorizedAccountType = (allowedTypes) => {
return (req, res, next) => {
if (!req.user || !allowedTypes.includes(req.user.accountType)) {
return res
.status(403)
.json({ success: false, message: "Forbidden" });
}
next();
}
}
const requireOwnership = (param = "id") => (req, res, next) => {
if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN || req.user.accountType === ACCOUNT_TYPES.ADMIN || req.user.id === req.params[param]) return next();
return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
};
module.exports = {authorizedAccountType, checkPermission};
module.exports = { authorizedAccountType, requireAccountType: authorizedAccountType, checkPermission, requirePermission: checkPermission, requireOwnership };
+7
View File
@@ -0,0 +1,7 @@
module.exports = (schema) => (req, _res, next) => {
try {
req.validated = schema.parse({ body: req.body, params: req.params, query: req.query });
req.body = req.validated.body;
next();
} catch (error) { next(error); }
};
+3
View File
@@ -39,6 +39,8 @@ db.sequelize = sequelize;
db.User = require("./user/user.model")(sequelize, DataTypes);
db.Customer = require("./user/customer.model")(sequelize, DataTypes);
db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes);
db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes);
db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes);
db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes);
db.Profile = require("./user/profile.model")(sequelize, DataTypes);
@@ -50,6 +52,7 @@ db.roles = require("./permission/role.model")(sequelize, DataTypes);
db.permission = require("./permission/permission.model")(sequelize, DataTypes);
db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes);
db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes);
db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes);
// Document Management
db.Document = require("./document/document.model")(sequelize, DataTypes);
+3 -1
View File
@@ -19,7 +19,8 @@ module.exports = (sequelize, DataTypes) => {
},
roleName: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
unique: true
},
roleDescription: {
type: DataTypes.TEXT,
@@ -37,6 +38,7 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "role_id",
as: "rolePermissions"
});
roles.hasMany(db.UserRole, { foreignKey: "role_id", as: "userRoles" });
};
return roles;
+21 -1
View File
@@ -28,7 +28,27 @@ module.exports = (sequelize, DataTypes) => {
},
{
tableName: "rolePermission",
timestamps: true
timestamps: true,
indexes: [{ unique: true, fields: ["role_id", "permission_id"] }],
hooks: {
afterCreate: async (record) => {
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
},
afterDestroy: async (record) => {
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
},
afterUpdate: async (record) => {
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
},
afterBulkCreate: async (records) => {
const roleIds = [...new Set(records.map((record) => record.role_id))];
const users = await sequelize.models.UserRole.findAll({ where: { role_id: roleIds } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
}
}
}
);
@@ -29,7 +29,14 @@ module.exports = (sequelize, DataTypes) => {
},
{
tableName: "userPermission",
timestamps: true
timestamps: true,
indexes: [{ unique: true, fields: ["user_id", "permission_id"] }],
hooks: {
afterCreate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
afterUpdate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
afterDestroy: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id)
,afterBulkCreate: (records) => Promise.all(records.map((record) => require("../../utils/cache.util").clearPermissionCache(record.user_id)))
}
}
);
+15
View File
@@ -0,0 +1,15 @@
module.exports = (sequelize, DataTypes) => {
const UserRole = sequelize.define("UserRole", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.STRING, allowNull: false },
role_id: { type: DataTypes.STRING, allowNull: false },
}, { tableName: "user_roles", timestamps: true, indexes: [{ unique: true, fields: ["user_id", "role_id"] }], hooks: {
afterCreate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
afterDestroy: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
} });
UserRole.associate = (db) => {
UserRole.belongsTo(db.User, { foreignKey: "user_id", as: "user" });
UserRole.belongsTo(db.roles, { foreignKey: "role_id", as: "role" });
};
return UserRole;
};
+22
View File
@@ -0,0 +1,22 @@
module.exports = (sequelize, DataTypes) => {
const AuthSession = sequelize.define("AuthSession", {
id: { type: DataTypes.UUID, primaryKey: true },
user_id: { type: DataTypes.STRING, allowNull: false },
token_family_id: { type: DataTypes.UUID, allowNull: false },
refresh_token_hash: { type: DataTypes.STRING(64), allowNull: false },
device_name: DataTypes.STRING,
user_agent: DataTypes.STRING(500),
ip_address: DataTypes.STRING(64),
remember_me: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
token_version: { type: DataTypes.INTEGER, allowNull: false },
last_used_at: DataTypes.DATE,
expires_at: { type: DataTypes.DATE, allowNull: false },
revoked_at: DataTypes.DATE,
revoked_reason: DataTypes.STRING,
replaced_by_session_id: DataTypes.UUID,
}, { tableName: "auth_sessions", timestamps: true, indexes: [
{ fields: ["user_id"] }, { fields: ["token_family_id"] }, { fields: ["expires_at"] },
] });
AuthSession.associate = (db) => AuthSession.belongsTo(db.User, { foreignKey: "user_id", as: "user" });
return AuthSession;
};
+7 -2
View File
@@ -33,10 +33,10 @@ module.exports = (sequelize, DataTypes) => {
},
password: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true,
},
accountType: {
type: DataTypes.ENUM("business_customer", "customer"),
type: DataTypes.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"),
defaultValue: "customer",
},
accountStatus: {
@@ -59,6 +59,8 @@ module.exports = (sequelize, DataTypes) => {
allowNull: true,
defaultValue: null,
},
tokenVersion: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
lastLoginAt: { type: DataTypes.DATE, allowNull: true },
},
{
tableName: "users",
@@ -83,6 +85,9 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "user_id",
as: "businessCustomer",
});
User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" });
User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" });
User.hasMany(db.UserRole, { foreignKey: "user_id", as: "userRoles" });
};
return User;
+13
View File
@@ -0,0 +1,13 @@
module.exports = (sequelize, DataTypes) => {
const UserIdentity = sequelize.define("UserIdentity", {
id: { type: DataTypes.UUID, primaryKey: true },
user_id: { type: DataTypes.STRING, allowNull: false },
provider: { type: DataTypes.ENUM("google", "apple"), allowNull: false },
provider_subject: { type: DataTypes.STRING, allowNull: false },
provider_email: DataTypes.STRING,
}, { tableName: "user_identities", timestamps: true, indexes: [
{ unique: true, fields: ["provider", "provider_subject"] }, { fields: ["user_id"] },
] });
UserIdentity.associate = (db) => UserIdentity.belongsTo(db.User, { foreignKey: "user_id", as: "user" });
return UserIdentity;
};
+10
View File
@@ -0,0 +1,10 @@
const express = require("express");
const auth = require("../controllers/auth.controller");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const router = express.Router();
router.use(sensitiveLimiter);
router.post("/login", validate(schemas.login), auth.adminLogin);
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
module.exports = router;
+9
View File
@@ -0,0 +1,9 @@
const express = require("express");
const controller = require("../controllers/adminUser.controller");
const { authenticate } = require("../middleware/auth.middleware");
const { authorizedAccountType } = require("../middleware/permission.middleware");
const router = express.Router();
router.use(authenticate, authorizedAccountType(["superadmin"]));
router.patch("/:id/status", controller.updateStatus);
router.patch("/:id/account-type", controller.updateAccountType);
module.exports = router;
+22 -31
View File
@@ -1,36 +1,27 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
const express = require("express");
const auth = require("../controllers/auth.controller");
const user = require("../controllers/user.controller");
const { authenticate } = require("../middleware/auth.middleware");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
// app/routes/auth.routes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/auth.controller');
const {authenticate} = require('../middleware/auth.middleware');
const { sensitiveLimiter } = require('../middleware/rateLimit.middleware');
router.use(sensitiveLimiter);
// GET /api/auth/me
router.get("/me", authenticate, (req, res) => {
res.json({
authenticated: true,
user: req.user
});
});
router.post('/req-otp', authController.loginReq);
router.post('/login', authController.login);
router.post('/refresh', authController.refreshToken);
router.post('/forgot-password', authController.forgotPassword);
router.post('/reset-password', authController.resetPassword);
router.post('/change-password', authenticate, authController.changePassword);
router.post('/logout', authController.logout);
router.post("/register", validate(schemas.register), user.createNewUser);
router.post("/login", validate(schemas.login), auth.login);
router.post("/req-otp", validate(schemas.login), auth.loginReq);
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
router.post("/refresh", validate(schemas.refresh), auth.refreshToken);
router.post("/logout", auth.logout);
router.post("/logout-all", authenticate, auth.logoutAll);
router.post("/forgot-password", validate(schemas.forgot), auth.forgotPassword);
router.post("/reset-password", validate(schemas.reset), auth.resetPassword);
router.post("/change-password", authenticate, validate(schemas.change), auth.changePassword);
router.post("/verify-email", validate(schemas.verifyEmail), auth.verifyEmail);
router.post("/resend-verification", validate(schemas.resend), auth.resendVerification);
router.post("/google", validate(schemas.oauth), auth.oauth("google"));
router.post("/apple", validate(schemas.oauth), auth.oauth("apple"));
router.get("/me", authenticate, auth.me);
module.exports = router;
+7
View File
@@ -20,6 +20,9 @@ const docsRoutes = require("./docs.routes");
const permissionRoutes = require("./permission.routes");
const profileRoutes = require("./profile.routes");
const notificationRoutes = require("./notification.routes");
const adminAuthRoutes = require("./adminAuth.routes");
const riderAuthRoutes = require("./riderAuth.routes");
const adminUserRoutes = require("./adminUser.routes");
const router = express.Router();
@@ -31,5 +34,9 @@ router.use("/document", documentRoutes);
router.use("/docs", docsRoutes);
router.use("/profile", profileRoutes);
router.use("/notification", notificationRoutes);
router.use("/permissions", permissionRoutes);
router.use("/admin/auth", adminAuthRoutes);
router.use("/rider/auth", riderAuthRoutes);
router.use("/admin/users", adminUserRoutes);
module.exports = router;
+7
View File
@@ -14,6 +14,13 @@ const router = express.Router();
const permissionController = require("../controllers/permission.controller");
const { authenticate } = require("../middleware/auth.middleware");
const { authorizedAccountType } = require("../middleware/permission.middleware");
const roleAssignmentController = require("../controllers/roleAssignment.controller");
router.use(authenticate, authorizedAccountType(["superadmin"]));
router.post("/users/:userId/roles/:roleId", roleAssignmentController.assignRole);
router.delete("/users/:userId/roles/:roleId", roleAssignmentController.removeRole);
router.get("/roles", permissionController.getAllRoles);
router.get("/roles/:roleId", permissionController.getRoleById);
// Permission routes
router.post(
+10 -6
View File
@@ -12,6 +12,7 @@
const express = require("express");
const router = express.Router();
const profileController = require("../controllers/profile.controller.js");
const authController = require("../controllers/auth.controller.js");
const { authenticate } = require("../middleware/auth.middleware");
const {
@@ -20,29 +21,32 @@ const {
} = require("../middleware/permission.middleware");
const PERMISSIONS = require("../constants/permissions");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { requireOwnership } = require("../middleware/permission.middleware");
router.post("/req-reset-password", sensitiveLimiter, profileController.requestPasswordReset);
router.post("/req-reset-password", sensitiveLimiter, validate(schemas.forgot), authController.forgotPassword);
router.post("/reset-password", sensitiveLimiter, profileController.resetPassword);
router.post("/reset-password", sensitiveLimiter, validate(schemas.reset), authController.resetPassword);
router.post(
"/change-password",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
profileController.changePassword,
validate(schemas.change),
authController.changePassword,
);
router.get(
"/avatar/:userId",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
requireOwnership("userId"),
profileController.getProfileAvatar,
);
router.get(
"/background/:userId",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
requireOwnership("userId"),
profileController.getProfileBackgroundImage,
);
+10
View File
@@ -0,0 +1,10 @@
const express = require("express");
const auth = require("../controllers/auth.controller");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const router = express.Router();
router.use(sensitiveLimiter);
router.post("/login", validate(schemas.login), auth.riderLogin);
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
module.exports = router;
+6 -3
View File
@@ -19,6 +19,9 @@ const {
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
const PERMISSIONS = require("../constants/permissions");
router.get("/me", authenticate, userController.getCurrentUser);
router.patch("/me", authenticate, userController.updateCurrentUser);
router.post(
"/",
@@ -41,7 +44,7 @@ router.get(
router.get(
"/",
authenticate,
authorizedAccountType(["admin"]),
authorizedAccountType(["admin", "superadmin"]),
userController.getAllUsers
);
@@ -55,14 +58,14 @@ router.get(
router.patch(
"/:id",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
authorizedAccountType(["superadmin"]),
userController.updateUser
);
router.delete(
"/:id",
authenticate,
authorizedAccountType(["admin"]),
authorizedAccountType(["superadmin"]),
userController.deleteUser
);
+65
View File
@@ -0,0 +1,65 @@
const crypto = require("crypto");
const db = require("../../models");
const { checkPassword } = require("../../utils/hashPassword.util");
const { generateToken } = require("../../utils/jwt.util");
const { generateUserId, generateId } = require("../../utils/idGen.util");
const { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES } = require("../../constants/accountTypes");
const { createLoginChallenge, verifyLoginChallenge } = require("./otp.service");
const { createSession, rotateSession } = require("./session.service");
const { sendLoginOtp } = require("./email.service");
const oauth = require("./oauth.service");
const authError = (code = "INVALID_CREDENTIALS", status = 401) => Object.assign(new Error(code === "ACCOUNT_NOT_ACTIVE" ? "Account is not active" : "Authentication failed"), { code, status });
const contextFromRequest = (req, deviceName) => ({ deviceName, userAgent: req.get("user-agent")?.slice(0, 500), ipAddress: req.ip });
const tokenPair = (user, session, refreshToken) => ({ accessToken: generateToken({ userId: user.id, sessionId: session.id, tokenVersion: user.tokenVersion }), refreshToken, refreshExpiresAt: session.expires_at });
const beginPasswordLogin = async ({ email, password, rememberMe, deviceName }, req, allowedTypes) => {
const user = await db.User.findOne({ where: { email: email.toLowerCase() } });
const valid = user?.password && await checkPassword(password, user.password);
if (!user || !valid || (allowedTypes && !allowedTypes.includes(user.accountType))) throw authError();
if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const context = contextFromRequest(req, deviceName);
const challenge = await createLoginChallenge({ userId: user.id, rememberMe, context });
await sendLoginOtp(user, challenge.otp);
return { challengeId: challenge.challengeId };
};
const completeOtpLogin = async ({ challengeId, otp }, req) => {
const challenge = await verifyLoginChallenge(challengeId, otp);
return db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(challenge.userId, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const created = await createSession({ user, rememberMe: challenge.rememberMe, ...challenge.context, ...contextFromRequest(req, challenge.context.deviceName), transaction });
user.lastLoginAt = new Date(); await user.save({ transaction });
return { user, ...tokenPair(user, created.session, created.refreshToken) };
});
};
const refresh = async (refreshToken, req) => {
const rotated = await rotateSession(refreshToken, contextFromRequest(req));
return { user: rotated.user, ...tokenPair(rotated.user, rotated.session, rotated.refreshToken) };
};
const authenticateOAuth = async (provider, input, req) => {
const verified = provider === "google" ? await oauth.verifyGoogleToken(input.idToken) : await oauth.verifyAppleToken(input.idToken);
return db.sequelize.transaction(async (transaction) => {
let identity = await db.UserIdentity.findOne({ where: { provider, provider_subject: verified.subject }, transaction, lock: transaction.LOCK.UPDATE });
let user = identity && await db.User.findByPk(identity.user_id, { transaction });
if (!user) {
if (!verified.email || !verified.emailVerified) throw authError("OAUTH_LINK_REQUIRED", 403);
user = await db.User.findOne({ where: { email: verified.email }, transaction, lock: transaction.LOCK.UPDATE });
if (user && (PRIVILEGED_ACCOUNT_TYPES.includes(user.accountType) || user.accountType === ACCOUNT_TYPES.RIDER)) throw authError("OAUTH_LINK_REQUIRED", 403);
if (!user) {
user = await db.User.create({ id: generateUserId(), firstName: verified.firstName || input.firstName || "ZUMRI", lastName: verified.lastName || input.lastName || "Customer", email: verified.email, password: null, accountType: ACCOUNT_TYPES.CUSTOMER, accountStatus: "ACTIVE", emailVerifiedAt: new Date(), tokenVersion: 0 }, { transaction });
await db.Profile.create({ profile_id: generateId(), user_id: user.id, theme: "light", notificationsEnabled: true }, { transaction });
}
identity = await db.UserIdentity.create({ id: crypto.randomUUID(), user_id: user.id, provider, provider_subject: verified.subject, provider_email: verified.email }, { transaction });
}
if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const created = await createSession({ user, rememberMe: input.rememberMe, ...contextFromRequest(req, input.deviceName), transaction });
user.lastLoginAt = new Date(); await user.save({ transaction });
return { user, identity, ...tokenPair(user, created.session, created.refreshToken) };
});
};
module.exports = { beginPasswordLogin, completeOtpLogin, refresh, authenticateOAuth, contextFromRequest, tokenPair };
+6
View File
@@ -0,0 +1,6 @@
const { sendMail } = require("../../utils/mail.util");
const sendLoginOtp = (user, otp) => sendMail({ to: user.email, subject: "Your ZUMRI login code", templateName: "otp", templateVars: { firstName: user.firstName, otp }, text: `Your ZUMRI login code is ${otp}.` });
const sendPasswordChanged = (user) => sendMail({ to: user.email, subject: "Your ZUMRI password was changed", templateName: "passwordChanged", templateVars: { customer_name: user.firstName, changed_at: new Date().toLocaleString() }, text: "Your ZUMRI password was changed. Contact support if this was not you." });
module.exports = { sendLoginOtp, sendPasswordChanged };
+31
View File
@@ -0,0 +1,31 @@
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const { OAuth2Client } = require("google-auth-library");
let appleKeys;
let appleKeysAt = 0;
const verifyGoogleToken = async (idToken) => {
if (!process.env.GOOGLE_CLIENT_ID) throw Object.assign(new Error("Google authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" });
const ticket = await new OAuth2Client(process.env.GOOGLE_CLIENT_ID).verifyIdToken({ idToken, audience: process.env.GOOGLE_CLIENT_ID });
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email || payload.email_verified !== true) throw new Error("Invalid Google identity token");
return { subject: payload.sub, email: payload.email.toLowerCase(), emailVerified: true, firstName: payload.given_name, lastName: payload.family_name };
};
const getAppleKeys = async () => {
if (appleKeys && Date.now() - appleKeysAt < 3600000) return appleKeys;
const response = await fetch("https://appleid.apple.com/auth/keys");
if (!response.ok) throw new Error("Apple key service unavailable");
appleKeys = (await response.json()).keys; appleKeysAt = Date.now(); return appleKeys;
};
const verifyAppleToken = async (idToken) => {
if (!process.env.APPLE_CLIENT_ID) throw Object.assign(new Error("Apple authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" });
const decoded = jwt.decode(idToken, { complete: true });
const key = (await getAppleKeys()).find((candidate) => candidate.kid === decoded?.header?.kid && candidate.alg === "RS256");
if (!key) throw new Error("Invalid Apple identity token");
const payload = jwt.verify(idToken, crypto.createPublicKey({ key, format: "jwk" }), { algorithms: ["RS256"], issuer: "https://appleid.apple.com", audience: process.env.APPLE_CLIENT_ID });
if (!payload.sub) throw new Error("Invalid Apple identity token");
return { subject: payload.sub, email: payload.email?.toLowerCase(), emailVerified: payload.email_verified === true || payload.email_verified === "true" };
};
module.exports = { verifyGoogleToken, verifyAppleToken };
+29
View File
@@ -0,0 +1,29 @@
const crypto = require("crypto");
const redis = require("../../config/redisClient");
const otpHash = (challengeId, otp) => crypto.createHmac("sha256", process.env.JWT_SECRET).update(`${challengeId}:${otp}`).digest("hex");
const generateOtp = () => crypto.randomInt(0, 1000000).toString().padStart(6, "0");
const createLoginChallenge = async ({ userId, rememberMe, context }) => {
const challengeId = crypto.randomUUID();
const otp = generateOtp();
await redis.set(`login:${challengeId}`, JSON.stringify({ userId, otpHash: otpHash(challengeId, otp), attempts: 0, rememberMe, context }), "EX", Number(process.env.LOGIN_OTP_TTL_SECONDS || 900));
return { challengeId, otp };
};
const VERIFY_SCRIPT = `
local raw=redis.call('GET',KEYS[1]); if not raw then return {-3} end
local value=cjson.decode(raw)
if value.otpHash==ARGV[1] then redis.call('DEL',KEYS[1]); return {1,value.userId,cjson.encode(value)} end
value.attempts=(value.attempts or 0)+1
if value.attempts>=tonumber(ARGV[2]) then redis.call('DEL',KEYS[1]); return {-2} end
redis.call('SET',KEYS[1],cjson.encode(value),'KEEPTTL'); return {-1}
`;
const verifyLoginChallenge = async (challengeId, otp) => {
const result = await redis.eval(VERIFY_SCRIPT, 1, `login:${challengeId}`, otpHash(challengeId, otp), Number(process.env.LOGIN_OTP_MAX_ATTEMPTS || 5));
if (Number(result[0]) !== 1) throw Object.assign(new Error("Invalid or expired challenge"), { code: "INVALID_OTP", status: 401 });
const stored = JSON.parse(result[2]);
return { userId: result[1], rememberMe: Boolean(stored.rememberMe), context: stored.context || {} };
};
module.exports = { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge };
+59
View File
@@ -0,0 +1,59 @@
const crypto = require("crypto");
const db = require("../../models");
const digest = (token) => crypto.createHash("sha256").update(token).digest("hex");
const safeEqual = (left, right) => left?.length === right?.length && crypto.timingSafeEqual(Buffer.from(left), Buffer.from(right));
const rawToken = (id) => `${id}.${crypto.randomBytes(48).toString("base64url")}`;
const tokenId = (token) => typeof token === "string" ? token.split(".", 1)[0] : null;
const ttlDays = (rememberMe) => Number(process.env[rememberMe ? "REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS" : "REFRESH_TOKEN_TTL_DAYS"] || (rememberMe ? 30 : 7));
const createSession = async ({ user, rememberMe = false, deviceName, userAgent, ipAddress, familyId, transaction }) => {
const id = crypto.randomUUID();
const refreshToken = rawToken(id);
const expiresAt = new Date(Date.now() + ttlDays(rememberMe) * 86400000);
const session = await db.AuthSession.create({
id, user_id: user.id, token_family_id: familyId || crypto.randomUUID(), refresh_token_hash: digest(refreshToken),
device_name: deviceName, user_agent: userAgent, ip_address: ipAddress, remember_me: rememberMe,
token_version: user.tokenVersion, last_used_at: new Date(), expires_at: expiresAt,
}, { transaction });
return { session, refreshToken, expiresAt };
};
const revokeFamily = async (familyId, reason, transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason },
{ where: { token_family_id: familyId, revoked_at: null }, transaction },
);
const rotateSession = async (token, context = {}) => db.sequelize.transaction(async (transaction) => {
const id = tokenId(token);
if (!id) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
const session = await db.AuthSession.findByPk(id, { transaction, lock: transaction.LOCK.UPDATE });
if (!session) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
if (!safeEqual(digest(token), session.refresh_token_hash)) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
if (session.revoked_at) {
if (session.revoked_reason === "ROTATED") await revokeFamily(session.token_family_id, "REFRESH_TOKEN_REUSE", transaction);
throw Object.assign(new Error("Invalid session"), { code: session.revoked_reason === "ROTATED" ? "REFRESH_TOKEN_REUSE" : "INVALID_SESSION", status: 401 });
}
if (session.expires_at <= new Date()) {
session.revoked_at = new Date(); session.revoked_reason = "EXPIRED"; await session.save({ transaction });
throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
}
const user = await db.User.findByPk(session.user_id, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== session.token_version) {
await revokeFamily(session.token_family_id, "ACCOUNT_OR_TOKEN_VERSION_INVALID", transaction);
throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
}
const replacement = await createSession({ user, rememberMe: session.remember_me, familyId: session.token_family_id, ...context, transaction });
session.revoked_at = new Date(); session.revoked_reason = "ROTATED"; session.replaced_by_session_id = replacement.session.id;
session.last_used_at = new Date(); await session.save({ transaction });
return { ...replacement, user };
});
const revokeSession = async (id, reason = "LOGOUT", transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason }, { where: { id, revoked_at: null }, transaction },
);
const revokeAllUserSessions = async (userId, reason, transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason }, { where: { user_id: userId, revoked_at: null }, transaction },
);
module.exports = { createSession, rotateSession, revokeSession, revokeFamily, revokeAllUserSessions, hashRefreshToken: digest, tokenId };
+5 -3
View File
@@ -12,6 +12,7 @@
const db = require("../models");
const User = db.User;
const RolePermission = db.rolePermission;
const UserRole = db.UserRole;
const UserPermission = db.userPermission;
const {
@@ -32,10 +33,11 @@ const getEffectivePermissions = async (userId) => {
const user = await User.findByPk(userId);
if (!user) return [];
const rolePermissions = await RolePermission.findAll({
where: { role_id: user.roleID },
const roles = await UserRole.findAll({ where: { user_id: userId }, attributes: ["role_id"] });
const rolePermissions = roles.length ? await RolePermission.findAll({
where: { role_id: roles.map((role) => role.role_id) },
attributes: ["permission_id"]
});
}) : [];
const userPermissions = await UserPermission.findAll({
where: { user_id: userId },
+15 -1
View File
@@ -16,6 +16,8 @@ const hashEmailVerificationToken = (token) => {
};
const createEmailVerification = async (userId) => {
const previousKey = await redis.get(`email-verification-user:${userId}`);
if (previousKey) await redis.del(previousKey);
// 1. Generate raw token
const token = generateEmailVerificationToken();
@@ -26,6 +28,7 @@ const createEmailVerification = async (userId) => {
const redisKey = `email-verification:${tokenHash}`;
await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL);
await redis.set(`email-verification-user:${userId}`, redisKey, "EX", EMAIL_VERIFICATION_TTL);
return token;
};
@@ -45,12 +48,14 @@ const verifyEmailVerificationToken = async (token) => {
const redisKey = `email-verification:${tokenHash}`;
// 3. Search Redis
const userId = await redis.get(redisKey);
const userId = await redis.getdel(redisKey);
if (!userId) {
return null;
}
await redis.del(`email-verification-user:${userId}`);
return {
userId,
redisKey,
@@ -82,10 +87,19 @@ const deleteEmailVerification = async (redisKey) => {
await redis.del(redisKey);
};
const consumeEmailVerificationToken = async (token) => {
if (!token || typeof token !== "string") return null;
const redisKey = `email-verification:${hashEmailVerificationToken(token)}`;
const userId = await redis.getdel(redisKey);
if (userId) await redis.del(`email-verification-user:${userId}`);
return userId;
};
module.exports = {
createEmailVerification,
verifyEmailVerificationToken,
sendVerificationEmail,
deleteEmailVerification,
consumeEmailVerificationToken,
hashEmailVerificationToken,
};
+4 -3
View File
@@ -10,6 +10,7 @@
// app/utils/idGen.util.js
const { v4: uuidv4 } = require("uuid");
const crypto = require("crypto");
const { nextSequence } = require("./referenceNumber.util");
const {log} = require("./consoleLog.utill");
@@ -20,15 +21,15 @@ const { generateDeliveryNote } = require("./id/dispatchNote.utill");
const generateUserId = () => {
return "usr_" + Math.random().toString(36).slice(2, 10);
return "usr_" + crypto.randomUUID();
};
const generateCustomerId = () => {
return "cust_" + Math.random().toString(36).slice(2, 10);
return "cust_" + crypto.randomUUID();
}
const generateBusinessCustomerId = () => {
return "b_cust_" + Math.random().toString(36).slice(2, 10);
return "b_cust_" + crypto.randomUUID();
}
const generateClientId = () => {
+20 -45
View File
@@ -1,51 +1,26 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/utils/jwt.util.js
const jwt = require("jsonwebtoken");
require("dotenv").config();
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity
const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity
const getSecret = (name) => {
const value = process.env[name];
if (!value || value.length < 32) throw new Error(`${name} is not configured securely`);
return value;
const secret = () => {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error("JWT_SECRET is not configured securely");
return process.env.JWT_SECRET;
};
/**
* Generate JWT token
* @param {Object} payload - usually { id, email, role }
* @returns string
*/
const generateToken = (payload) => {
return jwt.sign(payload, getSecret("JWT_SECRET"), { expiresIn: JWT_EXPIRES_IN });
};
const generateToken = ({ userId, sessionId, tokenVersion }) => jwt.sign(
{ sid: sessionId, tokenVersion },
secret(),
{
algorithm: "HS256",
subject: userId,
issuer: process.env.JWT_ISSUER || "zumri-api",
audience: process.env.JWT_AUDIENCE || "zumri-clients",
expiresIn: process.env.ACCESS_TOKEN_TTL || "15m",
},
);
/**
* Verify JWT token
* @param {string} token
* @returns payload or throws error
*/
const verifyToken = (token) => {
return jwt.verify(token, getSecret("JWT_SECRET"));
};
const verifyToken = (token) => jwt.verify(token, secret(), {
algorithms: ["HS256"],
issuer: process.env.JWT_ISSUER || "zumri-api",
audience: process.env.JWT_AUDIENCE || "zumri-clients",
});
const generateRefreshToken = (payload) => {
return jwt.sign(payload, getSecret("REFRESH_TOKEN_SECRET"), { expiresIn: REFRESH_TOKEN_DAYS });
}
const verifyRefreshToken = (token) => {
return jwt.verify(token, getSecret("REFRESH_TOKEN_SECRET"));
}
module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken };
module.exports = { generateToken, verifyToken };
-39
View File
@@ -1,39 +0,0 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/utils/otp.util.js
const otpCache = new Map();
function generateOTP(key){
const otp = Math.floor(100000 + Math.random() * 900000).toString();
saveOTP(key, otp);
return otp;
}
function saveOTP(key, otp) {
ttl = parseInt(process.env.LOGIN_OTP_TTL_SECONDS || 300);
const expiresAt = Date.now() + ttl * 1000; // Convert seconds to milliseconds
otpCache.set(key, { otp, expiresAt });
}
// Validate OTP
function validateOTP(key, otp) {
const record = otpCache.get(key);
if (!record) return false;
if (Date.now() > record.expiresAt) {
otpCache.delete(key);
return false;
}
const isValid = record.otp === otp;
if (isValid) otpCache.delete(key); // OTP can be used only once
return isValid;
}
module.exports = { generateOTP, validateOTP };
+6
View File
@@ -69,6 +69,11 @@ const deletePasswordReset = async (redisKey) => {
await redis.del(redisKey);
};
const consumePasswordResetToken = async (token) => {
if (!token || typeof token !== "string") return null;
return redis.getdel(`password-reset:${hashPasswordResetToken(token)}`);
};
const sendPasswordResetEmail =
async (email, firstName, resetToken) => {
@@ -130,6 +135,7 @@ module.exports = {
createPasswordReset,
verifyPasswordResetToken,
deletePasswordReset,
consumePasswordResetToken,
hashPasswordResetToken,
sendPasswordResetEmail,
sendPasswordChangedEmail,
-201
View File
@@ -1,201 +0,0 @@
// app/utils/refreshSession.util.js
const crypto = require("crypto");
const {
generateRefreshToken,
verifyRefreshToken,
} = require("./jwt.util");
const refreshSessions = new Map();
// Default = 7 days
const REFRESH_SESSION_TTL =
7 * 24 * 60 * 60 * 1000;
const hashRefreshToken = (token) => {
return crypto
.createHash("sha256")
.update(token)
.digest("hex");
};
const createRefreshSession = (userId) => {
// Unique session ID
const sessionId =
crypto.randomUUID();
// Create raw Refresh JWT
const refreshToken =
generateRefreshToken({
sub: userId,
sid: sessionId,
});
// Hash raw refresh token
const tokenHash =
hashRefreshToken(
refreshToken
);
// Expiration time
const expiresAt =
Date.now() +
REFRESH_SESSION_TTL;
// Save only HASH in RAM
refreshSessions.set(
sessionId,
{
userId,
tokenHash,
expiresAt,
}
);
return {
refreshToken,
sessionId,
};
};
const validateRefreshSession = (
refreshToken
) => {
if (!refreshToken) {
return null;
}
let decoded;
try {
decoded =
verifyRefreshToken(
refreshToken
);
} catch (error) {
return null;
}
const sessionId =
decoded.sid;
const userId =
decoded.sub;
if (!sessionId || !userId) {
return null;
}
// Get session from RAM
const session =
refreshSessions.get(
sessionId
);
if (!session) {
return null;
}
// Check session expiration
if (
Date.now() >
session.expiresAt
) {
refreshSessions.delete(
sessionId
);
return null;
}
// Hash received refresh token
const receivedHash =
hashRefreshToken(
refreshToken
);
// Compare stored hash
if (
receivedHash !==
session.tokenHash
) {
return null;
}
// Extra user check
if (
session.userId !==
userId
) {
return null;
}
return {
userId,
sessionId,
};
};
const deleteRefreshSession = (
sessionId
) => {
refreshSessions.delete(
sessionId
);
};
const deleteAllUserSessions = (
userId
) => {
for (
const [sessionId, session]
of refreshSessions.entries()
) {
if (
session.userId === userId
) {
refreshSessions.delete(
sessionId
);
}
}
};
module.exports = {
createRefreshSession,
validateRefreshSession,
deleteRefreshSession,
deleteAllUserSessions,
};
+3 -30
View File
@@ -1,32 +1,5 @@
//app/utils/validation/validatePassword.util.js
const validatePassword = (password) => {
// Check if password is a string
if (typeof password !== "string") {
return false;
}
const { passwordSchema } = require("../../validation/auth.schemas");
// At least 1 uppercase letter
const hasUppercase = /[A-Z]/.test(password);
const validatePassword = (password) => passwordSchema.safeParse(password).success;
// At least 1 lowercase letter
const hasLowercase = /[a-z]/.test(password);
// At least 1 symbol
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// At least 4 numbers
const numberCount = (password.match(/[0-9]/g) || []).length;
const hasFourNumbers = numberCount >= 4;
return (
hasUppercase &&
hasLowercase &&
hasSymbol &&
hasFourNumbers
);
};
module.exports = {
validatePassword,
};
module.exports = { validatePassword };
+22
View File
@@ -0,0 +1,22 @@
const { z } = require("zod");
const email = z.string().trim().toLowerCase().email();
const password = z.string().min(12).superRefine((value, context) => {
const failures = [!/[A-Z]/.test(value), !/[a-z]/.test(value), !/[^A-Za-z0-9]/.test(value), (value.match(/\d/g) || []).length < 4];
if (failures.some(Boolean)) context.addIssue({ code: "custom", message: "Password must include uppercase, lowercase, a symbol, and at least four numbers" });
});
const body = (shape) => z.object({ body: z.object(shape).strict(), params: z.object({}).passthrough(), query: z.object({}).passthrough() });
module.exports = {
passwordSchema: password,
register: body({ firstName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100), email, password, phoneNumber: z.string().trim().min(1), address: z.string().trim().min(1), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
login: body({ email, password: z.string().min(1), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional() }),
verifyOtp: body({ challengeId: z.string().uuid(), otp: z.string().regex(/^\d{6}$/), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
refresh: body({ refreshToken: z.string().min(20).optional(), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
forgot: body({ email }),
reset: body({ token: z.string().min(20), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }),
change: body({ currentPassword: z.string().min(1), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }),
verifyEmail: body({ token: z.string().min(20) }),
resend: body({ email }),
oauth: body({ idToken: z.string().min(20), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional(), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional() }),
};
@@ -0,0 +1,61 @@
"use strict";
module.exports = {
async up(queryInterface, Sequelize) {
const userColumns = await queryInterface.describeTable("users");
await queryInterface.changeColumn("users", "accountType", {
type: Sequelize.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"),
allowNull: false, defaultValue: "customer",
});
await queryInterface.changeColumn("users", "password", { type: Sequelize.STRING, allowNull: true });
if (!userColumns.passwordChangedAt) await queryInterface.addColumn("users", "passwordChangedAt", { type: Sequelize.DATE, allowNull: true });
await queryInterface.addColumn("users", "tokenVersion", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 });
await queryInterface.addColumn("users", "lastLoginAt", { type: Sequelize.DATE, allowNull: true });
await queryInterface.createTable("auth_sessions", {
id: { type: Sequelize.UUID, primaryKey: true }, user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" },
token_family_id: { type: Sequelize.UUID, allowNull: false }, refresh_token_hash: { type: Sequelize.STRING(64), allowNull: false },
device_name: Sequelize.STRING, user_agent: Sequelize.STRING(500), ip_address: Sequelize.STRING(64),
remember_me: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, token_version: { type: Sequelize.INTEGER, allowNull: false },
last_used_at: Sequelize.DATE, expires_at: { type: Sequelize.DATE, allowNull: false }, revoked_at: Sequelize.DATE,
revoked_reason: Sequelize.STRING, replaced_by_session_id: Sequelize.UUID,
createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex("auth_sessions", ["user_id"]);
await queryInterface.addIndex("auth_sessions", ["token_family_id"]);
await queryInterface.addIndex("auth_sessions", ["expires_at"]);
await queryInterface.createTable("user_identities", {
id: { type: Sequelize.UUID, primaryKey: true }, user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" },
provider: { type: Sequelize.ENUM("google", "apple"), allowNull: false }, provider_subject: { type: Sequelize.STRING, allowNull: false },
provider_email: Sequelize.STRING, createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex("user_identities", ["provider", "provider_subject"], { unique: true, name: "user_identity_provider_subject_unique" });
await queryInterface.addIndex("user_identities", ["user_id"]);
await queryInterface.createTable("user_roles", {
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" },
role_id: { type: Sequelize.STRING, allowNull: false, references: { model: "roles", key: "role_id" }, onDelete: "CASCADE" },
createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false },
});
await queryInterface.addIndex("user_roles", ["user_id", "role_id"], { unique: true, name: "user_role_unique" });
await queryInterface.addIndex("rolePermission", ["role_id", "permission_id"], { unique: true, name: "role_permission_unique" });
await queryInterface.addIndex("userPermission", ["user_id", "permission_id"], { unique: true, name: "user_permission_unique" });
await queryInterface.addIndex("roles", ["roleName"], { unique: true, name: "role_name_unique" });
},
async down(queryInterface, Sequelize) {
await queryInterface.removeIndex("roles", "role_name_unique");
await queryInterface.removeIndex("userPermission", "user_permission_unique");
await queryInterface.removeIndex("rolePermission", "role_permission_unique");
await queryInterface.dropTable("user_roles");
await queryInterface.dropTable("user_identities");
await queryInterface.dropTable("auth_sessions");
await queryInterface.removeColumn("users", "lastLoginAt");
await queryInterface.removeColumn("users", "tokenVersion");
// passwordChangedAt may predate this migration, so down intentionally preserves it.
await queryInterface.changeColumn("users", "password", { type: Sequelize.STRING, allowNull: false });
await queryInterface.changeColumn("users", "accountType", { type: Sequelize.ENUM("business_customer", "customer"), defaultValue: "customer" });
},
};
+170
View File
@@ -23,6 +23,7 @@
"exceljs": "^4.4.0",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"google-auth-library": "^11.0.2",
"helmet": "^8.3.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
@@ -4170,6 +4171,15 @@
"node": ">=0.6"
}
},
"node_modules/bignumber.js": {
"version": "9.3.1",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
@@ -5699,6 +5709,12 @@
"node": ">=6.6.0"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
@@ -5806,6 +5822,29 @@
"pend": "~1.2.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/filelist": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -5971,6 +6010,18 @@
"node": ">= 0.6"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"license": "MIT",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/formidable": {
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
@@ -6084,6 +6135,34 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gaxios": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz",
"integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"node-fetch": "^3.3.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/gcp-metadata": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-9.0.3.tgz",
"integrity": "sha512-2YYnIlHaKBGT2IPg3G2M57hia9Galz15zsEOvw9T3oRf0lSn6KN6VcHQLqby7x8ksYKnjXvp3rp2KJyLCN6zfQ==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^7.1.3",
"google-logging-utils": "^2.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
@@ -6222,6 +6301,32 @@
"node": ">= 6"
}
},
"node_modules/google-auth-library": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-11.0.2.tgz",
"integrity": "sha512-vzpgPutxrghPsnjrjpzLX2bdv8IOL719Rh0oEjGnQu8YCIbnbMuTTQ5zU9LcKvLdOPgCxBwppbvnhgW90Qna5Q==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^7.1.4",
"gcp-metadata": "^9.0.0",
"google-logging-utils": "^2.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/google-logging-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-2.0.1.tgz",
"integrity": "sha512-HMhaQghlOTvbcb3c4T5jmmOMtG3JUF1iOQMezaJXL86CDS+Tm2vHd0IeLFRAx3+ewd+bo9E1HFHoy17X5aJa9A==",
"license": "Apache-2.0",
"engines": {
"node": ">=22"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -7681,6 +7786,15 @@
"node": ">=6"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
@@ -8474,6 +8588,53 @@
"node": ">=20"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"license": "MIT",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/node-fetch/node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -10692,6 +10853,15 @@
"makeerror": "1.0.12"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"license": "MIT",
"engines": {
"node": ">= 8"
}
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
+1
View File
@@ -46,6 +46,7 @@
"exceljs": "^4.4.0",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"google-auth-library": "^11.0.2",
"helmet": "^8.3.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
+38
View File
@@ -19,9 +19,22 @@ jest.mock("../../app/config/bullBoard.config", () => {
const app = require("../../app");
const { AppError, errorHandler } = require("../../app/middleware/error.middleware");
const db = require("../../app/models");
const { generateToken } = require("../../app/utils/jwt.util");
const authenticated = (accountType = "customer") => {
const user = { id: "usr-self", firstName: "Test", lastName: "User", email: "test@example.com", accountType, accountStatus: "ACTIVE", tokenVersion: 0, emailVerifiedAt: new Date(), profile: null };
const session = { id: "session-self", user_id: user.id, revoked_at: null, expires_at: new Date(Date.now() + 60000), token_version: 0 };
jest.spyOn(db.User, "findByPk").mockResolvedValue(user);
jest.spyOn(db.AuthSession, "findByPk").mockResolvedValue(session);
jest.spyOn(db.UserRole, "findAll").mockResolvedValue([]);
jest.spyOn(db.userPermission, "findAll").mockResolvedValue([]);
return `Bearer ${generateToken({ userId: user.id, sessionId: session.id, tokenVersion: 0 })}`;
};
describe("foundation HTTP behavior", () => {
beforeEach(() => {
jest.restoreAllMocks();
mockCheckDatabase.mockResolvedValue(true);
mockCheckRedis.mockResolvedValue(true);
});
@@ -74,4 +87,29 @@ describe("foundation HTTP behavior", () => {
test("Bull Board rejects unauthenticated requests", async () => {
await request(app).get("/admin/queues").expect(401);
});
test("mounted permission administration rejects unauthenticated requests", async () => {
await request(app).get("/api/v1/permissions").expect(401);
});
test("customer cannot mass-assign account type", async () => {
const token = authenticated("customer");
await request(app).patch("/api/v1/user/me").set("Authorization", token).send({ accountType: "admin" }).expect(400).expect(({ body }) => expect(body.error.code).toBe("UNSAFE_FIELD"));
});
test("customer cannot mutate another user by ID", async () => {
const token = authenticated("customer");
await request(app).patch("/api/v1/user/usr-other").set("Authorization", token).send({ firstName: "Attack" }).expect(403);
});
test("authenticated admin can reach protected Bull Board", async () => {
const token = authenticated("admin");
await request(app).get("/admin/queues").set("Authorization", token).expect(200);
});
test("a suspended account cannot use an otherwise valid access token", async () => {
const token = authenticated("customer");
db.User.findByPk.mockResolvedValue({ id: "usr-self", accountStatus: "SUSPENDED", tokenVersion: 0 });
await request(app).get("/api/v1/auth/me").set("Authorization", token).expect(401);
});
});
+31
View File
@@ -0,0 +1,31 @@
const mockDb = { User: { findOne: jest.fn() } };
const mockCheckPassword = jest.fn();
const mockCreateChallenge = jest.fn();
const mockSendOtp = jest.fn();
jest.mock("../../app/models", () => mockDb);
jest.mock("../../app/utils/hashPassword.util", () => ({ checkPassword: mockCheckPassword }));
jest.mock("../../app/services/auth/otp.service", () => ({ createLoginChallenge: mockCreateChallenge, verifyLoginChallenge: jest.fn() }));
jest.mock("../../app/services/auth/email.service", () => ({ sendLoginOtp: mockSendOtp }));
jest.mock("../../app/utils/idGen.util", () => ({ generateUserId: jest.fn(), generateId: jest.fn() }));
jest.mock("../../app/services/auth/session.service", () => ({ createSession: jest.fn(), rotateSession: jest.fn() }));
jest.mock("../../app/services/auth/oauth.service", () => ({ verifyGoogleToken: jest.fn(), verifyAppleToken: jest.fn() }));
const { beginPasswordLogin } = require("../../app/services/auth/auth.service");
const req = { get: jest.fn(), ip: "127.0.0.1" };
describe("password login boundary", () => {
beforeEach(() => { jest.clearAllMocks(); mockCheckPassword.mockResolvedValue(true); });
test.each(["PENDING_VERIFICATION", "SUSPENDED", "DEACTIVATED"])("rejects %s accounts", async (status) => {
mockDb.User.findOne.mockResolvedValue({ id: "usr", password: "hash", accountStatus: status, accountType: "customer" });
await expect(beginPasswordLogin({ email: "x@example.com", password: "secret", rememberMe: false }, req)).rejects.toMatchObject({ code: "ACCOUNT_NOT_ACTIVE" });
});
test("uses a generic error for missing users and wrong passwords", async () => {
mockDb.User.findOne.mockResolvedValue(null);
await expect(beginPasswordLogin({ email: "x@example.com", password: "wrong" }, req)).rejects.toMatchObject({ code: "INVALID_CREDENTIALS" });
});
test("creates an OTP challenge but no session for valid credentials", async () => {
const user = { id: "usr", password: "hash", accountStatus: "ACTIVE", accountType: "customer" };
mockDb.User.findOne.mockResolvedValue(user); mockCreateChallenge.mockResolvedValue({ challengeId: "challenge", otp: "123456" });
await expect(beginPasswordLogin({ email: "x@example.com", password: "secret", rememberMe: true }, req)).resolves.toEqual({ challengeId: "challenge" });
expect(mockSendOtp).toHaveBeenCalledWith(user, "123456");
});
});
+22
View File
@@ -0,0 +1,22 @@
const jwt = require("jsonwebtoken");
const { generateToken, verifyToken } = require("../../app/utils/jwt.util");
describe("access JWT", () => {
test("contains only session security claims and validates issuer/audience", () => {
const token = generateToken({ userId: "usr-1", sessionId: "sid-1", tokenVersion: 3 });
const payload = verifyToken(token);
expect(payload).toMatchObject({ sub: "usr-1", sid: "sid-1", tokenVersion: 3, iss: "zumri-api", aud: "zumri-clients" });
expect(payload.password).toBeUndefined();
});
test("rejects the wrong issuer and audience", () => {
const token = jwt.sign({ sid: "sid", tokenVersion: 0 }, process.env.JWT_SECRET, { algorithm: "HS256", subject: "usr", issuer: "attacker", audience: "wrong", expiresIn: "1m" });
expect(() => verifyToken(token)).toThrow();
});
test("rejects expired and malformed tokens", () => {
const expired = jwt.sign({ sid: "sid", tokenVersion: 0 }, process.env.JWT_SECRET, { algorithm: "HS256", subject: "usr", issuer: "zumri-api", audience: "zumri-clients", expiresIn: -1 });
expect(() => verifyToken(expired)).toThrow();
expect(() => verifyToken("not-a-token")).toThrow();
});
});
+22
View File
@@ -0,0 +1,22 @@
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const mockVerifyIdToken = jest.fn();
jest.mock("google-auth-library", () => ({ OAuth2Client: jest.fn(() => ({ verifyIdToken: mockVerifyIdToken })) }));
const { verifyGoogleToken, verifyAppleToken } = require("../../app/services/auth/oauth.service");
describe("OAuth verifier adapters", () => {
test("uses Google's verified stable subject", async () => {
process.env.GOOGLE_CLIENT_ID = "google-client";
mockVerifyIdToken.mockResolvedValue({ getPayload: () => ({ sub: "google-subject", email: "USER@EXAMPLE.COM", email_verified: true, given_name: "Test", family_name: "User" }) });
await expect(verifyGoogleToken("signed-google-id-token")).resolves.toMatchObject({ subject: "google-subject", email: "user@example.com", emailVerified: true });
});
test("verifies Apple signature, issuer, audience and subject using JWKS", async () => {
process.env.APPLE_CLIENT_ID = "apple-client";
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = publicKey.export({ format: "jwk" }); Object.assign(jwk, { kid: "test-key", alg: "RS256", use: "sig" });
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ keys: [jwk] }) });
const token = jwt.sign({ email: "apple@example.com", email_verified: "true" }, privateKey, { algorithm: "RS256", keyid: "test-key", subject: "apple-subject", issuer: "https://appleid.apple.com", audience: "apple-client", expiresIn: "5m" });
await expect(verifyAppleToken(token)).resolves.toMatchObject({ subject: "apple-subject", emailVerified: true });
});
});
+23
View File
@@ -0,0 +1,23 @@
const mockRedis = { set: jest.fn(), eval: jest.fn() };
jest.mock("../../app/config/redisClient", () => mockRedis);
const { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge } = require("../../app/services/auth/otp.service");
describe("login OTP service", () => {
beforeEach(() => jest.clearAllMocks());
test("generates six numeric digits cryptographically", () => expect(generateOtp()).toMatch(/^\d{6}$/));
test("stores only an OTP hash with TTL", async () => {
const result = await createLoginChallenge({ userId: "usr-1", rememberMe: true, context: {} });
const stored = JSON.parse(mockRedis.set.mock.calls[0][1]);
expect(stored.otpHash).toHaveLength(64);
expect(stored.otp).toBeUndefined();
expect(mockRedis.set.mock.calls[0]).toEqual(expect.arrayContaining(["EX", 900]));
expect(result.otp).toMatch(/^\d{6}$/);
expect(stored.otpHash).toBe(otpHash(result.challengeId, result.otp));
});
test("maps invalid, exhausted, and expired challenges to a generic failure", async () => {
for (const code of [-1, -2, -3]) {
mockRedis.eval.mockResolvedValueOnce([code]);
await expect(verifyLoginChallenge("00000000-0000-4000-8000-000000000000", "000000")).rejects.toMatchObject({ code: "INVALID_OTP", status: 401 });
}
});
});
+10
View File
@@ -0,0 +1,10 @@
const { passwordSchema } = require("../../app/validation/auth.schemas");
describe("password policy", () => {
test("requires length, case, symbol, and four digits", () => {
expect(passwordSchema.safeParse("Strong!1234x").success).toBe(true);
for (const invalid of ["short!1234A", "lowercase!1234", "UPPERCASE!1234", "NoSymbol1234x", "Strong!12xx"]) {
expect(passwordSchema.safeParse(invalid).success).toBe(false);
}
});
});
+37
View File
@@ -0,0 +1,37 @@
const mockTransaction = { LOCK: { UPDATE: "UPDATE" } };
const mockDb = {
AuthSession: { create: jest.fn(), findByPk: jest.fn(), update: jest.fn() },
User: { findByPk: jest.fn() },
sequelize: { transaction: jest.fn((callback) => callback(mockTransaction)) },
};
jest.mock("../../app/models", () => mockDb);
const service = require("../../app/services/auth/session.service");
describe("durable refresh sessions", () => {
beforeEach(() => jest.clearAllMocks());
test("stores a hash and never the raw refresh token", async () => {
mockDb.AuthSession.create.mockImplementation(async (values) => ({ ...values }));
const result = await service.createSession({ user: { id: "usr", tokenVersion: 2 }, rememberMe: false });
expect(result.refreshToken).toContain(`${result.session.id}.`);
expect(result.session.refresh_token_hash).toBe(service.hashRefreshToken(result.refreshToken));
expect(JSON.stringify(result.session)).not.toContain(result.refreshToken);
});
test("rotates once and marks the previous session replaced", async () => {
const token = "11111111-1111-4111-8111-111111111111.secret";
const old = { id: token.split(".")[0], user_id: "usr", token_family_id: "22222222-2222-4222-8222-222222222222", refresh_token_hash: service.hashRefreshToken(token), remember_me: false, token_version: 1, expires_at: new Date(Date.now() + 10000), revoked_at: null, save: jest.fn() };
const user = { id: "usr", accountStatus: "ACTIVE", tokenVersion: 1 };
mockDb.AuthSession.findByPk.mockResolvedValue(old); mockDb.User.findByPk.mockResolvedValue(user);
mockDb.AuthSession.create.mockImplementation(async (values) => ({ ...values }));
const result = await service.rotateSession(token);
expect(result.refreshToken).not.toBe(token);
expect(old.revoked_reason).toBe("ROTATED");
expect(old.replaced_by_session_id).toBe(result.session.id);
});
test("replay of a rotated token revokes its family", async () => {
const token = "id.old-token";
const old = { id: "id", token_family_id: "family", revoked_at: new Date(), revoked_reason: "ROTATED", refresh_token_hash: service.hashRefreshToken(token) };
mockDb.AuthSession.findByPk.mockResolvedValue(old); mockDb.AuthSession.update.mockResolvedValue([1]);
await expect(service.rotateSession(token)).rejects.toMatchObject({ code: "REFRESH_TOKEN_REUSE" });
expect(mockDb.AuthSession.update).toHaveBeenCalledWith(expect.objectContaining({ revoked_reason: "REFRESH_TOKEN_REUSE" }), expect.objectContaining({ where: expect.objectContaining({ token_family_id: "family" }) }));
});
});