diff --git a/.env.sample b/.env.sample index efdc553..685dfc6 100644 --- a/.env.sample +++ b/.env.sample @@ -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= diff --git a/Documentation/API_AUTHENTICATION.md b/Documentation/API_AUTHENTICATION.md new file mode 100644 index 0000000..6c4b471 --- /dev/null +++ b/Documentation/API_AUTHENTICATION.md @@ -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 `. 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. diff --git a/Documentation/CURRENT_BACKEND_STATUS.md b/Documentation/CURRENT_BACKEND_STATUS.md index c312fd3..cb6d00c 100644 --- a/Documentation/CURRENT_BACKEND_STATUS.md +++ b/Documentation/CURRENT_BACKEND_STATUS.md @@ -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. diff --git a/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md b/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md index 347a1d1..ddd641f 100644 --- a/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md +++ b/Documentation/PHASE_0_FOUNDATION_STABILIZATION.md @@ -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`. diff --git a/Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md b/Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md new file mode 100644 index 0000000..47a0e3d --- /dev/null +++ b/Documentation/PHASE_1_IDENTITY_AUTHORIZATION.md @@ -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. diff --git a/app/config/env.config.js b/app/config/env.config.js index a57df83..8e708d4 100644 --- a/app/config/env.config.js +++ b/app/config/env.config.js @@ -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; diff --git a/app/constants/accountTypes.js b/app/constants/accountTypes.js new file mode 100644 index 0000000..49938b9 --- /dev/null +++ b/app/constants/accountTypes.js @@ -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) }; diff --git a/app/controllers/adminUser.controller.js b/app/controllers/adminUser.controller.js new file mode 100644 index 0000000..7ca005c --- /dev/null +++ b/app/controllers/adminUser.controller.js @@ -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"); diff --git a/app/controllers/auth.controller.js b/app/controllers/auth.controller.js index 4780f89..23cdb14 100644 --- a/app/controllers/auth.controller.js +++ b/app/controllers/auth.controller.js @@ -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; + 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); } +}; - if (!refreshToken) { - return res.status(401).send({ - success: false, - message: "Refresh token is required", - }); +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); } }; diff --git a/app/controllers/roleAssignment.controller.js b/app/controllers/roleAssignment.controller.js new file mode 100644 index 0000000..3d9b882 --- /dev/null +++ b/app/controllers/roleAssignment.controller.js @@ -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); } +}; diff --git a/app/controllers/user.controller.js b/app/controllers/user.controller.js index 6a2243b..bb79181 100644 --- a/app/controllers/user.controller.js +++ b/app/controllers/user.controller.js @@ -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); } +}; diff --git a/app/middleware/auth.middleware.js b/app/middleware/auth.middleware.js index 31181e4..accc3bf 100644 --- a/app/middleware/auth.middleware.js +++ b/app/middleware/auth.middleware.js @@ -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 }; diff --git a/app/middleware/permission.middleware.js b/app/middleware/permission.middleware.js index aaf3ff6..9a15148 100644 --- a/app/middleware/permission.middleware.js +++ b/app/middleware/permission.middleware.js @@ -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. - */ +const { ACCOUNT_TYPES } = require("../constants/accountTypes"); -// 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 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 methodToAction = { - GET: "view", - POST: "create", - PUT: "update", - PATCH: "update", - DELETE: "delete" +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 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}` - }); - } - - 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" } }); }; -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(); - } -} - -module.exports = {authorizedAccountType, checkPermission}; \ No newline at end of file +module.exports = { authorizedAccountType, requireAccountType: authorizedAccountType, checkPermission, requirePermission: checkPermission, requireOwnership }; diff --git a/app/middleware/validate.middleware.js b/app/middleware/validate.middleware.js new file mode 100644 index 0000000..0eaf16e --- /dev/null +++ b/app/middleware/validate.middleware.js @@ -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); } +}; diff --git a/app/models/index.js b/app/models/index.js index 176c245..2b6b999 100644 --- a/app/models/index.js +++ b/app/models/index.js @@ -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); diff --git a/app/models/permission/role.model.js b/app/models/permission/role.model.js index 857fe2b..46758b2 100644 --- a/app/models/permission/role.model.js +++ b/app/models/permission/role.model.js @@ -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; diff --git a/app/models/permission/rolePermission.model.js b/app/models/permission/rolePermission.model.js index b42b6ef..9869d20 100644 --- a/app/models/permission/rolePermission.model.js +++ b/app/models/permission/rolePermission.model.js @@ -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))); + } + } } ); diff --git a/app/models/permission/userPermission.model.js b/app/models/permission/userPermission.model.js index 1324880..94e7476 100644 --- a/app/models/permission/userPermission.model.js +++ b/app/models/permission/userPermission.model.js @@ -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))) + } } ); diff --git a/app/models/permission/userRole.model.js b/app/models/permission/userRole.model.js new file mode 100644 index 0000000..0759ccc --- /dev/null +++ b/app/models/permission/userRole.model.js @@ -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; +}; diff --git a/app/models/user/authSession.model.js b/app/models/user/authSession.model.js new file mode 100644 index 0000000..28d1bab --- /dev/null +++ b/app/models/user/authSession.model.js @@ -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; +}; diff --git a/app/models/user/user.model.js b/app/models/user/user.model.js index c147dd3..7bfc3eb 100644 --- a/app/models/user/user.model.js +++ b/app/models/user/user.model.js @@ -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; diff --git a/app/models/user/userIdentity.model.js b/app/models/user/userIdentity.model.js new file mode 100644 index 0000000..421262d --- /dev/null +++ b/app/models/user/userIdentity.model.js @@ -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; +}; diff --git a/app/routes/adminAuth.routes.js b/app/routes/adminAuth.routes.js new file mode 100644 index 0000000..5b954eb --- /dev/null +++ b/app/routes/adminAuth.routes.js @@ -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; diff --git a/app/routes/adminUser.routes.js b/app/routes/adminUser.routes.js new file mode 100644 index 0000000..e6a12f1 --- /dev/null +++ b/app/routes/adminUser.routes.js @@ -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; diff --git a/app/routes/auth.routes.js b/app/routes/auth.routes.js index 6bc7750..f94d905 100644 --- a/app/routes/auth.routes.js +++ b/app/routes/auth.routes.js @@ -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; diff --git a/app/routes/index.js b/app/routes/index.js index a638e3b..4ce5779 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -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; \ No newline at end of file +module.exports = router; diff --git a/app/routes/permission.routes.js b/app/routes/permission.routes.js index 96346d4..8ce980f 100644 --- a/app/routes/permission.routes.js +++ b/app/routes/permission.routes.js @@ -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( diff --git a/app/routes/profile.routes.js b/app/routes/profile.routes.js index 080c301..8e92643 100644 --- a/app/routes/profile.routes.js +++ b/app/routes/profile.routes.js @@ -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, ); diff --git a/app/routes/riderAuth.routes.js b/app/routes/riderAuth.routes.js new file mode 100644 index 0000000..ea850de --- /dev/null +++ b/app/routes/riderAuth.routes.js @@ -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; diff --git a/app/routes/user.routes.js b/app/routes/user.routes.js index 4f67710..dc8447e 100644 --- a/app/routes/user.routes.js +++ b/app/routes/user.routes.js @@ -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 ); diff --git a/app/services/auth/auth.service.js b/app/services/auth/auth.service.js new file mode 100644 index 0000000..97cd1ab --- /dev/null +++ b/app/services/auth/auth.service.js @@ -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 }; diff --git a/app/services/auth/email.service.js b/app/services/auth/email.service.js new file mode 100644 index 0000000..a77a866 --- /dev/null +++ b/app/services/auth/email.service.js @@ -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 }; diff --git a/app/services/auth/oauth.service.js b/app/services/auth/oauth.service.js new file mode 100644 index 0000000..5a5a045 --- /dev/null +++ b/app/services/auth/oauth.service.js @@ -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 }; diff --git a/app/services/auth/otp.service.js b/app/services/auth/otp.service.js new file mode 100644 index 0000000..27046e2 --- /dev/null +++ b/app/services/auth/otp.service.js @@ -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 }; diff --git a/app/services/auth/session.service.js b/app/services/auth/session.service.js new file mode 100644 index 0000000..f57a53d --- /dev/null +++ b/app/services/auth/session.service.js @@ -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 }; diff --git a/app/services/permission.service.js b/app/services/permission.service.js index c3ffe94..434982b 100644 --- a/app/services/permission.service.js +++ b/app/services/permission.service.js @@ -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 }, @@ -68,4 +70,4 @@ const getEffectivePermissions = async (userId) => { } }; -module.exports = { getEffectivePermissions }; \ No newline at end of file +module.exports = { getEffectivePermissions }; diff --git a/app/utils/emailVerification.util.js b/app/utils/emailVerification.util.js index d7e26af..c5a6a7f 100644 --- a/app/utils/emailVerification.util.js +++ b/app/utils/emailVerification.util.js @@ -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, }; diff --git a/app/utils/idGen.util.js b/app/utils/idGen.util.js index 0fc0abc..e414f8a 100644 --- a/app/utils/idGen.util.js +++ b/app/utils/idGen.util.js @@ -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 = () => { diff --git a/app/utils/jwt.util.js b/app/utils/jwt.util.js index 19bfd34..15b7092 100644 --- a/app/utils/jwt.util.js +++ b/app/utils/jwt.util.js @@ -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 }; diff --git a/app/utils/otp.util.js b/app/utils/otp.util.js deleted file mode 100644 index 8259d2b..0000000 --- a/app/utils/otp.util.js +++ /dev/null @@ -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 }; \ No newline at end of file diff --git a/app/utils/passwordReset.utill.js b/app/utils/passwordReset.utill.js index aca6e06..ecc7864 100644 --- a/app/utils/passwordReset.utill.js +++ b/app/utils/passwordReset.utill.js @@ -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, diff --git a/app/utils/refreshSession.util.js b/app/utils/refreshSession.util.js deleted file mode 100644 index bef329a..0000000 --- a/app/utils/refreshSession.util.js +++ /dev/null @@ -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, -}; \ No newline at end of file diff --git a/app/utils/validation/validatePassword.util.js b/app/utils/validation/validatePassword.util.js index 4d51b21..ab0a078 100644 --- a/app/utils/validation/validatePassword.util.js +++ b/app/utils/validation/validatePassword.util.js @@ -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, -}; \ No newline at end of file +module.exports = { validatePassword }; diff --git a/app/validation/auth.schemas.js b/app/validation/auth.schemas.js new file mode 100644 index 0000000..7a470e1 --- /dev/null +++ b/app/validation/auth.schemas.js @@ -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() }), +}; diff --git a/migrations/20260903010000-phase-1-identity-security.js b/migrations/20260903010000-phase-1-identity-security.js new file mode 100644 index 0000000..19a452c --- /dev/null +++ b/migrations/20260903010000-phase-1-identity-security.js @@ -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" }); + }, +}; diff --git a/package-lock.json b/package-lock.json index 93cc848..26ff243 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 3f56cf0..dbdcb13 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/integration/foundation.test.js b/tests/integration/foundation.test.js index 5879b55..54589b8 100644 --- a/tests/integration/foundation.test.js +++ b/tests/integration/foundation.test.js @@ -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); + }); }); diff --git a/tests/unit/auth.service.test.js b/tests/unit/auth.service.test.js new file mode 100644 index 0000000..443c008 --- /dev/null +++ b/tests/unit/auth.service.test.js @@ -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"); + }); +}); diff --git a/tests/unit/jwt.util.test.js b/tests/unit/jwt.util.test.js new file mode 100644 index 0000000..7885554 --- /dev/null +++ b/tests/unit/jwt.util.test.js @@ -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(); + }); +}); diff --git a/tests/unit/oauth.service.test.js b/tests/unit/oauth.service.test.js new file mode 100644 index 0000000..089f38f --- /dev/null +++ b/tests/unit/oauth.service.test.js @@ -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 }); + }); +}); diff --git a/tests/unit/otp.service.test.js b/tests/unit/otp.service.test.js new file mode 100644 index 0000000..13f1d22 --- /dev/null +++ b/tests/unit/otp.service.test.js @@ -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 }); + } + }); +}); diff --git a/tests/unit/password-policy.test.js b/tests/unit/password-policy.test.js new file mode 100644 index 0000000..cf4ca84 --- /dev/null +++ b/tests/unit/password-policy.test.js @@ -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); + } + }); +}); diff --git a/tests/unit/session.service.test.js b/tests/unit/session.service.test.js new file mode 100644 index 0000000..e9457cb --- /dev/null +++ b/tests/unit/session.service.test.js @@ -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" }) })); + }); +});