feat: implement identity and security features
- Added account types and privileged account types constants. - Created admin user controller for updating user security fields. - Developed role assignment controller for managing user roles. - Implemented validation middleware for request schemas. - Defined user role and auth session models for database interactions. - Created services for authentication, email notifications, and OTP handling. - Developed OAuth service for Google and Apple authentication. - Added JWT utility functions for token generation and verification. - Implemented comprehensive tests for authentication, session management, and password policies. - Created migration for updating user schema and adding new tables for auth sessions and user identities.
This commit is contained in:
+9
-2
@@ -18,9 +18,14 @@ REDIS_PASSWORD=replace_with_local_redis_password
|
|||||||
|
|
||||||
# Use separate randomly generated values of at least 32 characters.
|
# Use separate randomly generated values of at least 32 characters.
|
||||||
JWT_SECRET=replace_with_a_random_value_at_least_32_chars
|
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
|
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
|
# Runtime controls
|
||||||
RUN_CRON=false
|
RUN_CRON=false
|
||||||
@@ -58,3 +63,5 @@ DEFAULT_PASSWORD=
|
|||||||
PASSWORD_RESET_TTL_SECONDS=900
|
PASSWORD_RESET_TTL_SECONDS=900
|
||||||
EMAIL_VERIFICATION_TTL_SECONDS=86400
|
EMAIL_VERIFICATION_TTL_SECONDS=86400
|
||||||
PUPPETEER_EXECUTABLE_PATH=
|
PUPPETEER_EXECUTABLE_PATH=
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
APPLE_CLIENT_ID=
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# ZUMRI Authentication API
|
||||||
|
|
||||||
|
All endpoints are available under both `/api/auth` and `/api/v1/auth`; new clients should use `/api/v1`. JSON errors use the Phase 0 standard error envelope. Examples contain placeholders only.
|
||||||
|
|
||||||
|
## Account states
|
||||||
|
|
||||||
|
- `PENDING_VERIFICATION`: registration exists but password/OAuth session creation is denied.
|
||||||
|
- `ACTIVE`: authentication and refresh are permitted.
|
||||||
|
- `SUSPENDED` and `DEACTIVATED`: login, refresh, and access-token middleware are denied.
|
||||||
|
|
||||||
|
## Register a customer
|
||||||
|
|
||||||
|
`POST /api/v1/auth/register`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"firstName":"Asha","lastName":"Perera","email":"asha@example.com","password":"Strong!1234x","phoneNumber":"+94000000000","address":"Customer-provided value"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Public registration always creates `customer`; supplied privileged account fields are rejected/stripped by strict validation. Business and privileged registration are not public Phase 1 flows.
|
||||||
|
|
||||||
|
## Verify or resend email verification
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/verify-email` — `{"token":"verification-token-from-email"}`
|
||||||
|
- `POST /api/v1/auth/resend-verification` — `{"email":"asha@example.com"}`
|
||||||
|
|
||||||
|
Tokens are random, stored only by hash in Redis, expire, and are single-use. Resend invalidates the previous token. Resend returns a generic response.
|
||||||
|
|
||||||
|
## Password plus OTP login
|
||||||
|
|
||||||
|
`POST /api/v1/auth/login`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"email":"asha@example.com","password":"Strong!1234x","rememberMe":true,"clientType":"WEB","deviceName":"Personal laptop"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful credential verification returns HTTP 202 and a `challengeId`; it does not create a session. A hashed six-digit OTP challenge is stored in Redis for the configured TTL and attempt limit.
|
||||||
|
|
||||||
|
`POST /api/v1/auth/verify-otp`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"challengeId":"00000000-0000-4000-8000-000000000000","otp":"000000","clientType":"WEB"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Successful verification consumes the challenge, creates a durable session, and issues tokens. `POST /api/v1/auth/req-otp` remains an alias for login challenge creation. The historical endpoint that submitted email+OTP to `/login` is intentionally superseded by challenge IDs.
|
||||||
|
|
||||||
|
## Token transport
|
||||||
|
|
||||||
|
`clientType: WEB` sets `access_token` and `refresh_token` as HttpOnly cookies. Production cookies use `Secure` and `SameSite=None` for the intended cross-subdomain frontend/API deployment. The refresh cookie is restricted to `/api`. The response includes the access token for current compatibility but never includes the web refresh token.
|
||||||
|
|
||||||
|
`clientType: MOBILE` returns access and refresh tokens in JSON and does not depend on cookies. Mobile clients must store the refresh token in operating-system secure storage. Access tokens remain short-lived regardless of Remember Me.
|
||||||
|
|
||||||
|
## Refresh and rotation
|
||||||
|
|
||||||
|
`POST /api/v1/auth/refresh`
|
||||||
|
|
||||||
|
Web request body may be `{}`; mobile sends `{"clientType":"MOBILE","refreshToken":"opaque-token"}`. Every successful call revokes/replaces the previous session row and returns a new token pair. Replaying a rotated token revokes its entire token family. Only SHA-256 refresh hashes are persisted.
|
||||||
|
|
||||||
|
## Current identity
|
||||||
|
|
||||||
|
`GET /api/v1/auth/me` requires the access cookie or `Authorization: Bearer <access-token>`. It returns identity fields, account state, verification status, profile, and effective permissions. It omits password, token version, session hashes, and OAuth internals.
|
||||||
|
|
||||||
|
## Logout
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/logout` revokes the session identified by refresh or access token and is idempotent.
|
||||||
|
- `POST /api/v1/auth/logout-all` requires authentication, increments `tokenVersion`, revokes every active session, and clears cookies.
|
||||||
|
|
||||||
|
## Password recovery and change
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/forgot-password` — `{"email":"asha@example.com"}`; response is generic.
|
||||||
|
- `POST /api/v1/auth/reset-password` — token, `newPassword`, `confirmPassword`.
|
||||||
|
- `POST /api/v1/auth/change-password` — authenticated; `currentPassword`, `newPassword`, `confirmPassword`.
|
||||||
|
|
||||||
|
Reset tokens are random, hash-only Redis records and atomically consumed. Reset/change enforce the same password policy, increment token version, and revoke all sessions. The user must log in again.
|
||||||
|
|
||||||
|
## Google and Apple
|
||||||
|
|
||||||
|
- `POST /api/v1/auth/google`
|
||||||
|
- `POST /api/v1/auth/apple`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"idToken":"provider-signed-id-token","rememberMe":false,"clientType":"WEB"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Google verification validates signature/audience/issuer/expiry through Google's verifier and requires verified email. Apple validates the provider JWKS signature, issuer, audience, expiry, and stable subject. Identities persist `(provider, provider_subject)` uniquely; provider tokens are discarded. Verified email auto-linking is permitted only for customer accounts. Privileged and rider accounts are never automatically linked by email.
|
||||||
|
|
||||||
|
## Administrative and rider compatibility
|
||||||
|
|
||||||
|
- `POST /api/v1/admin/auth/login` and `/verify-otp` use the shared challenge/session flow but only accept privileged account types.
|
||||||
|
- `POST /api/v1/rider/auth/login` and `/verify-otp` use the same system and only accept rider accounts.
|
||||||
|
|
||||||
|
No separate token implementation exists for these actors.
|
||||||
|
|
||||||
|
## Password policy
|
||||||
|
|
||||||
|
Minimum 12 characters with uppercase, lowercase, a symbol, and at least four numeric characters. Registration, reset, and change use the same Zod schema.
|
||||||
@@ -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.
|
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`.
|
Remaining foundation-adjacent work is intentionally deferred: production database baseline verification, distributed cron locking, full queue policy/idempotency, stronger documentation sessions, permission-router/role integration, and the Phase 1 authentication/ownership/security issues. The original audit above remains the historical baseline; statements such as “missing tests/Helmet/migrations” are superseded by this update and `Documentation/PHASE_0_FOUNDATION_STABILIZATION.md`.
|
||||||
|
|
||||||
|
## Phase 1 Completion Update
|
||||||
|
|
||||||
|
**Date:** 2026-09-03
|
||||||
|
**Authentication completion:** approximately **91%**.
|
||||||
|
**Revised Day 2 completion:** approximately **90%**.
|
||||||
|
|
||||||
|
Phase 1 replaced process-memory OTP and refresh sessions with Redis hash-only login challenges and durable MySQL auth-session families. OTP now uses `crypto.randomInt`, challenge UUIDs, TTL, atomic verification, bounded attempts, and no plaintext logging. Refresh tokens are opaque random values stored only by SHA-256 hash; row-locked transaction rotation detects replay and revokes the family. Access JWTs are short-lived, issuer/audience/algorithm constrained, and bound to `sub`, live session ID, and User token version. Middleware enforces current account state and session revocation.
|
||||||
|
|
||||||
|
New `auth_sessions`, `user_identities`, and `user_roles` models/tables support devices, Remember Me, Google/Apple stable subjects, and configurable roles. The User security migration adds token version/last login, expands canonical account types, and adds RBAC unique indexes. Permission routes are mounted behind SUPER_ADMIN, permission resolution now uses UserRole, and cache invalidation covers assignments/grants. Customer self-service update is allowlisted and ID-based mutation is privileged, closing the audited identity IDOR/mass-assignment path.
|
||||||
|
|
||||||
|
Auth endpoints now cover customer registration, verification/resend, password-to-OTP challenge, OTP completion, refresh rotation, current/all-device logout, forgot/reset/change password, Google, Apple, `/me`, and shared admin/rider compatibility flows. Both `/api` and `/api/v1` remain. Security-focused unit/integration tests were added without real providers/email/database/Redis.
|
||||||
|
|
||||||
|
Remaining identity work is operational: validate/deduplicate deployed RBAC data before migration, run staging MySQL/Redis concurrency tests, seed initial privileged assignments securely, configure provider audiences/mail, and design manual privileged OAuth linking and email change if required. Module 01 is now approximately 91%; migration/staging validation prevents claiming 100% production completion.
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ Tests can import `app.js` without opening a TCP port. Startup failures prevent t
|
|||||||
|
|
||||||
## Environment Variables
|
## 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`.
|
Runtime controls: `TRUST_PROXY` (numeric trusted proxy hop count; keep `0` when directly exposed), `JSON_BODY_LIMIT`, `API_RATE_LIMIT_WINDOW_MS`, `API_RATE_LIMIT_MAX`, `SENSITIVE_RATE_LIMIT_WINDOW_MS`, `SENSITIVE_RATE_LIMIT_MAX`, `RUN_CRON`, `CACHE`, and `SHUTDOWN_TIMEOUT_MS`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# ZUMRI Phase 1 Identity and Authorization
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Complete the identity security boundary without beginning commerce modules: verified registration, password+OTP authentication, durable revocable sessions, social identity verification, account-state enforcement, repaired configurable RBAC, and ownership-safe self service.
|
||||||
|
|
||||||
|
## Existing Components Reused
|
||||||
|
|
||||||
|
User/Profile and customer-extension models, Sequelize registration, Redis client, bcrypt helper, email templates/transport, hashed verification/reset concepts, activity queue, permission/role/grant models, permission cache, Phase 0 error/rate-limit/request-ID middleware, dual API mounting, tests, and migrations were extended rather than replaced.
|
||||||
|
|
||||||
|
## Identity Model
|
||||||
|
|
||||||
|
`User` is the account and contains broad `accountType`, state, password hash (nullable for social-only customers), verification/password timestamps, `tokenVersion`, and `lastLoginAt`. `UserIdentity` maps Google/Apple stable subjects to User. `AuthSession` persists refresh credentials/device context and rotation state. `UserRole` assigns configurable Roles independently from account type.
|
||||||
|
|
||||||
|
## Account Types
|
||||||
|
|
||||||
|
Canonical application constants map to persisted lowercase values: `SUPER_ADMIN=superadmin`, `ADMIN=admin`, `MANAGER=manager`, `CUSTOMER=customer`, `BUSINESS_CUSTOMER=business_customer`, `RIDER=rider`, `SUPPORT_AGENT=support_agent`. Existing lowercase values remain valid.
|
||||||
|
|
||||||
|
## Role vs Account Type
|
||||||
|
|
||||||
|
Account type is a broad trusted identity category used for hard security boundaries. Role is configurable authorization grouping. Users may have multiple roles through `UserRole`; effective permissions are the union of role grants and direct additive `UserPermission` grants. This removes dependence on undeclared `User.roleID`.
|
||||||
|
|
||||||
|
## Session Architecture
|
||||||
|
|
||||||
|
```text
|
||||||
|
Password -> OTP Challenge -> Verify OTP -> AuthSession
|
||||||
|
-> Access JWT + opaque Refresh Token
|
||||||
|
-> transactional rotation -> logout/revocation
|
||||||
|
```
|
||||||
|
|
||||||
|
Sessions support multiple devices, user-agent/IP/device metadata, Remember Me, token families, last use, expiry, revocation reason, and replacement linkage. Raw refresh tokens exist only at issuance/transport.
|
||||||
|
|
||||||
|
## Access Token
|
||||||
|
|
||||||
|
HS256 JWTs are short-lived and contain `sub`, `sid`, and `tokenVersion`, plus `iss`, `aud`, `iat`, and `exp`. Middleware enforces algorithm, signature, issuer, audience, expiry, live User state/token version, and live non-revoked session state. It loads permissions server-side rather than embedding them.
|
||||||
|
|
||||||
|
## Refresh Token Rotation
|
||||||
|
|
||||||
|
Refresh tokens are opaque `session-id.random-secret` values. The database stores only SHA-256 hashes. Rotation locks the current session row and User in a transaction, creates a same-family replacement, and revokes/links the old row.
|
||||||
|
|
||||||
|
## Reuse Detection
|
||||||
|
|
||||||
|
Presentation of a revoked/replaced or hash-mismatched known session token revokes all active members of that token family and returns the same invalid-session boundary. Row locks ensure two concurrent refresh calls cannot both succeed.
|
||||||
|
|
||||||
|
## Remember Me
|
||||||
|
|
||||||
|
Remember Me changes only refresh-session lifetime: `REFRESH_TOKEN_TTL_DAYS` versus `REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS`. Access lifetime remains `ACCESS_TOKEN_TTL`.
|
||||||
|
|
||||||
|
## Account Status Enforcement
|
||||||
|
|
||||||
|
Only `ACTIVE` can finish login, refresh, or use protected endpoints. Pending, suspended, and deactivated accounts are rejected using current database state, not stale claims. Password/security administration increments token version and revokes sessions.
|
||||||
|
|
||||||
|
## Password Policy
|
||||||
|
|
||||||
|
One Zod policy requires at least 12 characters, uppercase, lowercase, a symbol, and four digits. It is used by registration, reset, and change-password flows. Reset/change require confirmation and reject reuse of the current password.
|
||||||
|
|
||||||
|
## Email Verification
|
||||||
|
|
||||||
|
Verification tokens are cryptographically random and hash-only in Redis. They expire, are consumed atomically, and activate the account. Per-user pointers let resend invalidate an earlier token. Resend responses are generic and rate limited.
|
||||||
|
|
||||||
|
## Password Recovery
|
||||||
|
|
||||||
|
Forgot-password normalizes/validates email and returns a generic response. Reset tokens are random, stored hashed with TTL, atomically consumed using Redis `GETDEL`, and never logged. Successful reset updates the hash/timestamp/tokenVersion and revokes every session.
|
||||||
|
|
||||||
|
## Google Authentication
|
||||||
|
|
||||||
|
The backend verifies Google ID tokens against configured audience and uses Google's `sub`; verified email is required. Provider access tokens are not stored. Automated tests mock Google's verifier.
|
||||||
|
|
||||||
|
## Apple Authentication
|
||||||
|
|
||||||
|
The backend obtains/caches Apple's JWKS, selects the signed key, and verifies RS256 signature, Apple issuer, configured audience, expiry, and `sub`. First-login email is used only when verified. Tests use a locally signed RSA token and mocked JWKS response.
|
||||||
|
|
||||||
|
## OAuth Account Linking Rules
|
||||||
|
|
||||||
|
Existing provider subject wins. Otherwise a strongly verified provider email may link/create a customer. Email-only automatic linking is denied for super admins, admins, managers, support agents, and riders. Provider subject is unique and provider tokens/secrets are not persisted. Manual privileged linking remains deferred.
|
||||||
|
|
||||||
|
## Role and Permission Architecture
|
||||||
|
|
||||||
|
`/api/v1/permissions` is now mounted and default-denied to SUPER_ADMIN at router level. Existing CRUD is retained behind that boundary. Role names and role/user grant pairs have unique constraints. UserRole pairs are unique. Model hooks and assignment controllers invalidate affected permission caches.
|
||||||
|
|
||||||
|
## Ownership Authorization
|
||||||
|
|
||||||
|
`GET/PATCH /user/me` provides self service. Self update allowlists only first and last name; account type/status/roles/security fields are rejected. ID-based legacy mutation is restricted to SUPER_ADMIN, and no arbitrary ID read route is exposed. Profile image access uses reusable self-or-admin ownership middleware.
|
||||||
|
|
||||||
|
## New Database Tables
|
||||||
|
|
||||||
|
- `auth_sessions`
|
||||||
|
- `user_identities`
|
||||||
|
- `user_roles`
|
||||||
|
|
||||||
|
User adds `tokenVersion` and `lastLoginAt`; password becomes nullable for verified social-only users; the account-type ENUM expands to all canonical types.
|
||||||
|
|
||||||
|
## New Migrations
|
||||||
|
|
||||||
|
`20260903010000-phase-1-identity-security.js` is forward-only relative to the Phase 0 baseline and was not executed. Before applying to an existing database, diagnose duplicate `roles.roleName`, `(role_id,permission_id)`, and `(user_id,permission_id)` rows because unique indexes intentionally fail on dirty data. Back up and test a restored database first.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
Auth: register, login challenge, verify OTP, refresh, logout, logout-all, forgot/reset/change password, verify/resend email, Google, Apple, and me. Thin shared-flow admin and rider login routes exist. Admin User status/account-type endpoints and protected permission/UserRole endpoints are mounted. See `Documentation/API_AUTHENTICATION.md`.
|
||||||
|
|
||||||
|
## Security Controls
|
||||||
|
|
||||||
|
Hash-only OTP/reset/verification/refresh persistence; cryptographic random generation; bounded OTP attempts; single-use challenges; short access TTL; durable revocation; replay-family revocation; DB row locks; account-state/token-version checks; strict Zod bodies; generic enumeration responses; provider signature/audience checks; customer-only public registration; field allowlists; ownership checks; and no token/OTP credential logging.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Unit coverage includes password policy, JWT claims/wrong issuer-audience/expiry/malformed input, OTP format/hash-only persistence/failure states, refresh hash-only persistence/rotation/replay, account-status/password-login behavior, and Google/Apple verification. Integration coverage preserves Phase 0 health/error behavior and checks RBAC denial, self mass-assignment, other-user mutation, suspended access, and Bull Board admin access. External DB/Redis/email/OAuth services are mocked.
|
||||||
|
|
||||||
|
## Legacy Compatibility
|
||||||
|
|
||||||
|
Both `/api` and `/api/v1` remain. `/auth/req-otp` aliases new login challenge creation; `/profile` password endpoints delegate to the same hardened controllers. The old email+OTP `/auth/login` second step is intentionally replaced by `/verify-otp` with challenge IDs because the old email-keyed flow could not meet security requirements.
|
||||||
|
|
||||||
|
## Remaining Known Issues
|
||||||
|
|
||||||
|
Manual privileged OAuth linking and secure email-change confirmation are deferred. Full email queue/delivery tracking remains Phase 2 infrastructure work. Existing business registration/account approval is not part of this phase. Role/grant uniqueness migration requires deployed-data diagnostics. The baseline test suite mocks MySQL/Redis; staging integration tests must run after migration review. Existing non-auth legacy routes may still contain account-name/ownership debt outside Phase 1 scope.
|
||||||
|
|
||||||
|
## Phase 2 Prerequisites
|
||||||
|
|
||||||
|
Review and run both migrations on a restored environment, configure mail plus Google/Apple client audiences where those flows are enabled, run staging MySQL/Redis integration tests, and seed the initial SUPER_ADMIN/roles/permissions through a controlled operational process. Once complete, identity is ready for the next non-commerce phase requested by the development roadmap.
|
||||||
@@ -11,7 +11,13 @@ const envSchema = z.object({
|
|||||||
DB_USER: z.string().min(1),
|
DB_USER: z.string().min(1),
|
||||||
DB_PASSWORD: z.string(),
|
DB_PASSWORD: z.string(),
|
||||||
JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"),
|
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_HOST: z.string().min(1),
|
||||||
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
|
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
|
||||||
REDIS_PASSWORD: z.string().optional(),
|
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_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(),
|
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
|
||||||
DOCS_USER: z.string().optional(), DOCS_PASS: 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) => {
|
}).superRefine((env, context) => {
|
||||||
const requireFeature = (enabled, names) => {
|
const requireFeature = (enabled, names) => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
|
|||||||
@@ -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) };
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const db = require("../models");
|
||||||
|
const { ACCOUNT_TYPE_VALUES } = require("../constants/accountTypes");
|
||||||
|
const { revokeAllUserSessions } = require("../services/auth/session.service");
|
||||||
|
const { logActivity } = require("../services/activity.service");
|
||||||
|
|
||||||
|
const updateSecurityField = (field) => async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const value = req.body[field];
|
||||||
|
if (field === "accountType" && !ACCOUNT_TYPE_VALUES.includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_TYPE", message: "Invalid account type" } });
|
||||||
|
if (field === "accountStatus" && !["PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"].includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_STATUS", message: "Invalid account status" } });
|
||||||
|
if (req.params.id === req.user.id) return res.status(400).json({ success: false, error: { code: "SELF_SECURITY_CHANGE_DENIED", message: "Security-sensitive self changes are not allowed" } });
|
||||||
|
let target; let previous;
|
||||||
|
await db.sequelize.transaction(async (transaction) => {
|
||||||
|
target = await db.User.findByPk(req.params.id, { transaction, lock: transaction.LOCK.UPDATE });
|
||||||
|
if (!target) throw Object.assign(new Error("User not found"), { status: 404, code: "USER_NOT_FOUND" });
|
||||||
|
previous = target[field]; target[field] = value; target.tokenVersion += 1; await target.save({ transaction });
|
||||||
|
await revokeAllUserSessions(target.id, `ADMIN_${field.toUpperCase()}_CHANGE`, transaction);
|
||||||
|
});
|
||||||
|
await logActivity({ user: req.user, description: `${field} changed for ${target.id} from ${previous} to ${value}; reason: ${req.body.reason || "not supplied"}; request: ${req.id}`, type: field === "accountStatus" ? "ACCOUNT_STATUS_CHANGED" : "ACCOUNT_TYPE_CHANGED", module: "Identity Administration" });
|
||||||
|
res.json({ success: true, data: { id: target.id, [field]: target[field] } });
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
};
|
||||||
|
exports.updateStatus = updateSecurityField("accountStatus");
|
||||||
|
exports.updateAccountType = updateSecurityField("accountType");
|
||||||
+159
-503
@@ -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 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;
|
exports.login = async (req, res, next) => {
|
||||||
|
|
||||||
// Login Step 1: Request OTP
|
|
||||||
exports.loginReq = async (req, res) => {
|
|
||||||
try {
|
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);
|
exports.verifyOtp = async (req, res, next) => {
|
||||||
if (!user) {
|
try {
|
||||||
return res
|
const input = req.validated.body;
|
||||||
.status(404)
|
const result = await authService.completeOtpLogin(input, req);
|
||||||
.send({ success: false, message: "User Not Found" });
|
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);
|
exports.refreshToken = async (req, res, next) => {
|
||||||
if (!passwordIsValid) {
|
try {
|
||||||
return res
|
const input = req.validated.body;
|
||||||
.status(401)
|
const token = input.refreshToken || req.cookies?.refresh_token;
|
||||||
.send({ success: false, message: "Invalid Password" });
|
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) });
|
||||||
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" });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("Error occurred while sending OTP:", error);
|
clearCookies(res);
|
||||||
res.status(500).send({ success: false, message: error.message });
|
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.logout = async (req, res, next) => {
|
||||||
exports.login = async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
const { email, otp } = req.body;
|
const token = req.body?.refreshToken || req.cookies?.refresh_token;
|
||||||
|
let id = sessionService.tokenId(token);
|
||||||
if (!email || !otp) {
|
if (!id) {
|
||||||
return res
|
const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
|
||||||
.status(400)
|
try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; }
|
||||||
.send({ success: false, message: "Email and OTP are required" });
|
|
||||||
}
|
}
|
||||||
|
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) {
|
exports.me = async (req, res, next) => {
|
||||||
return res
|
try {
|
||||||
.status(404)
|
const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] });
|
||||||
.send({ success: false, message: "User Not Found" });
|
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);
|
||||||
}
|
}
|
||||||
|
res.json({ success: true, message });
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log("Error occurred during login:", error);
|
console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
|
||||||
res.status(500).send({ success: false, message: error.message });
|
res.json({ success: true, message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.refreshToken = async (req, res) => {
|
exports.resetPassword = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const refreshToken = req.cookies?.refresh_token;
|
const input = req.validated.body;
|
||||||
|
const userId = await consumePasswordResetToken(input.token);
|
||||||
if (!refreshToken) {
|
if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" });
|
||||||
return res.status(401).send({
|
let changedUser;
|
||||||
success: false,
|
await db.sequelize.transaction(async (transaction) => {
|
||||||
message: "Refresh token is required",
|
const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE });
|
||||||
|
if (!user || (user.password && await checkPassword(input.newPassword, user.password))) throw Object.assign(new Error("Invalid password change"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
|
||||||
|
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
|
||||||
|
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_RESET", transaction); changedUser = user;
|
||||||
});
|
});
|
||||||
|
sendPasswordChanged(changedUser).catch(() => {});
|
||||||
|
await logActivity({ user: changedUser, description: "Password reset and sessions revoked", type: "PASSWORD_RESET", module: "Authentication" });
|
||||||
|
res.json({ success: true, message: "Password reset successfully. Please login again" });
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.changePassword = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const input = req.validated.body;
|
||||||
|
let changedUser;
|
||||||
|
await db.sequelize.transaction(async (transaction) => {
|
||||||
|
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
|
||||||
|
if (!user?.password || !await checkPassword(input.currentPassword, user.password)) throw Object.assign(new Error("Current password is incorrect"), { status: 401, code: "INVALID_CREDENTIALS" });
|
||||||
|
if (await checkPassword(input.newPassword, user.password)) throw Object.assign(new Error("New password must be different"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
|
||||||
|
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
|
||||||
|
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_CHANGED", transaction); changedUser = user;
|
||||||
|
});
|
||||||
|
clearCookies(res); sendPasswordChanged(changedUser).catch(() => {});
|
||||||
|
await logActivity({ user: changedUser, description: "Password changed and sessions revoked", type: "PASSWORD_CHANGED", module: "Authentication" });
|
||||||
|
res.json({ success: true, message: "Password changed successfully. Please login again" });
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.verifyEmail = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const userId = await consumeEmailVerificationToken(req.validated.body.token);
|
||||||
|
if (!userId) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
|
||||||
|
const user = await db.User.findByPk(userId);
|
||||||
|
if (!user) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
|
||||||
|
if (!user.emailVerifiedAt) { user.emailVerifiedAt = new Date(); user.accountStatus = "ACTIVE"; await user.save(); }
|
||||||
|
await logActivity({ user, description: "Email address verified", type: "EMAIL_VERIFIED", module: "Authentication" });
|
||||||
|
res.json({ success: true, message: "Email verified" });
|
||||||
|
} catch (error) { next(error); }
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.resendVerification = async (req, res, next) => {
|
||||||
|
const message = "If verification is required, a new email has been sent";
|
||||||
|
try {
|
||||||
|
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
|
||||||
|
if (user && !user.emailVerifiedAt && user.accountStatus === "PENDING_VERIFICATION") {
|
||||||
|
const token = await createEmailVerification(user.id); await sendVerificationEmail(user.email, user.firstName, token);
|
||||||
}
|
}
|
||||||
|
res.json({ success: true, message });
|
||||||
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",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("REFRESH ERROR:", error);
|
console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message });
|
||||||
|
res.json({ success: true, message });
|
||||||
return res.status(401).send({
|
|
||||||
success: false,
|
|
||||||
message: "Invalid or expired session. Please login again.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.forgotPassword = async (req, res) => {
|
exports.oauth = (provider) => async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
let { email } = req.body;
|
const input = req.validated.body; const result = await authService.authenticateOAuth(provider, input, req);
|
||||||
|
const tokens = deliverTokens(req, res, result, input.clientType);
|
||||||
if (!email) {
|
await logActivity({ user: result.user, description: `${provider} identity authenticated`, type: `${provider.toUpperCase()}_ACCOUNT_LINKED`, module: "Authentication" });
|
||||||
return res.status(400).send({
|
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
|
||||||
success: false,
|
} catch (error) { next(Object.assign(error, { status: error.status || 401, code: error.code || "OAUTH_FAILED" })); }
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exports.resetPassword = async (req, res) => {
|
exports.adminLogin = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { token, newPassword, confirmPassword } = req.body;
|
const { PRIVILEGED_ACCOUNT_TYPES } = require("../constants/accountTypes");
|
||||||
|
const result = await authService.beginPasswordLogin(req.validated.body, req, PRIVILEGED_ACCOUNT_TYPES);
|
||||||
if (!token || !newPassword || !confirmPassword) {
|
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
|
||||||
return res.status(400).send({
|
} catch (error) { next(error); }
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
exports.riderLogin = async (req, res, next) => {
|
||||||
exports.changePassword = async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
// User ID comes from authenticate middleware
|
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
|
||||||
const userId = req.user.id;
|
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" });
|
||||||
const { currentPassword, newPassword, confirmPassword } = req.body;
|
} catch (error) { next(error); }
|
||||||
|
|
||||||
// 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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
};
|
||||||
@@ -44,7 +44,6 @@ exports.createNewUser = async (req, res) => {
|
|||||||
lastName,
|
lastName,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
accountType,
|
|
||||||
address,
|
address,
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
businessName,
|
businessName,
|
||||||
@@ -56,23 +55,23 @@ exports.createNewUser = async (req, res) => {
|
|||||||
note,
|
note,
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
if (!firstName || !lastName || !email || !password || !accountType) {
|
if (!firstName || !lastName || !email || !password) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
|
|
||||||
return res.status(400).send({
|
return res.status(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
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();
|
await transaction.rollback();
|
||||||
|
|
||||||
return res.status(400).send({
|
return res.status(400).send({
|
||||||
success: false,
|
success: false,
|
||||||
message:
|
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,
|
lastName,
|
||||||
email,
|
email,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
accountType,
|
accountType: "customer",
|
||||||
accountStatus: "PENDING_VERIFICATION",
|
accountStatus: "PENDING_VERIFICATION",
|
||||||
emailVerifiedAt: null,
|
emailVerifiedAt: null,
|
||||||
},
|
},
|
||||||
@@ -139,8 +138,8 @@ exports.createNewUser = async (req, res) => {
|
|||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
//create customer and business customer
|
// Create the customer identity extension for public registration.
|
||||||
if (accountType === "customer") {
|
{
|
||||||
const customerData = {
|
const customerData = {
|
||||||
address,phoneNumber,
|
address,phoneNumber,
|
||||||
};
|
};
|
||||||
@@ -150,23 +149,6 @@ exports.createNewUser = async (req, res) => {
|
|||||||
customerData,
|
customerData,
|
||||||
transaction,
|
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();
|
await transaction.commit();
|
||||||
@@ -449,6 +431,7 @@ exports.updateUser = async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
await transaction.rollback();
|
||||||
return res.status(404).send({
|
return res.status(404).send({
|
||||||
success: false,
|
success: false,
|
||||||
message: "User not found",
|
message: "User not found",
|
||||||
@@ -456,6 +439,7 @@ exports.updateUser = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!UserProfile) {
|
if (!UserProfile) {
|
||||||
|
await transaction.rollback();
|
||||||
return res.status(404).send({
|
return res.status(404).send({
|
||||||
success: false,
|
success: false,
|
||||||
message: "User profile not found",
|
message: "User profile not found",
|
||||||
@@ -533,6 +517,7 @@ exports.deleteUser = async (req, res) => {
|
|||||||
where: { id },
|
where: { id },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
await transaction.rollback();
|
||||||
return res.status(404).send({
|
return res.status(404).send({
|
||||||
success: false,
|
success: false,
|
||||||
message: "User not found",
|
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); }
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 { verifyToken } = require("../utils/jwt.util");
|
||||||
const { getEffectivePermissions } = require("../services/permission.service");
|
const { getEffectivePermissions } = require("../services/permission.service");
|
||||||
|
const db = require("../models");
|
||||||
|
|
||||||
const authenticate = async (req, res, next) => {
|
const authenticate = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
let token = null;
|
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" } });
|
||||||
// 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 decoded = verifyToken(token);
|
const decoded = verifyToken(token);
|
||||||
|
if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing");
|
||||||
if (process.env.NODE_ENV === "development") {
|
const [user, session] = await Promise.all([
|
||||||
console.log("DECODED:", decoded);
|
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" } });
|
||||||
}
|
}
|
||||||
|
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) };
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
next();
|
next();
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error("AUTH ERROR:", err);
|
res.clearCookie("access_token");
|
||||||
|
return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } });
|
||||||
// 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",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { authenticate };
|
module.exports = { authenticate, requireAuth: authenticate };
|
||||||
|
|||||||
@@ -1,100 +1,25 @@
|
|||||||
/**
|
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
|
||||||
* Copyright (c) 2026 Niolla
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This source code is proprietary and confidential.
|
|
||||||
* Unauthorized copying, modification, distribution, or use
|
|
||||||
* of this file, via any medium, is strictly prohibited.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// app/middleware/permission.middleware.js
|
|
||||||
|
|
||||||
const hasPermission = (userPermissions, requiredPermission) => {
|
|
||||||
return userPermissions.some(p => {
|
|
||||||
if (p === requiredPermission) return true;
|
|
||||||
|
|
||||||
// wildcard support
|
|
||||||
if (p.endsWith(".*")) {
|
|
||||||
const prefix = p.slice(0, -2);
|
|
||||||
return requiredPermission.startsWith(prefix);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const methodToAction = {
|
|
||||||
GET: "view",
|
|
||||||
POST: "create",
|
|
||||||
PUT: "update",
|
|
||||||
PATCH: "update",
|
|
||||||
DELETE: "delete"
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkPermission = (baseOrFull, options = {}) => {
|
|
||||||
return (req, res, next) => {
|
|
||||||
if (!req.user) {
|
|
||||||
return res.status(401).json({
|
|
||||||
success: false,
|
|
||||||
message: "Unauthorized"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// admin bypass
|
|
||||||
if (req.user.accountType === "admin") {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof baseOrFull !== "string") {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "Permission must be a string"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let requiredPermission;
|
|
||||||
|
|
||||||
if (options.custom) {
|
|
||||||
requiredPermission = baseOrFull;
|
|
||||||
} else {
|
|
||||||
const action = methodToAction[req.method];
|
|
||||||
|
|
||||||
if (!action) {
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: "Unknown HTTP method"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
requiredPermission = `${baseOrFull}.${action}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userPermissions = req.user.permissions || [];
|
|
||||||
|
|
||||||
const hasPermission =
|
|
||||||
userPermissions.includes(requiredPermission) ||
|
|
||||||
userPermissions.includes(`${baseOrFull}.*`);
|
|
||||||
|
|
||||||
if (!hasPermission) {
|
|
||||||
return res.status(403).json({
|
|
||||||
success: false,
|
|
||||||
message: `Forbidden - Missing ${requiredPermission}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const 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();
|
next();
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const authorizedAccountType = (allowedTypes) => {
|
const checkPermission = (baseOrFull, options = {}) => (req, res, next) => {
|
||||||
return (req, res, next) => {
|
if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
|
||||||
if (!req.user || !allowedTypes.includes(req.user.accountType)) {
|
if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN) return next();
|
||||||
return res
|
const actions = { GET: "view", POST: "create", PUT: "update", PATCH: "update", DELETE: "delete" };
|
||||||
.status(403)
|
const required = options.custom ? baseOrFull : `${baseOrFull}.${actions[req.method]}`;
|
||||||
.json({ success: false, message: "Forbidden" });
|
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();
|
next();
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {authorizedAccountType, checkPermission};
|
const requireOwnership = (param = "id") => (req, res, next) => {
|
||||||
|
if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN || req.user.accountType === ACCOUNT_TYPES.ADMIN || req.user.id === req.params[param]) return next();
|
||||||
|
return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { authorizedAccountType, requireAccountType: authorizedAccountType, checkPermission, requirePermission: checkPermission, requireOwnership };
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
};
|
||||||
@@ -39,6 +39,8 @@ db.sequelize = sequelize;
|
|||||||
db.User = require("./user/user.model")(sequelize, DataTypes);
|
db.User = require("./user/user.model")(sequelize, DataTypes);
|
||||||
db.Customer = require("./user/customer.model")(sequelize, DataTypes);
|
db.Customer = require("./user/customer.model")(sequelize, DataTypes);
|
||||||
db.BusinessCustomer = require("./user/businessCustomer.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.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes);
|
||||||
db.Profile = require("./user/profile.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.permission = require("./permission/permission.model")(sequelize, DataTypes);
|
||||||
db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes);
|
db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes);
|
||||||
db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes);
|
db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes);
|
||||||
|
db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes);
|
||||||
|
|
||||||
// Document Management
|
// Document Management
|
||||||
db.Document = require("./document/document.model")(sequelize, DataTypes);
|
db.Document = require("./document/document.model")(sequelize, DataTypes);
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
},
|
},
|
||||||
roleName: {
|
roleName: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false
|
allowNull: false,
|
||||||
|
unique: true
|
||||||
},
|
},
|
||||||
roleDescription: {
|
roleDescription: {
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
@@ -37,6 +38,7 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
foreignKey: "role_id",
|
foreignKey: "role_id",
|
||||||
as: "rolePermissions"
|
as: "rolePermissions"
|
||||||
});
|
});
|
||||||
|
roles.hasMany(db.UserRole, { foreignKey: "role_id", as: "userRoles" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return roles;
|
return roles;
|
||||||
|
|||||||
@@ -28,7 +28,27 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: "rolePermission",
|
tableName: "rolePermission",
|
||||||
timestamps: true
|
timestamps: true,
|
||||||
|
indexes: [{ unique: true, fields: ["role_id", "permission_id"] }],
|
||||||
|
hooks: {
|
||||||
|
afterCreate: async (record) => {
|
||||||
|
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
|
||||||
|
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
|
||||||
|
},
|
||||||
|
afterDestroy: async (record) => {
|
||||||
|
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
|
||||||
|
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
|
||||||
|
},
|
||||||
|
afterUpdate: async (record) => {
|
||||||
|
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
|
||||||
|
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
|
||||||
|
},
|
||||||
|
afterBulkCreate: async (records) => {
|
||||||
|
const roleIds = [...new Set(records.map((record) => record.role_id))];
|
||||||
|
const users = await sequelize.models.UserRole.findAll({ where: { role_id: roleIds } });
|
||||||
|
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: "userPermission",
|
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)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -33,10 +33,10 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
accountType: {
|
accountType: {
|
||||||
type: DataTypes.ENUM("business_customer", "customer"),
|
type: DataTypes.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"),
|
||||||
defaultValue: "customer",
|
defaultValue: "customer",
|
||||||
},
|
},
|
||||||
accountStatus: {
|
accountStatus: {
|
||||||
@@ -59,6 +59,8 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
defaultValue: null,
|
defaultValue: null,
|
||||||
},
|
},
|
||||||
|
tokenVersion: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
|
||||||
|
lastLoginAt: { type: DataTypes.DATE, allowNull: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tableName: "users",
|
tableName: "users",
|
||||||
@@ -83,6 +85,9 @@ module.exports = (sequelize, DataTypes) => {
|
|||||||
foreignKey: "user_id",
|
foreignKey: "user_id",
|
||||||
as: "businessCustomer",
|
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;
|
return User;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const controller = require("../controllers/adminUser.controller");
|
||||||
|
const { authenticate } = require("../middleware/auth.middleware");
|
||||||
|
const { authorizedAccountType } = require("../middleware/permission.middleware");
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(authenticate, authorizedAccountType(["superadmin"]));
|
||||||
|
router.patch("/:id/status", controller.updateStatus);
|
||||||
|
router.patch("/:id/account-type", controller.updateAccountType);
|
||||||
|
module.exports = router;
|
||||||
+22
-31
@@ -1,36 +1,27 @@
|
|||||||
/**
|
const express = require("express");
|
||||||
* Copyright (c) 2026 Niolla
|
const auth = require("../controllers/auth.controller");
|
||||||
* All rights reserved.
|
const user = require("../controllers/user.controller");
|
||||||
*
|
const { authenticate } = require("../middleware/auth.middleware");
|
||||||
* This source code is proprietary and confidential.
|
const validate = require("../middleware/validate.middleware");
|
||||||
* Unauthorized copying, modification, distribution, or use
|
const schemas = require("../validation/auth.schemas");
|
||||||
* of this file, via any medium, is strictly prohibited.
|
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
|
||||||
*/
|
|
||||||
|
|
||||||
// app/routes/auth.routes.js
|
|
||||||
|
|
||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
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);
|
router.use(sensitiveLimiter);
|
||||||
|
router.post("/register", validate(schemas.register), user.createNewUser);
|
||||||
// GET /api/auth/me
|
router.post("/login", validate(schemas.login), auth.login);
|
||||||
router.get("/me", authenticate, (req, res) => {
|
router.post("/req-otp", validate(schemas.login), auth.loginReq);
|
||||||
res.json({
|
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
|
||||||
authenticated: true,
|
router.post("/refresh", validate(schemas.refresh), auth.refreshToken);
|
||||||
user: req.user
|
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('/req-otp', authController.loginReq);
|
router.post("/change-password", authenticate, validate(schemas.change), auth.changePassword);
|
||||||
router.post('/login', authController.login);
|
router.post("/verify-email", validate(schemas.verifyEmail), auth.verifyEmail);
|
||||||
router.post('/refresh', authController.refreshToken);
|
router.post("/resend-verification", validate(schemas.resend), auth.resendVerification);
|
||||||
router.post('/forgot-password', authController.forgotPassword);
|
router.post("/google", validate(schemas.oauth), auth.oauth("google"));
|
||||||
router.post('/reset-password', authController.resetPassword);
|
router.post("/apple", validate(schemas.oauth), auth.oauth("apple"));
|
||||||
router.post('/change-password', authenticate, authController.changePassword);
|
router.get("/me", authenticate, auth.me);
|
||||||
router.post('/logout', authController.logout);
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ const docsRoutes = require("./docs.routes");
|
|||||||
const permissionRoutes = require("./permission.routes");
|
const permissionRoutes = require("./permission.routes");
|
||||||
const profileRoutes = require("./profile.routes");
|
const profileRoutes = require("./profile.routes");
|
||||||
const notificationRoutes = require("./notification.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();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -31,5 +34,9 @@ router.use("/document", documentRoutes);
|
|||||||
router.use("/docs", docsRoutes);
|
router.use("/docs", docsRoutes);
|
||||||
router.use("/profile", profileRoutes);
|
router.use("/profile", profileRoutes);
|
||||||
router.use("/notification", notificationRoutes);
|
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;
|
module.exports = router;
|
||||||
@@ -14,6 +14,13 @@ const router = express.Router();
|
|||||||
const permissionController = require("../controllers/permission.controller");
|
const permissionController = require("../controllers/permission.controller");
|
||||||
const { authenticate } = require("../middleware/auth.middleware");
|
const { authenticate } = require("../middleware/auth.middleware");
|
||||||
const { authorizedAccountType } = require("../middleware/permission.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
|
// Permission routes
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const profileController = require("../controllers/profile.controller.js");
|
const profileController = require("../controllers/profile.controller.js");
|
||||||
|
const authController = require("../controllers/auth.controller.js");
|
||||||
const { authenticate } = require("../middleware/auth.middleware");
|
const { authenticate } = require("../middleware/auth.middleware");
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -20,29 +21,32 @@ const {
|
|||||||
} = require("../middleware/permission.middleware");
|
} = require("../middleware/permission.middleware");
|
||||||
const PERMISSIONS = require("../constants/permissions");
|
const PERMISSIONS = require("../constants/permissions");
|
||||||
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
|
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(
|
router.post(
|
||||||
"/change-password",
|
"/change-password",
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
validate(schemas.change),
|
||||||
profileController.changePassword,
|
authController.changePassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/avatar/:userId",
|
"/avatar/:userId",
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
requireOwnership("userId"),
|
||||||
profileController.getProfileAvatar,
|
profileController.getProfileAvatar,
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
"/background/:userId",
|
"/background/:userId",
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
requireOwnership("userId"),
|
||||||
profileController.getProfileBackgroundImage,
|
profileController.getProfileBackgroundImage,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -19,6 +19,9 @@ const {
|
|||||||
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
|
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
|
||||||
const PERMISSIONS = require("../constants/permissions");
|
const PERMISSIONS = require("../constants/permissions");
|
||||||
|
|
||||||
|
router.get("/me", authenticate, userController.getCurrentUser);
|
||||||
|
router.patch("/me", authenticate, userController.updateCurrentUser);
|
||||||
|
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
@@ -41,7 +44,7 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizedAccountType(["admin"]),
|
authorizedAccountType(["admin", "superadmin"]),
|
||||||
userController.getAllUsers
|
userController.getAllUsers
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,14 +58,14 @@ router.get(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizedAccountType(["admin", "management", "team_head", "user"]),
|
authorizedAccountType(["superadmin"]),
|
||||||
userController.updateUser
|
userController.updateUser
|
||||||
);
|
);
|
||||||
|
|
||||||
router.delete(
|
router.delete(
|
||||||
"/:id",
|
"/:id",
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizedAccountType(["admin"]),
|
authorizedAccountType(["superadmin"]),
|
||||||
userController.deleteUser
|
userController.deleteUser
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
const db = require("../models");
|
const db = require("../models");
|
||||||
const User = db.User;
|
const User = db.User;
|
||||||
const RolePermission = db.rolePermission;
|
const RolePermission = db.rolePermission;
|
||||||
|
const UserRole = db.UserRole;
|
||||||
const UserPermission = db.userPermission;
|
const UserPermission = db.userPermission;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -32,10 +33,11 @@ const getEffectivePermissions = async (userId) => {
|
|||||||
const user = await User.findByPk(userId);
|
const user = await User.findByPk(userId);
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
|
|
||||||
const rolePermissions = await RolePermission.findAll({
|
const roles = await UserRole.findAll({ where: { user_id: userId }, attributes: ["role_id"] });
|
||||||
where: { role_id: user.roleID },
|
const rolePermissions = roles.length ? await RolePermission.findAll({
|
||||||
|
where: { role_id: roles.map((role) => role.role_id) },
|
||||||
attributes: ["permission_id"]
|
attributes: ["permission_id"]
|
||||||
});
|
}) : [];
|
||||||
|
|
||||||
const userPermissions = await UserPermission.findAll({
|
const userPermissions = await UserPermission.findAll({
|
||||||
where: { user_id: userId },
|
where: { user_id: userId },
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const hashEmailVerificationToken = (token) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const createEmailVerification = async (userId) => {
|
const createEmailVerification = async (userId) => {
|
||||||
|
const previousKey = await redis.get(`email-verification-user:${userId}`);
|
||||||
|
if (previousKey) await redis.del(previousKey);
|
||||||
// 1. Generate raw token
|
// 1. Generate raw token
|
||||||
const token = generateEmailVerificationToken();
|
const token = generateEmailVerificationToken();
|
||||||
|
|
||||||
@@ -26,6 +28,7 @@ const createEmailVerification = async (userId) => {
|
|||||||
const redisKey = `email-verification:${tokenHash}`;
|
const redisKey = `email-verification:${tokenHash}`;
|
||||||
|
|
||||||
await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL);
|
await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL);
|
||||||
|
await redis.set(`email-verification-user:${userId}`, redisKey, "EX", EMAIL_VERIFICATION_TTL);
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
};
|
};
|
||||||
@@ -45,12 +48,14 @@ const verifyEmailVerificationToken = async (token) => {
|
|||||||
const redisKey = `email-verification:${tokenHash}`;
|
const redisKey = `email-verification:${tokenHash}`;
|
||||||
|
|
||||||
// 3. Search Redis
|
// 3. Search Redis
|
||||||
const userId = await redis.get(redisKey);
|
const userId = await redis.getdel(redisKey);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await redis.del(`email-verification-user:${userId}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
redisKey,
|
redisKey,
|
||||||
@@ -82,10 +87,19 @@ const deleteEmailVerification = async (redisKey) => {
|
|||||||
await redis.del(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 = {
|
module.exports = {
|
||||||
createEmailVerification,
|
createEmailVerification,
|
||||||
verifyEmailVerificationToken,
|
verifyEmailVerificationToken,
|
||||||
sendVerificationEmail,
|
sendVerificationEmail,
|
||||||
deleteEmailVerification,
|
deleteEmailVerification,
|
||||||
|
consumeEmailVerificationToken,
|
||||||
hashEmailVerificationToken,
|
hashEmailVerificationToken,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
// app/utils/idGen.util.js
|
// app/utils/idGen.util.js
|
||||||
|
|
||||||
const { v4: uuidv4 } = require("uuid");
|
const { v4: uuidv4 } = require("uuid");
|
||||||
|
const crypto = require("crypto");
|
||||||
const { nextSequence } = require("./referenceNumber.util");
|
const { nextSequence } = require("./referenceNumber.util");
|
||||||
const {log} = require("./consoleLog.utill");
|
const {log} = require("./consoleLog.utill");
|
||||||
|
|
||||||
@@ -20,15 +21,15 @@ const { generateDeliveryNote } = require("./id/dispatchNote.utill");
|
|||||||
|
|
||||||
|
|
||||||
const generateUserId = () => {
|
const generateUserId = () => {
|
||||||
return "usr_" + Math.random().toString(36).slice(2, 10);
|
return "usr_" + crypto.randomUUID();
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateCustomerId = () => {
|
const generateCustomerId = () => {
|
||||||
return "cust_" + Math.random().toString(36).slice(2, 10);
|
return "cust_" + crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateBusinessCustomerId = () => {
|
const generateBusinessCustomerId = () => {
|
||||||
return "b_cust_" + Math.random().toString(36).slice(2, 10);
|
return "b_cust_" + crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateClientId = () => {
|
const generateClientId = () => {
|
||||||
|
|||||||
+20
-45
@@ -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");
|
const jwt = require("jsonwebtoken");
|
||||||
require("dotenv").config();
|
|
||||||
|
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity
|
const secret = () => {
|
||||||
|
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error("JWT_SECRET is not configured securely");
|
||||||
const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity
|
return process.env.JWT_SECRET;
|
||||||
|
|
||||||
const getSecret = (name) => {
|
|
||||||
const value = process.env[name];
|
|
||||||
if (!value || value.length < 32) throw new Error(`${name} is not configured securely`);
|
|
||||||
return value;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const generateToken = ({ userId, sessionId, tokenVersion }) => jwt.sign(
|
||||||
* Generate JWT token
|
{ sid: sessionId, tokenVersion },
|
||||||
* @param {Object} payload - usually { id, email, role }
|
secret(),
|
||||||
* @returns string
|
{
|
||||||
*/
|
algorithm: "HS256",
|
||||||
const generateToken = (payload) => {
|
subject: userId,
|
||||||
return jwt.sign(payload, getSecret("JWT_SECRET"), { expiresIn: JWT_EXPIRES_IN });
|
issuer: process.env.JWT_ISSUER || "zumri-api",
|
||||||
};
|
audience: process.env.JWT_AUDIENCE || "zumri-clients",
|
||||||
|
expiresIn: process.env.ACCESS_TOKEN_TTL || "15m",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
const verifyToken = (token) => jwt.verify(token, secret(), {
|
||||||
* Verify JWT token
|
algorithms: ["HS256"],
|
||||||
* @param {string} token
|
issuer: process.env.JWT_ISSUER || "zumri-api",
|
||||||
* @returns payload or throws error
|
audience: process.env.JWT_AUDIENCE || "zumri-clients",
|
||||||
*/
|
});
|
||||||
const verifyToken = (token) => {
|
|
||||||
return jwt.verify(token, getSecret("JWT_SECRET"));
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateRefreshToken = (payload) => {
|
module.exports = { generateToken, verifyToken };
|
||||||
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 };
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
@@ -69,6 +69,11 @@ const deletePasswordReset = async (redisKey) => {
|
|||||||
await redis.del(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 =
|
const sendPasswordResetEmail =
|
||||||
async (email, firstName, resetToken) => {
|
async (email, firstName, resetToken) => {
|
||||||
|
|
||||||
@@ -130,6 +135,7 @@ module.exports = {
|
|||||||
createPasswordReset,
|
createPasswordReset,
|
||||||
verifyPasswordResetToken,
|
verifyPasswordResetToken,
|
||||||
deletePasswordReset,
|
deletePasswordReset,
|
||||||
|
consumePasswordResetToken,
|
||||||
hashPasswordResetToken,
|
hashPasswordResetToken,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
sendPasswordChangedEmail,
|
sendPasswordChangedEmail,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -1,32 +1,5 @@
|
|||||||
//app/utils/validation/validatePassword.util.js
|
const { passwordSchema } = require("../../validation/auth.schemas");
|
||||||
const validatePassword = (password) => {
|
|
||||||
// Check if password is a string
|
|
||||||
if (typeof password !== "string") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// At least 1 uppercase letter
|
const validatePassword = (password) => passwordSchema.safeParse(password).success;
|
||||||
const hasUppercase = /[A-Z]/.test(password);
|
|
||||||
|
|
||||||
// At least 1 lowercase letter
|
module.exports = { validatePassword };
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const email = z.string().trim().toLowerCase().email();
|
||||||
|
const password = z.string().min(12).superRefine((value, context) => {
|
||||||
|
const failures = [!/[A-Z]/.test(value), !/[a-z]/.test(value), !/[^A-Za-z0-9]/.test(value), (value.match(/\d/g) || []).length < 4];
|
||||||
|
if (failures.some(Boolean)) context.addIssue({ code: "custom", message: "Password must include uppercase, lowercase, a symbol, and at least four numbers" });
|
||||||
|
});
|
||||||
|
const body = (shape) => z.object({ body: z.object(shape).strict(), params: z.object({}).passthrough(), query: z.object({}).passthrough() });
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
passwordSchema: password,
|
||||||
|
register: body({ firstName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100), email, password, phoneNumber: z.string().trim().min(1), address: z.string().trim().min(1), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
|
||||||
|
login: body({ email, password: z.string().min(1), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional() }),
|
||||||
|
verifyOtp: body({ challengeId: z.string().uuid(), otp: z.string().regex(/^\d{6}$/), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
|
||||||
|
refresh: body({ refreshToken: z.string().min(20).optional(), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
|
||||||
|
forgot: body({ email }),
|
||||||
|
reset: body({ token: z.string().min(20), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }),
|
||||||
|
change: body({ currentPassword: z.string().min(1), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }),
|
||||||
|
verifyEmail: body({ token: z.string().min(20) }),
|
||||||
|
resend: body({ email }),
|
||||||
|
oauth: body({ idToken: z.string().min(20), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional(), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional() }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface, Sequelize) {
|
||||||
|
const userColumns = await queryInterface.describeTable("users");
|
||||||
|
await queryInterface.changeColumn("users", "accountType", {
|
||||||
|
type: Sequelize.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"),
|
||||||
|
allowNull: false, defaultValue: "customer",
|
||||||
|
});
|
||||||
|
await queryInterface.changeColumn("users", "password", { type: Sequelize.STRING, allowNull: true });
|
||||||
|
if (!userColumns.passwordChangedAt) await queryInterface.addColumn("users", "passwordChangedAt", { type: Sequelize.DATE, allowNull: true });
|
||||||
|
await queryInterface.addColumn("users", "tokenVersion", { type: Sequelize.INTEGER, allowNull: false, defaultValue: 0 });
|
||||||
|
await queryInterface.addColumn("users", "lastLoginAt", { type: Sequelize.DATE, allowNull: true });
|
||||||
|
|
||||||
|
await queryInterface.createTable("auth_sessions", {
|
||||||
|
id: { type: Sequelize.UUID, primaryKey: true }, user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" },
|
||||||
|
token_family_id: { type: Sequelize.UUID, allowNull: false }, refresh_token_hash: { type: Sequelize.STRING(64), allowNull: false },
|
||||||
|
device_name: Sequelize.STRING, user_agent: Sequelize.STRING(500), ip_address: Sequelize.STRING(64),
|
||||||
|
remember_me: { type: Sequelize.BOOLEAN, allowNull: false, defaultValue: false }, token_version: { type: Sequelize.INTEGER, allowNull: false },
|
||||||
|
last_used_at: Sequelize.DATE, expires_at: { type: Sequelize.DATE, allowNull: false }, revoked_at: Sequelize.DATE,
|
||||||
|
revoked_reason: Sequelize.STRING, replaced_by_session_id: Sequelize.UUID,
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex("auth_sessions", ["user_id"]);
|
||||||
|
await queryInterface.addIndex("auth_sessions", ["token_family_id"]);
|
||||||
|
await queryInterface.addIndex("auth_sessions", ["expires_at"]);
|
||||||
|
|
||||||
|
await queryInterface.createTable("user_identities", {
|
||||||
|
id: { type: Sequelize.UUID, primaryKey: true }, user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" },
|
||||||
|
provider: { type: Sequelize.ENUM("google", "apple"), allowNull: false }, provider_subject: { type: Sequelize.STRING, allowNull: false },
|
||||||
|
provider_email: Sequelize.STRING, createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex("user_identities", ["provider", "provider_subject"], { unique: true, name: "user_identity_provider_subject_unique" });
|
||||||
|
await queryInterface.addIndex("user_identities", ["user_id"]);
|
||||||
|
|
||||||
|
await queryInterface.createTable("user_roles", {
|
||||||
|
id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
|
user_id: { type: Sequelize.STRING, allowNull: false, references: { model: "users", key: "id" }, onDelete: "CASCADE" },
|
||||||
|
role_id: { type: Sequelize.STRING, allowNull: false, references: { model: "roles", key: "role_id" }, onDelete: "CASCADE" },
|
||||||
|
createdAt: { type: Sequelize.DATE, allowNull: false }, updatedAt: { type: Sequelize.DATE, allowNull: false },
|
||||||
|
});
|
||||||
|
await queryInterface.addIndex("user_roles", ["user_id", "role_id"], { unique: true, name: "user_role_unique" });
|
||||||
|
await queryInterface.addIndex("rolePermission", ["role_id", "permission_id"], { unique: true, name: "role_permission_unique" });
|
||||||
|
await queryInterface.addIndex("userPermission", ["user_id", "permission_id"], { unique: true, name: "user_permission_unique" });
|
||||||
|
await queryInterface.addIndex("roles", ["roleName"], { unique: true, name: "role_name_unique" });
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface, Sequelize) {
|
||||||
|
await queryInterface.removeIndex("roles", "role_name_unique");
|
||||||
|
await queryInterface.removeIndex("userPermission", "user_permission_unique");
|
||||||
|
await queryInterface.removeIndex("rolePermission", "role_permission_unique");
|
||||||
|
await queryInterface.dropTable("user_roles");
|
||||||
|
await queryInterface.dropTable("user_identities");
|
||||||
|
await queryInterface.dropTable("auth_sessions");
|
||||||
|
await queryInterface.removeColumn("users", "lastLoginAt");
|
||||||
|
await queryInterface.removeColumn("users", "tokenVersion");
|
||||||
|
// passwordChangedAt may predate this migration, so down intentionally preserves it.
|
||||||
|
await queryInterface.changeColumn("users", "password", { type: Sequelize.STRING, allowNull: false });
|
||||||
|
await queryInterface.changeColumn("users", "accountType", { type: Sequelize.ENUM("business_customer", "customer"), defaultValue: "customer" });
|
||||||
|
},
|
||||||
|
};
|
||||||
Generated
+170
@@ -23,6 +23,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
|
"google-auth-library": "^11.0.2",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -4170,6 +4171,15 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/binary": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz",
|
||||||
@@ -5699,6 +5709,12 @@
|
|||||||
"node": ">=6.6.0"
|
"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": {
|
"node_modules/extract-zip": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
|
||||||
@@ -5806,6 +5822,29 @@
|
|||||||
"pend": "~1.2.0"
|
"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": {
|
"node_modules/filelist": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||||
@@ -5971,6 +6010,18 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/formidable": {
|
||||||
"version": "3.5.4",
|
"version": "3.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||||
@@ -6084,6 +6135,34 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/generate-function": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
@@ -6222,6 +6301,32 @@
|
|||||||
"node": ">= 6"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -7681,6 +7786,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/json-parse-even-better-errors": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
|
"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": ">=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": {
|
"node_modules/node-gyp-build": {
|
||||||
"version": "4.8.4",
|
"version": "4.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
@@ -10692,6 +10853,15 @@
|
|||||||
"makeerror": "1.0.12"
|
"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": {
|
"node_modules/webdriver-bidi-protocol": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.7.0",
|
"express-rate-limit": "^8.7.0",
|
||||||
|
"google-auth-library": "^11.0.2",
|
||||||
"helmet": "^8.3.0",
|
"helmet": "^8.3.0",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
|||||||
Vendored
+38
@@ -19,9 +19,22 @@ jest.mock("../../app/config/bullBoard.config", () => {
|
|||||||
|
|
||||||
const app = require("../../app");
|
const app = require("../../app");
|
||||||
const { AppError, errorHandler } = require("../../app/middleware/error.middleware");
|
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", () => {
|
describe("foundation HTTP behavior", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
mockCheckDatabase.mockResolvedValue(true);
|
mockCheckDatabase.mockResolvedValue(true);
|
||||||
mockCheckRedis.mockResolvedValue(true);
|
mockCheckRedis.mockResolvedValue(true);
|
||||||
});
|
});
|
||||||
@@ -74,4 +87,29 @@ describe("foundation HTTP behavior", () => {
|
|||||||
test("Bull Board rejects unauthenticated requests", async () => {
|
test("Bull Board rejects unauthenticated requests", async () => {
|
||||||
await request(app).get("/admin/queues").expect(401);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }) }));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user