This commit is contained in:
rgrgogu
2026-05-05 23:18:47 +08:00
parent 5aeb959e92
commit a8f10a25d7
38 changed files with 5992 additions and 2 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env*
*.log
dist/
coverage/
+10
View File
@@ -0,0 +1,10 @@
'use strict';
const path = require('path');
module.exports = {
'config': path.resolve('config', 'database.cjs'),
'models-path': path.resolve('database', 'models'),
'migrations-path': path.resolve('database', 'migrations'),
'seeders-path': path.resolve('database', 'seeders'),
};
+757 -2
View File
@@ -1,2 +1,757 @@
# new_starr # STAR Phase 1 — Tier 1 Auth System
New STARR ## Technical Documentation
**Version:** 1.0.0
**Author:** rgrgogu
**Date:** October 6, 2025
**Stack:** Node.js · Express · Sequelize (PostgreSQL) · JWT · Google OAuth 2.0 · Nodemailer
---
## Table of Contents
1. [Architecture Overview](#1-architecture-overview)
2. [Project Structure](#2-project-structure)
3. [Setup & Installation](#3-setup--installation)
4. [Environment Variables](#4-environment-variables)
5. [Database Models](#5-database-models)
6. [Security Layers](#6-security-layers)
7. [Authentication Flows](#7-authentication-flows)
8. [API Reference](#8-api-reference)
9. [RBAC Permission Matrix](#9-rbac-permission-matrix)
10. [File-by-File Documentation](#10-file-by-file-documentation)
11. [Error Codes Reference](#11-error-codes-reference)
12. [Extending the System](#12-extending-the-system)
---
## 1. Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Client (SPA / Mobile / Browser) │
└────────────────────────────┬────────────────────────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────────┐
│ Express.js App (server.js) │
│ │
│ ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌────────────┐ │
│ │ CORS │ │ Rate Limit│ │ Cookie │ │ Session │ │
│ │ Guard │ │ (global) │ │ Parser │ │ (csurf) │ │
│ └──────────┘ └───────────┘ └────────────┘ └────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Routes │ │
│ │ /api/auth/* → auth.routes.js │ │
│ │ /api/client/* → client.routes.js │ │
│ │ /api/staff/users/*→ staff.routes.js │ │
│ │ /api/admin/* → admin.routes.js │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Middleware Chain (per-route) │ │
│ │ authLimiter → csrfProtection → validate → authenticate │ │
│ │ → requireRole() → Controller │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Services │ Utils │ │
│ │ email.service.js │ token.util.js │ │
│ │ │ otp.util.js │ │
│ │ │ response.util.js │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────┬───────────────────────┘
│ Sequelize ORM
▼
┌─────────────────────────────────────────────────────────────────┐
│ PostgreSQL │
│ ┌──────────┐ ┌─────────────────┐ ┌──────────────────────┐ │
│ │ users │ │ user_sessions │ │ user_groups / │ │
│ │ │ │ │ │ user_group_members │ │
│ └──────────┘ └─────────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 2. Project Structure
```
new_starr/
├── server.js ← App entry point
├── package.json
├── .env.example ← Copy to .env and fill values
│
├── config/
│ ├── db.config.js ← Sequelize PostgreSQL instance
│ └── passport.config.js ← Google OAuth 2.0 strategy
│
├── models/
│ ├── users.mdl.js ← Users table (with OTP fields)
│ ├── user_sessions.mdl.js ← Session audit table
│ └── user_groups.mdl.js ← Groups + Junction table
│
├── middleware/
│ ├── auth.middleware.js ← JWT Bearer token verification
│ ├── rbac.middleware.js ← Role-based access guards
│ ├── csrf.middleware.js ← CSRF protection (csurf)
│ ├── rateLimiter.middleware.js ← Per-route rate limits
│ └── validate.middleware.js ← express-validator result handler
│
├── validators/
│ ├── auth.validator.js ← register / login / OTP / password rules
│ └── profile.validator.js ← profile update rules
│
├── controllers/
│ ├── auth.controller.js ← Shared: register, OTP, login, refresh, logout
│ ├── client/
│ │ └── profile.controller.js ← Own profile + own sessions
│ ├── staff/
│ │ └── users.controller.js ← View/toggle non-admin users
│ └── admin/
│ └── users.controller.js ← Full CRUD + groups
│
├── routes/
│ ├── auth.routes.js ← /api/auth/*
│ ├── client/
│ │ └── client.routes.js ← /api/client/*
│ ├── staff/
│ │ └── staff.routes.js ← /api/staff/users/*
│ └── admin/
│ └── admin.routes.js ← /api/admin/*
│
├── services/
│ └── email.service.js ← Nodemailer OTP + welcome emails
│
└── utils/
├── token.util.js ← JWT generate / verify / hash
├── otp.util.js ← OTP generate / expiry / check
└── response.util.js ← Standardised JSON responses
```
---
## 3. Setup & Installation
```bash
# 1. Clone / copy the project
cd star-auth-system
# 2. Install dependencies
npm install
# 3. Configure environment
cp .env.example .env
# Edit .env with your DB, SMTP, Google OAuth, and JWT credentials
# 4. Ensure PostgreSQL is running and the DB exists
createdb star_db
# 5. Start development server (auto-syncs models)
npm run dev
# 6. Production
npm start
```
> **Note:** On first boot, Sequelize will create all tables automatically via `sequelize.sync({ alter: true })`.
---
## 4. Environment Variables
| Variable | Description | Example |
|---|---|---|
| `NODE_ENV` | `development` or `production` | `development` |
| `PORT` | HTTP port | `3000` |
| `APP_URL` | Frontend/app URL (used for CORS) | `http://localhost:3000` |
| `DB_HOST` | PostgreSQL host | `localhost` |
| `DB_PORT` | PostgreSQL port | `5432` |
| `DB_NAME` | Database name | `star_db` |
| `DB_USER` | DB user | `postgres` |
| `DB_PASSWORD` | DB password | `yourpassword` |
| `JWT_SECRET` | Access token signing key (≥32 chars) | `your_super_secret_…` |
| `JWT_EXPIRES_IN` | Access token TTL | `1d` |
| `JWT_REFRESH_SECRET` | Refresh token signing key | `your_refresh_secret_…` |
| `JWT_REFRESH_EXPIRES_IN` | Refresh token TTL | `7d` |
| `SESSION_SECRET` | Express session secret (used by csurf) | `your_session_secret_…` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | from GCP Console |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | from GCP Console |
| `GOOGLE_CALLBACK_URL` | OAuth redirect URI | `/api/auth/google/callback` |
| `SMTP_HOST` | Mail server host | `smtp.gmail.com` |
| `SMTP_PORT` | Mail server port | `587` |
| `SMTP_USER` | Mail username | `you@gmail.com` |
| `SMTP_PASS` | Mail password / App Password | `xxxx xxxx xxxx xxxx` |
| `EMAIL_FROM` | Sender address | `no-reply@star-system.com` |
| `OTP_EXPIRY_MINUTES` | OTP validity in minutes | `10` |
---
## 5. Database Models
### 5.1 `users` Table
| Column | Type | Notes |
|---|---|---|
| `user_id` | BIGINT PK | Auto-increment |
| `email` | VARCHAR(255) UNIQUE | Validated email |
| `password` | TEXT | bcrypt hash (null for Google users) |
| `is_active` | BOOLEAN | Default `true` |
| `is_verified` | BOOLEAN | Default `false`; set `true` on OTP verify |
| `reg_type` | ENUM | `system` or `google` |
| `acc_type` | ENUM | `user` / `staff` / `admin` |
| `personal_info` | JSONB | Nested name, addresses, phone, avatar, album |
| `otp_code` | VARCHAR(6) | Cleared after verification |
| `otp_expires_at` | TIMESTAMP | Cleared after verification |
| `createdAt` | TIMESTAMP | Auto |
| `updatedAt` | TIMESTAMP | Auto |
### 5.2 `user_sessions` Table
| Column | Type | Notes |
|---|---|---|
| `session_id` | BIGINT PK | Auto-increment |
| `user_id` | BIGINT FK | → users.user_id |
| `login_info` | JSONB | `{ date, ip_address, device_info }` |
| `logout_info` | JSONB | Populated on logout |
| `refresh_token_hash` | TEXT | SHA-256 of refresh token |
| `is_active` | BOOLEAN | `false` after logout |
### 5.3 `user_groups` + `user_group_members` Tables
Groups are named permission bundles. A user can belong to multiple groups.
```
users ──< user_group_members >── user_groups
(user_id, group_id,
joined_at)
```
---
## 6. Security Layers
### 6.1 Rate Limiting
| Limiter | Routes | Window | Max |
|---|---|---|---|
| `globalLimiter` | All routes | 15 min | 1 000 |
| `authLimiter` | `/register`, `/login` | 15 min | 20 |
| `otpLimiter` | `/verify-otp`, `/resend-otp` | 15 min | 5 |
| `sensitiveOpsLimiter` | `/change-password` | 60 min | 10 |
### 6.2 CSRF Protection
CSRF is provided via **csurf** (Double Submit Cookie pattern).
- Retrieve token: `GET /api/auth/csrf-token`
- Send token: `X-CSRF-Token` header OR `_csrf` body field
- Applied automatically to cookie-based flows
> **SPA / REST API clients using `Authorization: Bearer` are inherently CSRF-safe** because cross-site requests cannot set custom headers. CSRF is included for hybrid/SSR scenarios.
### 6.3 JWT Authentication
- **Access Token:** Short-lived (1 day). Sent as `Authorization: Bearer <token>`.
- **Refresh Token:** Long-lived (7 days). SHA-256 hash stored in `user_sessions`. Rotated on every `/refresh` call.
- **Session invalidation:** Logout, password change, and admin force-terminate all flip `is_active = false`.
### 6.4 Password Security
- bcryptjs with **cost factor 12**
- Minimum 8 chars, must contain uppercase + digit
- Current password required before change
- All sessions invalidated after password change
### 6.5 RBAC Hierarchy
```
admin ──► can do everything
│
staff ──► can view/toggle client+staff users; cannot touch admin accounts
│
user ──► can only manage own profile and sessions
(client)
```
---
## 7. Authentication Flows
### 7.1 System Registration → OTP Verify → Auto-Login
```
Client Server Email
│ │ │
│── POST /api/auth/register ──► │ │
│ { email, password } │ │
│ │── create user (unverified)│
│ │── generate OTP ──────────►│
│◄── 201 { email } ─────────── │ │
│ │ │
│── POST /api/auth/verify-otp ►│ │
│ { email, otp } │ │
│ │── validate OTP │
│ │── mark is_verified=true │
│ │── create session record │
│ │── send welcome email ────►│
│◄── 200 { accessToken, │ │
│ refreshToken, │ │
│ user } ─────────── │ │
```
### 7.2 System Login
```
Client Server
│── POST /api/auth/login ─────►│
│ { email, password } │
│ │── lookup user by email
│ │── bcrypt.compare(password, hash)
│ │── create session record
│◄── 200 { accessToken, │
│ refreshToken, │
│ session_id, user } ──│
```
### 7.3 Google OAuth
```
Client Google Server
│── GET /api/auth/google ──────────────►│
│ │── redirect to Google
│◄─────── Google consent screen ───────│
│── approve ──►[Google] ───callback──► │
│ │── find or create user
│ │── create session
│◄── 200 { accessToken, refreshToken } ─│
```
### 7.4 Token Refresh
```
Client Server
│── POST /api/auth/refresh ───►│
│ { refreshToken } │
│ │── verify JWT signature
│ │── lookup session by token hash
│ │── rotate refresh token
│◄── 200 { accessToken, │
│ refreshToken } ─────│
```
### 7.5 Logout
```
Client Server
│── POST /api/auth/logout ────►│
│ Authorization: Bearer ... │
│ { session_id } │
│ │── authenticate JWT
│ │── set session.is_active=false
│ │── save logout_info
│◄── 200 "Logged out" ─────── │
```
---
## 8. API Reference
> **Base URL:** `http://localhost:3000/api`
> **Auth header:** `Authorization: Bearer <accessToken>`
---
### 8.1 Auth Endpoints (Public)
#### `POST /auth/register`
Register a new client account.
**Request body:**
```json
{
"email": "jane@example.com",
"password": "Password1"
}
```
**Response 201:**
```json
{
"status": "success",
"message": "Registration successful. Please check your email for the OTP.",
"data": { "email": "jane@example.com" }
}
```
---
#### `POST /auth/verify-otp`
Verify email with OTP. Returns tokens (auto-login).
**Request body:**
```json
{ "email": "jane@example.com", "otp": "048291" }
```
**Response 200:**
```json
{
"status": "success",
"message": "Email verified successfully. You are now logged in.",
"data": {
"accessToken": "eyJ...",
"refreshToken": "eyJ...",
"session_id": 1,
"user": { "user_id": 1, "email": "jane@example.com", "acc_type": "user", ... }
}
}
```
---
#### `POST /auth/resend-otp`
Resend OTP email (5 requests / 15 min limit).
**Request body:** `{ "email": "jane@example.com" }`
---
#### `POST /auth/login`
System (email + password) login.
**Request body:**
```json
{ "email": "jane@example.com", "password": "Password1" }
```
**Response 200:** Same shape as verify-otp.
---
#### `POST /auth/refresh`
Rotate access + refresh tokens.
**Request body:** `{ "refreshToken": "eyJ..." }`
---
#### `GET /auth/google`
Redirect to Google consent screen. No body needed.
---
#### `GET /auth/google/callback`
Google OAuth redirect target. Returns tokens on success.
---
#### `POST /auth/logout` *(requires Bearer token)*
Invalidates the specified session.
**Request body:** `{ "session_id": 1 }`
---
#### `POST /auth/change-password` *(requires Bearer token)*
Change password. Invalidates all sessions.
**Request body:**
```json
{ "current_password": "OldPass1", "new_password": "NewPass2" }
```
---
### 8.2 Client Endpoints *(requires Bearer — any acc_type)*
| Method | Path | Description |
|---|---|---|
| GET | `/client/profile` | Own profile |
| PUT | `/client/profile` | Update own `personal_info` |
| GET | `/client/sessions` | Own active sessions |
| DELETE | `/client/sessions/:id` | Revoke one of own sessions |
**PUT /client/profile — Example body:**
```json
{
"personal_info": {
"name": {
"given_name": "Jane",
"last_name": "Doe"
},
"occupation": "Engineer",
"date_of_birth": "1995-06-15"
}
}
```
---
### 8.3 Staff Endpoints *(requires Bearer — staff or admin)*
| Method | Path | Description |
|---|---|---|
| GET | `/staff/users` | Paginated list (non-admins) |
| GET | `/staff/users/:id` | Single non-admin user |
| PUT | `/staff/users/:id/status` | Toggle `is_active` |
| GET | `/staff/users/:id/sessions` | User's sessions |
**Query params for GET list:** `?page=1&limit=20`
**PUT /staff/users/:id/status — Body:** `{ "is_active": false }`
---
### 8.4 Admin Endpoints *(requires Bearer — admin only)*
| Method | Path | Description |
|---|---|---|
| GET | `/admin/users` | All users (paginated) |
| GET | `/admin/users/:id` | Any user with groups |
| PUT | `/admin/users/:id` | Update `acc_type`, `is_active`, `personal_info` |
| DELETE | `/admin/users/:id` | Hard delete user |
| GET | `/admin/users/:id/sessions` | All sessions |
| DELETE | `/admin/users/:id/sessions/:sid` | Force-terminate session |
| GET | `/admin/groups` | List all groups |
| POST | `/admin/groups` | Create group `{ name, description }` |
| DELETE | `/admin/groups/:gid` | Delete group |
| POST | `/admin/users/:id/groups/:gid` | Add user to group |
| DELETE | `/admin/users/:id/groups/:gid` | Remove user from group |
**PUT /admin/users/:id — Allowed fields:**
```json
{
"acc_type": "staff",
"is_active": true,
"personal_info": { ... }
}
```
---
## 9. RBAC Permission Matrix
| Action | Client (`user`) | Staff (`staff`) | Admin (`admin`) |
|---|:---:|:---:|:---:|
| Register | ✅ | ✅ | ✅ |
| Login | ✅ | ✅ | ✅ |
| View own profile | ✅ | ✅ | ✅ |
| Edit own profile | ✅ | ✅ | ✅ |
| View own sessions | ✅ | ✅ | ✅ |
| Revoke own session | ✅ | ✅ | ✅ |
| Change own password | ✅ | ✅ | ✅ |
| List all users | ❌ | ✅ (no admins) | ✅ |
| View any user | ❌ | ✅ (no admins) | ✅ |
| Toggle user status | ❌ | ✅ (no admins) | ✅ |
| View user sessions | ❌ | ✅ (no admins) | ✅ |
| Change user `acc_type` | ❌ | ❌ | ✅ |
| View admin accounts | ❌ | ❌ | ✅ |
| Hard delete users | ❌ | ❌ | ✅ |
| Force-terminate session | ❌ | ❌ | ✅ |
| Manage groups | ❌ | ❌ | ✅ |
---
## 10. File-by-File Documentation
### `server.js`
**Purpose:** Application entry point. Bootstraps Express, registers global middleware (CORS, rate limiter, cookie-parser, session, Passport), mounts all routes, syncs DB models.
**Key behaviors:**
- Runs `sequelize.sync({ alter: true })` on startup (development) — safe column updates.
- Registers a global 404 handler and error handler.
- Enables `trust proxy` for correct IP detection behind load balancers.
---
### `config/db.config.js`
**Purpose:** Creates and exports a single authenticated Sequelize instance.
**Usage:** `const sequelize = require('./config/db.config');`
---
### `config/passport.config.js`
**Purpose:** Registers the Google OAuth 2.0 strategy. Auto-creates user on first Google login with `reg_type='google'` and `is_verified=true`.
**Usage:** Imported in `server.js`; used in `routes/auth.routes.js` via `passport.authenticate('google', ...)`.
---
### `models/users.mdl.js`
**Purpose:** Defines the `users` table including OTP fields (`otp_code`, `otp_expires_at`) added for the verification flow.
**Exported as:** `mdl_Users` (default)
---
### `models/user_sessions.mdl.js`
**Purpose:** Audit log for logins/logouts. Stores `refresh_token_hash` for secure token rotation and session invalidation. Sets up `belongsTo/hasMany` with Users.
**Exported as:** `mdl_UserSessions` (default)
---
### `models/user_groups.mdl.js`
**Purpose:** `user_groups` and `user_group_members` junction table. Implements `belongsToMany` associations between Users and Groups.
**Exported as:** `{ mdl_UserGroups, mdl_UserGroupMembers }`
---
### `middleware/auth.middleware.js`
**Purpose:** Reads `Authorization: Bearer <token>`, calls `verifyAccessToken()`, loads the user from DB, and attaches it to `req.user`.
**Exported:** `authenticate`
---
### `middleware/rbac.middleware.js`
**Purpose:** Role guards that inspect `req.user.acc_type`. All guards must be used **after** `authenticate`.
**Exported:** `requireClient()`, `requireStaff()`, `requireAdmin()`, `requireOwnerOrStaff()`, `requireOwnerOrAdmin()`
---
### `middleware/csrf.middleware.js`
**Purpose:** Wraps `csurf` with a token endpoint and a dedicated error handler.
**Exported:** `csrfProtection`, `getCsrfToken`, `csrfErrorHandler`
---
### `middleware/rateLimiter.middleware.js`
**Purpose:** Four rate-limiter configurations for different route tiers.
**Exported:** `globalLimiter`, `authLimiter`, `otpLimiter`, `sensitiveOpsLimiter`
---
### `middleware/validate.middleware.js`
**Purpose:** Reads the `validationResult` from express-validator and returns 422 if any rule failed.
**Exported:** `validate`
---
### `validators/auth.validator.js`
**Purpose:** express-validator chains for `register`, `login`, `verify-otp`, `resend-otp`, `change-password`.
**Usage:** Spread into route definitions: `router.post('/login', ...loginValidator, validate, handler)`
---
### `validators/profile.validator.js`
**Purpose:** Validates `personal_info` fields on profile update requests.
---
### `utils/token.util.js`
**Purpose:** JWT helpers.
**Functions:**
- `generateTokens(user)` → `{ accessToken, refreshToken }`
- `verifyAccessToken(token)` → decoded payload (throws on expiry/invalid)
- `verifyRefreshToken(token)` → decoded payload
- `hashToken(token)` → SHA-256 hex string for DB storage
---
### `utils/otp.util.js`
**Purpose:** Cryptographically secure OTP helpers.
**Functions:**
- `generateOTP()` → `"048291"` (6 digits, padded)
- `getOTPExpiry(minutes)` → `Date` N minutes from now
- `isOTPExpired(expiresAt)` → `boolean`
---
### `utils/response.util.js`
**Purpose:** Standardised response envelope `{ status, message, data?, errors? }`.
**Functions:** `success(res, msg, data, statusCode)`, `error(res, msg, statusCode, errors)`, `validationError(res, errors)`
---
### `services/email.service.js`
**Purpose:** Nodemailer transporter with two email templates.
**Functions:**
- `sendOTPEmail(to, otp, expiryMinutes)` — styled HTML OTP email
- `sendWelcomeEmail(to, name)` — post-verification welcome
---
### `controllers/auth.controller.js`
**Purpose:** Shared auth logic: `register`, `verifyOTP`, `resendOTP`, `login`, `googleCallback`, `refreshToken`, `logout`, `changePassword`.
---
### `controllers/client/profile.controller.js`
**Purpose:** Self-service profile actions for any authenticated user.
**Functions:** `getProfile`, `updateProfile` (deep-merge), `getSessions`, `revokeSession`
---
### `controllers/staff/users.controller.js`
**Purpose:** Staff-level user management (read-only + status toggle; no admin data).
**Functions:** `getUsers`, `getUser`, `setUserStatus`, `getUserSessions`
---
### `controllers/admin/users.controller.js`
**Purpose:** Full admin control over users, sessions, and groups.
**Functions:** `getUsers`, `getUser`, `updateUser`, `deleteUser`, `getUserSessions`, `terminateSession`, `getGroups`, `createGroup`, `deleteGroup`, `addUserToGroup`, `removeUserFromGroup`
---
## 11. Error Codes Reference
| HTTP | Scenario |
|---|---|
| 400 | Bad request (missing field, wrong type, already verified) |
| 401 | Missing/invalid/expired token, wrong credentials |
| 403 | Account deactivated, wrong role, CSRF error |
| 404 | Resource not found |
| 409 | Email already registered |
| 422 | Validation failed (see `errors[]` array) |
| 429 | Rate limit exceeded |
| 500 | Internal server error |
All error responses follow:
```json
{
"status": "error",
"message": "Human-readable reason",
"errors": [ ... ] // only on 422
}
```
---
## 12. Extending the System
### Add a New Endpoint
1. Create validator rules in `validators/`
2. Create or extend a controller in `controllers/<role>/`
3. Add the route in `routes/<role>/<role>.routes.js` with appropriate middleware chain
4. No changes to `server.js` needed if mounting the same base path
### Add a New Role
1. Add the ENUM value to `acc_type` in `users.mdl.js`
2. Add a guard in `rbac.middleware.js` (e.g., `requireSuperAdmin()`)
3. Create a dedicated route + controller folder
### Add Redis Session Store
Install `connect-redis` and update `server.js`:
```js
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
app.use(session({ store: new RedisStore({ client: redisClient }), ... }));
```
### Production Checklist
- [ ] Set `NODE_ENV=production`
- [ ] Use strong `JWT_SECRET` (≥ 64 random characters)
- [ ] Enable HTTPS / TLS (set `cookie.secure: true`)
- [ ] Point `APP_URL` to your actual domain for CORS
- [ ] Replace in-memory rate limiter with Redis store
- [ ] Set up log rotation (replace `console.log` with Winston/Pino)
- [ ] Use `sequelize.sync({ force: false, alter: false })` in production (run migrations instead)
---
*End of Documentation — STAR Phase 1 Auth System v1.0.0*
+41
View File
@@ -0,0 +1,41 @@
/***********************************************************************************************************************************************************************
* File Name: db.config.js
* Type of Program: Configuration
* Description: Sequelize PostgreSQL connection instance.
* Reads credentials from environment variables and exposes a single
* authenticated Sequelize instance used across all models.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const sequelize = require('./config/db.config');
* // Then use sequelize.define() or import your models directly.
***********************************************************************************************************************************************************************/
require('dotenv').config();
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(
process.env.DB_NAME,
process.env.DB_USER,
process.env.DB_PASSWORD,
{
host: process.env.DB_HOST,
port: process.env.DB_PORT || 5432,
dialect: 'postgres',
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false, // or provide CA cert if strict
},
},
logging: process.env.NODE_ENV === 'development' ? console.log : false,
pool: {
max: 10,
min: 0,
acquire: 30000,
idle: 10000,
},
}
);
module.exports = sequelize;
+70
View File
@@ -0,0 +1,70 @@
/***********************************************************************************************************************************************************************
* File Name: passport.config.js
* Type of Program: Configuration
* Description: Passport.js strategy configuration.
* Registers the Google OAuth 2.0 strategy. On first login via Google
* a new user record is auto-created with reg_type="google".
* Returns a signed JWT payload after successful authentication.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* In server.js:
* require('./config/passport.config');
* app.use(passport.initialize());
* In routes:
* router.get('/google', passport.authenticate('google', { scope: ['profile', 'email'] }));
* router.get('/google/callback', passport.authenticate('google', { session: false }), handler);
***********************************************************************************************************************************************************************/
const passport = require('passport');
const { Strategy: GoogleStrategy } = require('passport-google-oauth20');
const mdl_Users = require('../models/users/users.mdl');
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL,
},
async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) return done(new Error('No email returned from Google'), null);
let user = await mdl_Users.findOne({ where: { email } });
if (!user) {
// Auto-register Google users as 'user' acc_type
user = await mdl_Users.create({
email,
reg_type: 'google',
acc_type: 'user',
is_active: true,
is_verified: true, // Google accounts are pre-verified
personal_info: {
name: {
given_name: profile.name?.givenName || '',
last_name: profile.name?.familyName || '',
full_name: profile.displayName || '',
},
avatar: {
url: profile.photos?.[0]?.value || null,
},
},
});
}
if (!user.is_active) {
return done(null, false, { message: 'Account is deactivated.' });
}
return done(null, user);
} catch (err) {
return done(err, null);
}
}
)
);
module.exports = passport;
+446
View File
@@ -0,0 +1,446 @@
/***********************************************************************************************************************************************************************
* File Name: users.controller.js (admin)
* Type of Program: Controller
* Description: Admin-level user management — full CRUD on any user.
* Admins can: list all users, view any user, update acc_type,
* activate/deactivate, hard-delete, manage groups.
*
* Endpoints (require authenticate → requireAdmin()):
* GET /api/admin/users → all users (paginated)
* GET /api/admin/users/:id → any user
* PUT /api/admin/users/:id → update acc_type, is_active, personal_info
* DELETE /api/admin/users/:id → hard delete
* POST /api/admin/users/:id/groups/:gid → add user to group
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
* GET /api/admin/groups → list groups
* POST /api/admin/groups → create group
* DELETE /api/admin/groups/:gid → delete group
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const { Op, Sequelize } = require('sequelize')
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const { mdl_UserGroups, mdl_UserGroupMembers } = require('../../models/users/user_groups.mdl');
const R = require('../../utils/response.util');
const { paginate, auditInclude } = require('../../utils/paginate.util');
const { enrichPersonalInfo } = require('../../utils/personalInfo.util');
const { excludeAttributes: usersExclude, jsonbSchemas: usersSchemas } = require('../../models/users/users.attributes');
const { excludeAttributes: groupExclude, jsonbSchemas: groupSchemas } = require('../../models/users/user_groups.attributes');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
exports.getUsers = async (req, res) => {
try {
const result = await paginate(mdl_Users, req, {
excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' },
});
return R.success(res, 'Users retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET ALL USERS]', err);
return R.error(res, 'Could not retrieve users.', 500);
}
};
// ─── GET single user ───────────────────────────────────────────────────────────
exports.getUser = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.params.id, {
attributes: { exclude: EXCLUDED },
include: [{
model: mdl_UserGroups,
as: 'groups', // ← matches the association alias
through: { attributes: [] }
}],
});
if (!user) return R.error(res, 'User not found.', 404);
return R.success(res, 'User retrieved.', user);
} catch (err) {
console.error("[ADMIN][GET USER]", err);
return R.error(res, 'Could not retrieve user.', 500);
}
};
// ─── PUT update any user ───────────────────────────────────────────────────────
exports.updateUser = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.params.id);
if (!user) return R.error(res, 'User not found.', 404);
const allowed = ['acc_type', 'is_active', 'personal_info'];
const updates = {};
allowed.forEach((k) => { if (req.body[k] !== undefined) updates[k] = req.body[k]; });
if (Number(req.params.id) === req.user.user_id && updates.acc_type !== undefined)
return R.error(res, 'Admins cannot change their own role.', 400);
// Enrich personal_info before saving
if (updates.personal_info) {
updates.personal_info = enrichPersonalInfo(updates.personal_info);
}
updates.updatedBy = req.user.user_id; // ← who updated
await user.update(updates);
const updated = await mdl_Users.findByPk(req.params.id, { attributes: { exclude: EXCLUDED } });
return R.success(res, 'User updated.', updated);
} catch (err) {
console.error("[ADMIN][UPDATE USER]", err);
return R.error(res, 'Could not update user.', 500);
}
};
// ─── DEACTIVATE user (soft delete) ────────────────────────────────────────────
exports.deactivateUser = async (req, res) => {
try {
if (Number(req.params.id) === req.user.user_id)
return R.error(res, 'You cannot deactivate your own account.', 400);
const user = await mdl_Users.findByPk(req.params.id);
if (!user) return R.error(res, 'User not found.', 404);
if (!user.is_active && user.deletedAt)
return R.error(res, 'User is already deactivated.', 400);
await user.update({
is_active: false,
deletedBy: req.user.user_id, // ← who deactivated
});
await user.destroy();
await mdl_UserSessions.update(
{ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } },
{ where: { user_id: req.params.id } }
);
return R.success(res, 'User deactivated successfully.');
} catch (err) {
console.error("[ADMIN][DEACTIVATE USER]", err);
return R.error(res, 'Could not deactivate user.', 500);
}
};
// ─── RESTORE user ──────────────────────────────────────────────────────────────
exports.restoreUser = async (req, res) => {
try {
const user = await mdl_Users.findOne({
where: { user_id: req.params.id },
paranoid: false,
});
if (!user) return R.error(res, 'User not found.', 404);
if (!user.deletedAt) return R.error(res, 'User is not deactivated.', 400);
await user.restore();
await user.update({
is_active: true,
updatedBy: req.user.user_id, // ← who restored
});
return R.success(res, 'User restored successfully.');
} catch (err) {
console.error("[ADMIN][RESTORE USER]", err);
return R.error(res, 'Could not restore user.', 500);
}
};
exports.getGroups = async (req, res) => {
try {
const result = await paginate(mdl_UserGroups, req, {
excludeAttributes: groupExclude,
jsonbSchemas: groupSchemas,
auditOptions: { mdl_Users, parentAlias: 'UserGroup' },
findOptions: {
attributes: {
include: [
[
Sequelize.literal(`(
SELECT CAST(COUNT(*) AS INTEGER)
FROM "user_group_members"
WHERE "user_group_members"."group_id" = "UserGroup"."group_id"
)`),
'memberCount',
],
],
},
},
});
return R.success(res, 'Groups retrieved.', result);
} catch (err) {
console.error('[ADMIN][GET GROUPS]', err);
return R.error(res, 'Could not retrieve groups.', 500);
}
};
exports.getGroup = async (req, res) => {
try {
const group = await mdl_UserGroups.findByPk(req.params.gid);
if (!group) return R.error(res, 'Group not found.', 404);
const members = await paginate(mdl_Users, req, {
excludeAttributes: usersExclude,
jsonbSchemas: usersSchemas,
jsonbColumn: 'personal_info',
auditOptions: { mdl_Users, parentAlias: 'User' },
findOptions: {
include: [
{
model: mdl_UserGroupMembers,
where: { group_id: req.params.gid },
attributes: [],
required: true,
},
],
},
});
return R.success(res, 'Group retrieved.', { group, members });
} catch (err) {
console.error('[ADMIN][GET GROUP]', err);
return R.error(res, 'Could not retrieve group.', 500);
}
};
exports.createGroup = async (req, res) => {
try {
const { name, description } = req.body;
if (!name) return R.error(res, 'Group name is required.', 400);
const group = await mdl_UserGroups.create({
name,
description,
createdBy: req.user.user_id,
});
return R.success(res, 'Group created.', group, 201);
} catch (err) {
console.error('[ADMIN][CREATE GROUP]', err);
return R.error(res, 'Could not create group.', 500);
}
};
exports.updateGroup = async (req, res) => {
try {
const { name, description } = req.body;
const group = await mdl_UserGroups.findByPk(req.params.gid);
if (!group) return R.error(res, 'Group not found.', 404);
if (name !== undefined) group.name = name;
if (description !== undefined) group.description = description;
group.updatedBy = req.user.user_id;
await group.save();
return R.success(res, 'Group updated.', group);
} catch (err) {
console.error('[ADMIN][UPDATE GROUP]', err);
return R.error(res, 'Could not update group.', 500);
}
};
exports.deactivateGroup = async (req, res) => {
try {
const group = await mdl_UserGroups.findByPk(req.params.gid);
if (!group) return R.error(res, 'Group not found.', 404);
if (!group.is_active) return R.error(res, 'Group is already deactivated.', 400);
await group.update({
is_active: false,
updatedBy: req.user.user_id,
deletedBy: req.user.user_id,
});
await group.destroy();
return R.success(res, 'Group deactivated.');
} catch (err) {
console.error('[ADMIN][DEACTIVATE GROUP]', err);
return R.error(res, 'Could not deactivate group.', 500);
}
};
exports.restoreGroup = async (req, res) => {
try {
const group = await mdl_UserGroups.findOne({
where: { group_id: req.params.gid },
paranoid: false, // ← needed to find soft-deleted rows
});
if (!group) return R.error(res, 'Group not found.', 404);
if (group.is_active) return R.error(res, 'Group is already active.', 400);
await group.restore();
await group.update({
is_active: true,
updatedBy: req.user.user_id,
deletedBy: null,
});
return R.success(res, 'Group restored.');
} catch (err) {
console.error('[ADMIN][RESTORE GROUP]', err);
return R.error(res, 'Could not restore group.', 500);
}
};
// ─── Group membership ──────────────────────────────────────────────────────────
// Get List of Members in a Group, to be added
exports.getUsersNotInGroup = async (req, res) => {
try {
const { gid: group_id } = req.params;
const members = await mdl_UserGroupMembers.findAll({
where: { group_id },
attributes: ['user_id'],
});
const memberIds = members.map(m => m.user_id);
const users = await mdl_Users.findAll({
where: {
user_id: { [Op.notIn]: memberIds.length ? memberIds : [0] },
},
attributes: [
'user_id',
[Sequelize.literal(`("User"."personal_info"->'name'->>'full_name')`), 'full_name'],
],
});
return R.success(res, 'Users fetched.', users);
} catch (err) {
console.error('[ADMIN][GET USERS NOT IN GROUP]', err);
return R.error(res, 'Could not fetch users.', 500);
}
};
// Get List of Members in a Group, to be removed
exports.getUsersInGroup = async (req, res) => {
try {
const { gid: group_id } = req.params;
const group = await mdl_UserGroups.findByPk(group_id, {
include: [{
model: mdl_Users,
as: 'members',
through: { attributes: [] },
attributes: [
'user_id',
[Sequelize.literal(`("members"."personal_info"->'name'->>'full_name')`), 'full_name'],
],
}],
});
if (!group) return R.error(res, 'Group not found.', 404);
return R.success(res, 'Group members fetched.', group.members);
} catch (err) {
console.error('[ADMIN][GET USERS IN GROUP]', err);
return R.error(res, 'Could not fetch group members.', 500);
}
};
exports.addUserToGroup = async (req, res) => {
try {
const { gid: group_id } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids) || user_ids.length === 0)
return R.error(res, 'No users provided.', 400);
const existingUsers = await mdl_Users.findAll({
where: { user_id: user_ids },
attributes: ['user_id'],
});
const existingUserIds = existingUsers.map(u => u.user_id);
const notFound = user_ids.filter(id => !existingUserIds.includes(id));
if (notFound.length > 0)
return R.error(res, `Users not found: ${notFound.join(', ')}`, 404);
// Restore soft-deleted rows
await mdl_UserGroupMembers.restore({
where: { user_id: user_ids, group_id },
});
await mdl_UserGroupMembers.update(
{ deletedBy: null, updatedBy: req.user.user_id },
{ where: { user_id: user_ids, group_id }, paranoid: false }
);
// Insert any that didn't exist at all
await mdl_UserGroupMembers.bulkCreate(
user_ids.map(user_id => ({ user_id, group_id, createdBy: req.user.user_id })),
{ ignoreDuplicates: true }
);
return R.success(res, 'Users added to group.');
} catch (err) {
console.error('[ADMIN][ADD USERS TO GROUP]', err);
return R.error(res, 'Could not add users to group.', 500);
}
};
exports.removeUserFromGroup = async (req, res) => {
try {
const { gid: group_id } = req.params;
const { user_ids } = req.body;
if (!Array.isArray(user_ids) || user_ids.length === 0)
return R.error(res, 'No users provided.', 400);
const existingMembers = await mdl_UserGroupMembers.findAll({
where: { user_id: user_ids, group_id },
attributes: ['user_id'],
});
const existingMemberIds = existingMembers.map(m => m.user_id);
const notFound = user_ids.filter(id => !existingMemberIds.includes(id));
if (notFound.length > 0)
return R.error(res, `Memberships not found for users: ${notFound.join(', ')}`, 404);
await mdl_UserGroupMembers.update(
{ deletedBy: req.user.user_id },
{ where: { user_id: user_ids, group_id } }
);
await mdl_UserGroupMembers.destroy({
where: { user_id: user_ids, group_id },
});
return R.success(res, 'Users removed from group.');
} catch (err) {
console.error('[ADMIN][REMOVE USERS FROM GROUP]', err);
return R.error(res, 'Could not remove users from group.', 500);
}
};
// ─── Admin sessions management ─────────────────────────────────────────────────
exports.getUserSessions = async (req, res) => {
try {
const sessions = await mdl_UserSessions.findAll({
where: { user_id: req.params.id },
attributes: { exclude: ['refresh_token_hash'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Sessions retrieved.', sessions);
} catch (err) {
return R.error(res, 'Could not retrieve sessions.', 500);
}
};
exports.terminateSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findByPk(req.params.sid);
if (!session) return R.error(res, 'Session not found.', 404);
await session.update({ is_active: false, logout_info: { date: new Date().toISOString(), forced_by: req.user.user_id } });
return R.success(res, 'Session terminated.');
} catch (err) {
return R.error(res, 'Could not terminate session.', 500);
}
};
+318
View File
@@ -0,0 +1,318 @@
/***********************************************************************************************************************************************************************
* File Name: auth.controller.js
* Type of Program: Controller
* Description: Handles all authentication flows:
* 1. System Registration → OTP email → OTP Verify → Auto-Login
* 2. System Login (verified users)
* 3. Google OAuth callback
* 4. Token Refresh
* 5. Logout (invalidates session)
* 6. OTP Resend
* 7. Change Password
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* Mount via routes/auth.routes.js
* POST /api/auth/register
* POST /api/auth/verify-otp
* POST /api/auth/resend-otp
* POST /api/auth/login
* POST /api/auth/refresh
* POST /api/auth/logout
* GET /api/auth/google
* GET /api/auth/google/callback
***********************************************************************************************************************************************************************/
const bcrypt = require('bcryptjs');
const sequelize = require('../config/db.config')
const mdl_Users = require('../models/users/users.mdl');
const mdl_UserSessions = require('../models/users/user_sessions.mdl');
const { generateTokens, verifyRefreshToken, hashToken } = require('../utils/token.util');
const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
const sendEmail = require('../services/email.service');
const R = require('../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at', 'createdAt', 'updatedAt', 'deletedAt', 'createdBy', 'updatedBy', 'deletedBy'];
// ─── Helpers ───────────────────────────────────────────────────────────────────
const buildLoginInfo = (req) => ({
date: new Date().toISOString(),
ip_address: req.ip,
device_info: req.headers['user-agent'] || 'unknown',
});
const safeUser = (user, extraExclude = []) => {
const u = user.toJSON ? user.toJSON() : { ...user };
[...EXCLUDED, ...extraExclude].forEach((key) => delete u[key]);
return u;
};
// ─── Register ──────────────────────────────────────────────────────────────────
exports.register = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { email, password } = req.body;
const existing = await mdl_Users.findOne({ where: { email } });
if (existing) return R.error(res, 'Email is already registered.', 409);
const hashed = await bcrypt.hash(password, 12);
const otp = generateOTP();
const user = await mdl_Users.create({
email,
password: hashed,
otp_code: otp,
otp_expires_at: getOTPExpiry(),
is_active: true,
is_verified: false,
reg_type: 'system',
acc_type: 'user',
createdBy: null,
}, { transaction });
await sendEmail({ to: email, type: "OTP", data: { otp } });
await transaction.commit();
return R.success(res, 'Registration successful. Please check your email for the OTP.', {
email: user.email,
}, 201);
} catch (err) {
await transaction.rollback();
console.error('[AUTH] register error:', err);
return R.error(res, 'Registration failed.', 500);
}
};
// ─── Verify OTP ────────────────────────────────────────────────────────────────
exports.verifyOTP = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { email, otp } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
if (user.is_verified) return R.error(res, 'Account already verified.', 400);
if (user.otp_code !== otp) return R.error(res, 'Invalid OTP.', 400);
if (isOTPExpired(user.otp_expires_at)) return R.error(res, 'OTP has expired. Please request a new one.', 400);
await user.update({ is_verified: true, otp_code: null, otp_expires_at: null }, { transaction });
// Auto-login after verification
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
}, { transaction });
await sendEmail({ to: email, type: "WELCOME", data: { name: email } });
await transaction.commit();
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return R.success(res, 'Email verified successfully. You are now logged in.', {
accessToken,
refreshToken,
session_id: session.session_id,
user: safeUser(user),
});
} catch (err) {
await transaction.rollback();
console.error('[AUTH] verifyOTP error:', err);
return R.error(res, 'OTP verification failed.', 500);
}
};
// ─── Resend OTP ────────────────────────────────────────────────────────────────
exports.resendOTP = async (req, res) => {
const transaction = await sequelize.transaction();
try {
const { email } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'User not found.', 404);
if (user.is_verified) return R.error(res, 'Account is already verified.', 400);
const otp = generateOTP();
await user.update({ otp_code: otp, otp_expires_at: getOTPExpiry() }, { transaction });
await sendEmail({ to: email, type: "OTP", data: { otp } });
await transaction.commit();
return R.success(res, 'A new OTP has been sent to your email.');
} catch (err) {
await transaction.rollback();
console.error('[AUTH] resendOTP error:', err);
return R.error(res, 'Could not resend OTP.', 500);
}
};
// ─── System Login ──────────────────────────────────────────────────────────────
exports.login = async (req, res) => {
try {
const { email, password } = req.body;
const user = await mdl_Users.findOne({ where: { email } });
if (!user) return R.error(res, 'Invalid credentials.', 401);
if (user.reg_type === 'google') return R.error(res, 'Please log in with Google.', 400);
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
if (!user.is_verified) return R.error(res, 'Please verify your email first.', 403);
const match = await bcrypt.compare(password, user.password);
if (!match) return R.error(res, 'Invalid credentials.', 401);
const { accessToken, refreshToken } = generateTokens(user);
const session = await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return R.success(res, 'Login successful.', {
accessToken,
session_id: session.session_id,
user: safeUser(user),
});
} catch (err) {
console.error('[AUTH] login error:', err);
return R.error(res, 'Login failed.', 500);
}
};
// ─── Google OAuth Callback ─────────────────────────────────────────────────────
exports.googleCallback = async (req, res) => {
try {
const user = req.user; // set by passport
const { accessToken, refreshToken } = generateTokens(user);
await mdl_UserSessions.create({
user_id: user.user_id,
login_info: buildLoginInfo(req),
refresh_token_hash: hashToken(refreshToken),
is_active: true,
});
// In a real SPA: redirect with tokens in query or set httpOnly cookie
return R.success(res, 'Google login successful.', {
accessToken,
refreshToken,
user: safeUser(user),
});
} catch (err) {
console.error('[AUTH] googleCallback error:', err);
return R.error(res, 'Google authentication failed.', 500);
}
};
// ─── Refresh Token ─────────────────────────────────────────────────────────────
exports.refreshToken = async (req, res) => {
try {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return R.error(res, 'Refresh token is required.', 400);
console.log('[REFRESH] called, token tail:', refreshToken?.slice(-10))
console.log('[REFRESH] hash:', hashToken(refreshToken))
const decoded = verifyRefreshToken(refreshToken);
const tokenHash = hashToken(refreshToken);
console.log({ user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true })
const session = await mdl_UserSessions.findOne({
where: { user_id: decoded.user_id, refresh_token_hash: tokenHash, is_active: true },
});
if (!session) return R.error(res, 'Session is invalid or expired. Please log in again.', 401);
const user = await mdl_Users.findByPk(decoded.user_id);
if (!user || !user.is_active) return R.error(res, 'User not found or deactivated.', 401);
const tokens = generateTokens(user);
// Rotate refresh token
await session.update({ refresh_token_hash: hashToken(tokens.refreshToken) });
console.log('[REFRESH] old token tail:', refreshToken?.slice(-10))
console.log('[REFRESH] new token tail:', tokens.refreshToken?.slice(-10))
console.log('[REFRESH] are they different:', refreshToken !== tokens.refreshToken)
res.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true, // ← JS cannot read this
secure: process.env.NODE_ENV === 'production',
sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax', // ← CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
return R.success(res, 'Token refreshed.', { ...tokens, user: safeUser(user) });
} catch (err) {
return R.error(res, 'Invalid or expired refresh token.', 401);
}
};
// ─── Logout ────────────────────────────────────────────────────────────────────
exports.logout = async (req, res) => {
try {
const { session_id } = req.body;
if (session_id) {
await mdl_UserSessions.update(
{ is_active: false, logout_info: buildLoginInfo(req) },
{ where: { session_id, user_id: req.user.user_id } }
);
}
res.clearCookie('refreshToken')
res.clearCookie('_csrf')
return R.success(res, 'Logged out successfully.');
} catch (err) {
console.error('[AUTH] logout error:', err);
return R.error(res, 'Logout failed.', 500);
}
};
// ─── Change Password ───────────────────────────────────────────────────────────
exports.changePassword = async (req, res) => {
try {
const { current_password, new_password } = req.body;
const user = await mdl_Users.findByPk(req.user.user_id);
if (user.reg_type === 'google')
return R.error(res, 'Google accounts cannot change passwords here.', 400);
const match = await bcrypt.compare(current_password, user.password);
if (!match) return R.error(res, 'Current password is incorrect.', 400);
const hashed = await bcrypt.hash(new_password, 12);
await user.update({ password: hashed });
// Invalidate all sessions to force re-login
await mdl_UserSessions.update({ is_active: false }, { where: { user_id: user.user_id } });
return R.success(res, 'Password changed. All sessions have been invalidated. Please log in again.');
} catch (err) {
console.error('[AUTH] changePassword error:', err);
return R.error(res, 'Password change failed.', 500);
}
};
+92
View File
@@ -0,0 +1,92 @@
/***********************************************************************************************************************************************************************
* File Name: profile.controller.js (client)
* Type of Program: Controller
* Description: Self-service profile management for CLIENT users.
* All routes require: authenticate → requireClient()
*
* Endpoints:
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update personal_info
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke a specific session
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const R = require('../../utils/response.util');
// ─── GET own profile ───────────────────────────────────────────────────────────
exports.getProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Profile retrieved.', user);
} catch (err) {
return R.error(res, 'Could not retrieve profile.', 500);
}
};
// ─── PUT update own profile ────────────────────────────────────────────────────
exports.updateProfile = async (req, res) => {
try {
const user = await mdl_Users.findByPk(req.user.user_id);
const { personal_info } = req.body;
// Deep-merge personal_info so partial updates don't wipe existing data
const merged = {
...(user.personal_info || {}),
...(personal_info || {}),
name: {
...((user.personal_info?.name) || {}),
...((personal_info?.name) || {}),
},
};
await user.update({ personal_info: merged });
const updated = await mdl_Users.findByPk(req.user.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
return R.success(res, 'Profile updated.', updated);
} catch (err) {
console.error('[CLIENT] updateProfile error:', err);
return R.error(res, 'Profile update failed.', 500);
}
};
// ─── GET own sessions ──────────────────────────────────────────────────────────
exports.getSessions = async (req, res) => {
try {
const sessions = await mdl_UserSessions.findAll({
where: { user_id: req.user.user_id, is_active: true },
order: [['createdAt', 'DESC']],
attributes: { exclude: ['refresh_token_hash'] },
});
return R.success(res, 'Sessions retrieved.', sessions);
} catch (err) {
return R.error(res, 'Could not retrieve sessions.', 500);
}
};
// ─── DELETE revoke a session ───────────────────────────────────────────────────
exports.revokeSession = async (req, res) => {
try {
const session = await mdl_UserSessions.findOne({
where: { session_id: req.params.id, user_id: req.user.user_id },
});
if (!session) return R.error(res, 'Session not found.', 404);
await session.update({
is_active: false,
logout_info: { date: new Date().toISOString(), ip_address: req.ip },
});
return R.success(res, 'Session revoked.');
} catch (err) {
return R.error(res, 'Could not revoke session.', 500);
}
};
+105
View File
@@ -0,0 +1,105 @@
/***********************************************************************************************************************************************************************
* File Name: users.controller.js (staff)
* Type of Program: Controller
* Description: Staff-level user management.
* Staff can VIEW any client/staff user and perform limited actions.
* Staff CANNOT modify admin accounts or assign/revoke admin roles.
*
* Endpoints (require authenticate → requireStaff()):
* GET /api/staff/users → paginated user list (non-admins)
* GET /api/staff/users/:id → view any non-admin user
* PUT /api/staff/users/:id/status → activate / deactivate user
* GET /api/staff/users/:id/sessions → view user sessions
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const { Op } = require('sequelize');
const mdl_Users = require('../../models/users/users.mdl');
const mdl_UserSessions = require('../../models/users/user_sessions.mdl');
const R = require('../../utils/response.util');
const EXCLUDED = ['password', 'otp_code', 'otp_expires_at'];
// ─── GET paginated list (no admins) ────────────────────────────────────────────
exports.getUsers = async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const offset = (page - 1) * limit;
const { count, rows } = await mdl_Users.findAndCountAll({
where: { acc_type: { [Op.ne]: 'admin' } },
attributes: { exclude: EXCLUDED },
limit,
offset,
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Users retrieved.', {
total: count,
page,
totalPages: Math.ceil(count / limit),
users: rows,
});
} catch (err) {
console.error('[STAFF] getUsers error:', err);
return R.error(res, 'Could not retrieve users.', 500);
}
};
// ─── GET single non-admin user ─────────────────────────────────────────────────
exports.getUser = async (req, res) => {
try {
const user = await mdl_Users.findOne({
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
attributes: { exclude: EXCLUDED },
});
if (!user) return R.error(res, 'User not found.', 404);
return R.success(res, 'User retrieved.', user);
} catch (err) {
return R.error(res, 'Could not retrieve user.', 500);
}
};
// ─── PUT activate / deactivate ─────────────────────────────────────────────────
exports.setUserStatus = async (req, res) => {
try {
const { is_active } = req.body;
if (typeof is_active !== 'boolean')
return R.error(res, 'is_active must be a boolean.', 400);
const user = await mdl_Users.findOne({
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
});
if (!user) return R.error(res, 'User not found or operation not permitted.', 404);
await user.update({ is_active });
return R.success(res, `User ${is_active ? 'activated' : 'deactivated'}.`);
} catch (err) {
return R.error(res, 'Could not update user status.', 500);
}
};
// ─── GET user sessions ─────────────────────────────────────────────────────────
exports.getUserSessions = async (req, res) => {
try {
const user = await mdl_Users.findOne({
where: { user_id: req.params.id, acc_type: { [Op.ne]: 'admin' } },
});
if (!user) return R.error(res, 'User not found.', 404);
const sessions = await mdl_UserSessions.findAll({
where: { user_id: req.params.id },
attributes: { exclude: ['refresh_token_hash'] },
order: [['createdAt', 'DESC']],
});
return R.success(res, 'Sessions retrieved.', sessions);
} catch (err) {
return R.error(res, 'Could not retrieve sessions.', 500);
}
};
// ─── Staff profile (re-use client controller) ──────────────────────────────────
// Staff also manage their own profile through the same client endpoints.
// No additional staff-specific profile endpoints needed.
+81
View File
@@ -0,0 +1,81 @@
export const emailTemplates = {
OTP: ({ otp, expiryMinutes = 10 }) => ({
subject: "Email OTP Verification - STARR System",
title: "Email Verification",
body: `
<p>Dear User,</p>
<p>Please use the One-Time Password (OTP) below to verify your email address.
This code is valid for <strong>${expiryMinutes} minutes</strong>.</p>
<div style="margin:24px 0;font-size:32px;font-weight:bold;letter-spacing:8px;text-align:center;color:#1d4ed8">
${otp}
</div>
<p style="font-size:13px;color:#6b7280">
For security reasons, please do not share this code with anyone.
If you did not request this, please contact the administrator.
</p>
`,
}),
WELCOME: ({ name }) => ({
subject: "Welcome to STARR System",
title: `Welcome Aboard!`,
body: `
<p>Dear ${name},</p>
<p>We are pleased to welcome you to the STARR System. Your account has been successfully created and is now ready for use.</p>
<p>You may now access your dashboard and begin using the available services.</p>
<p style="margin-top:16px">We look forward to supporting you.</p>
`,
}),
PASSWORD_CHANGED: () => ({
subject: "Password Update Confirmation - STARR System",
title: "Password Successfully Updated",
body: `
<p>Dear User,</p>
<p>This is to confirm that your account password has been successfully changed.</p>
<p>If you did not perform this action, please reset your password immediately or contact support.</p>
<p style="margin-top:16px">For your security, we recommend using a strong and unique password.</p>
`,
}),
ADDED_TO_GROUP: ({ groupName }) => ({
subject: "Group Assignment Notification",
title: "Added to Group",
body: `
<p>Dear User,</p>
<p>You have been assigned to the group <strong>${groupName}</strong> in the STARR System.</p>
<p>This assignment grants you access to shared resources and collaboration tools within the group.</p>
<p style="margin-top:16px">Please log in to your account to view group details.</p>
`,
}),
TASK_ASSIGNED: ({ taskTitle, dueDate }) => ({
subject: "New Task Assignment",
title: "Task Assigned",
body: `
<p>Dear User,</p>
<p>You have been assigned a new task in the STARR System.</p>
<div style="margin:16px 0;padding:12px;border:1px solid #e5e7eb;border-radius:6px">
<strong>Task:</strong> ${taskTitle}
</div>
<p><strong>Due Date:</strong> ${dueDate}</p>
<p style="margin-top:16px">Kindly ensure completion within the specified timeframe.</p>
`,
}),
};
+48
View File
@@ -0,0 +1,48 @@
/***********************************************************************************************************************************************************************
* File Name: auth.middleware.js
* Type of Program: Middleware
* Description: JWT authentication guard.
* Reads the Bearer token from the Authorization header,
* verifies it, attaches decoded payload to req.user,
* and verifies the session is still active in the DB.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { authenticate } = require('../middleware/auth.middleware');
* router.get('/profile', authenticate, handler);
***********************************************************************************************************************************************************************/
const { verifyAccessToken } = require('../utils/token.util');
const mdl_Users = require('../models/users/users.mdl');
const R = require('../utils/response.util');
/**
* Validates JWT and loads user from DB.
* Attaches the full user record to req.user.
*/
const authenticate = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer '))
return R.error(res, 'No token provided.', 401);
const token = authHeader.split(' ')[1];
const decoded = verifyAccessToken(token);
const user = await mdl_Users.findByPk(decoded.user_id, {
attributes: { exclude: ['password', 'otp_code', 'otp_expires_at'] },
});
if (!user) return R.error(res, 'User not found.', 401);
if (!user.is_active) return R.error(res, 'Account is deactivated.', 403);
req.user = user;
next();
} catch (err) {
if (err.name === 'TokenExpiredError')
return R.error(res, 'Token expired. Please log in again.', 401);
return R.error(res, 'Invalid token.', 401);
}
};
module.exports = { authenticate };
+53
View File
@@ -0,0 +1,53 @@
/***********************************************************************************************************************************************************************
* File Name: csrf.middleware.js
* Type of Program: Middleware
* Description: CSRF protection using the `csurf` package (Double Submit Cookie pattern).
* - csrfProtection → the csurf middleware instance (attach to state-changing routes)
* - getCsrfToken → GET /csrf-token handler — sends the token to the client
* - csrfErrorHandler → catches EBADCSRFTOKEN and returns a 403
*
* NOTE: Because we use stateless JWT (no server sessions), CSRF is only
* relevant for cookie-based flows (e.g., CSRF token embedded in form headers).
* For REST / SPA clients, the standard practice is to omit CSRF and rely on
* the Authorization Bearer header (which is already CSRF-safe by design).
* This file keeps CSRF available for SSR / hybrid flows.
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* // 1. Mount the token endpoint (public):
* app.get('/api/csrf-token', getCsrfToken);
*
* // 2. Apply to state-mutating cookie-based routes:
* router.post('/login', csrfProtection, loginHandler);
*
* // 3. Register the error handler AFTER all routes:
* app.use(csrfErrorHandler);
***********************************************************************************************************************************************************************/
const csurf = require('csurf');
const R = require('../utils/response.util');
/** csurf instance — stores token in a signed cookie */
const csrfProtection = csurf({ cookie: { httpOnly: true, sameSite: 'strict' } });
/**
* GET /api/csrf-token
* Returns the CSRF token the client must echo back on state-changing requests
* via the `X-CSRF-Token` header or `_csrf` body field.
*/
const getCsrfToken = (req, res) => {
res.json({ csrfToken: req.csrfToken() });
};
/**
* Error handler for invalid / missing CSRF tokens.
* Must be registered as Express error-handling middleware (4 args).
*/
const csrfErrorHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN')
return R.error(res, 'Invalid or missing CSRF token.', 403);
next(err);
};
module.exports = { csrfProtection, getCsrfToken, csrfErrorHandler };
+65
View File
@@ -0,0 +1,65 @@
/***********************************************************************************************************************************************************************
* File Name: rateLimiter.middleware.js
* Type of Program: Middleware
* Description: Express-rate-limit configurations for different route tiers.
* - globalLimiter → applied to ALL routes (1000 req / 15 min)
* - authLimiter → applied to login/register (20 req / 15 min)
* - otpLimiter → applied to OTP send/verify (5 req / 15 min)
* - sensitiveOpsLimiter → password change, account delete (10 req / hour)
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { authLimiter } = require('../middleware/rateLimiter.middleware');
* router.post('/login', authLimiter, loginHandler);
***********************************************************************************************************************************************************************/
const rateLimit = require('express-rate-limit');
const windowMs15 = 15 * 60 * 1000; // 15 minutes
/** Applied globally in server.js */
const globalLimiter = rateLimit({
windowMs: windowMs15,
max: 1000,
standardHeaders: true,
legacyHeaders: false,
message: { status: 'error', message: 'Too many requests, please try again later.' },
});
/** Login & register routes */
const authLimiter = rateLimit({
windowMs: windowMs15,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { status: 'error', message: 'Too many auth attempts. Please wait 15 minutes.' },
});
/** OTP send / verify */
const otpLimiter = rateLimit({
windowMs: windowMs15,
max: 5,
standardHeaders: true,
legacyHeaders: false,
message: { status: 'error', message: 'Too many OTP requests. Please wait 15 minutes.' },
});
/** Password change, account delete */
const sensitiveOpsLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { status: 'error', message: 'Too many sensitive operations. Please wait 1 hour.' },
});
/** Admin routes — tighter than global */
const adminLimiter = rateLimit({
windowMs: windowMs15,
max: 200,
standardHeaders: true,
legacyHeaders: false,
message: { status: 'error', message: 'Too many admin requests. Please wait 15 minutes.' },
});
module.exports = { globalLimiter, authLimiter, otpLimiter, sensitiveOpsLimiter, adminLimiter };
+77
View File
@@ -0,0 +1,77 @@
/***********************************************************************************************************************************************************************
* File Name: rbac.middleware.js
* Type of Program: Middleware
* Description: Role-Based Access Control (RBAC) guards.
* Hierarchy: admin > staff > user (client)
*
* Exported guards:
* - requireClient() → acc_type in ['user', 'staff', 'admin']
* - requireStaff() → acc_type in ['staff', 'admin']
* - requireAdmin() → acc_type === 'admin' only
* - requireOwnerOrStaff() → owns the resource OR is staff/admin
* - requireOwnerOrAdmin() → owns the resource OR is admin
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { requireAdmin, requireStaff } = require('../middleware/rbac.middleware');
* // Must be used AFTER authenticate middleware
* router.get('/users', authenticate, requireStaff(), handler);
* router.delete('/users/:id', authenticate, requireAdmin(), handler);
***********************************************************************************************************************************************************************/
const R = require('../utils/response.util');
const ROLES = { user: 1, staff: 2, admin: 3 };
/**
* Generic role guard factory.
* @param {string[]} allowed - list of acc_type values that can pass
*/
const requireRole = (allowed) => (req, res, next) => {
if (!req.user)
return R.error(res, 'Authentication required.', 401);
if (!allowed.includes(req.user.acc_type))
return R.error(res, 'You do not have permission to access this resource.', 403);
next();
};
/** Any logged-in user (client, staff, admin) */
const requireClient = () => requireRole(['user', 'staff', 'admin']);
/** Staff or Admin */
const requireStaff = () => requireRole(['staff', 'admin']);
/** Admin only */
const requireAdmin = () => requireRole(['admin']);
/**
* Allows resource owner OR staff/admin.
* Reads the owner's user_id from req.params.user_id or req.params.id.
*/
const requireOwnerOrStaff = () => (req, res, next) => {
if (!req.user) return R.error(res, 'Authentication required.', 401);
const targetId = Number(req.params.user_id || req.params.id);
const isOwner = req.user.user_id === targetId;
const elevated = ['staff', 'admin'].includes(req.user.acc_type);
if (!isOwner && !elevated)
return R.error(res, 'You do not have permission.', 403);
next();
};
/**
* Allows resource owner OR admin only.
*/
const requireOwnerOrAdmin = () => (req, res, next) => {
if (!req.user) return R.error(res, 'Authentication required.', 401);
const targetId = Number(req.params.user_id || req.params.id);
const isOwner = req.user.user_id === targetId;
const isAdmin = req.user.acc_type === 'admin';
if (!isOwner && !isAdmin)
return R.error(res, 'You do not have permission.', 403);
next();
};
module.exports = { requireClient, requireStaff, requireAdmin, requireOwnerOrStaff, requireOwnerOrAdmin };
+25
View File
@@ -0,0 +1,25 @@
/***********************************************************************************************************************************************************************
* File Name: validate.middleware.js
* Type of Program: Middleware
* Description: express-validator result checker.
* Pairs with validators/*.validator.js — those files define the rules,
* this file intercepts the request if any rule failed and returns 422.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { registerValidator } = require('../../validators/auth.validator');
* const { validate } = require('../../middleware/validate.middleware');
* router.post('/register', ...registerValidator, validate, handler);
***********************************************************************************************************************************************************************/
const { validationResult } = require('express-validator');
const R = require('../utils/response.util');
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty())
return R.validationError(res, errors.array());
next();
};
module.exports = { validate };
+22
View File
@@ -0,0 +1,22 @@
const excludeAttributes = [
// Add here
];
const jsonbSchemas = {
// Add here
};
// Different exclude sets per role
const adminExclude = [
...excludeAttributes,
// admins can see audit fields, so nothing extra excluded
];
const userExclude = [
...excludeAttributes,
// regular users cannot see audit trails
"created_by", "updated_by", "deleted_by",
"deleted_at",
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas };
+70
View File
@@ -0,0 +1,70 @@
/***********************************************************************************************************************************************************************
* File Name: user_groups.mdl.js
* Type of Program: Model
* Description: Sequelize models for `user_groups` and `user_group_members` tables.
* Implements a Many-to-Many self-contained group system.
* Groups are used as named permission bundles.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { mdl_UserGroups, mdl_UserGroupMembers } = require('./models/user_groups.mdl');
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
// ─── UserGroups ────────────────────────────────────────────────────────────────
const mdl_UserGroups = sequelize.define('UserGroup', {
group_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
name: { type: DataTypes.STRING(50), allowNull: false },
description: { type: DataTypes.TEXT },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
// ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, {
tableName: 'user_groups',
timestamps: true,
paranoid: true,
});
// ─── Junction: UserGroupMembers ────────────────────────────────────────────────
const mdl_UserGroupMembers = sequelize.define('UserGroupMember', {
group_id: { type: DataTypes.BIGINT, primaryKey: true },
user_id: { type: DataTypes.BIGINT, primaryKey: true },
joined_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
// ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, {
tableName: 'user_group_members',
timestamps: true,
paranoid: true,
});
// ─── Associations ──────────────────────────────────────────────────────────────
mdl_Users.belongsToMany(mdl_UserGroups, { through: mdl_UserGroupMembers, foreignKey: 'user_id', otherKey: 'group_id', as: 'groups' });
mdl_UserGroups.belongsToMany(mdl_Users, { through: mdl_UserGroupMembers, foreignKey: 'group_id', otherKey: 'user_id', as: 'members', });
mdl_UserGroups.hasMany(mdl_UserGroupMembers, { foreignKey: 'group_id' });
mdl_UserGroupMembers.belongsTo(mdl_UserGroups, { foreignKey: 'group_id' });
mdl_Users.hasMany(mdl_UserGroupMembers, { foreignKey: 'user_id' });
mdl_UserGroupMembers.belongsTo(mdl_Users, { foreignKey: 'user_id' });
// models/users/user_groups.mdl.js — add at the bottom
mdl_UserGroups.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
mdl_UserGroups.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' });
mdl_UserGroups.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' });
// Self-referencing associations for audit fields
mdl_Users.belongsTo(mdl_Users, { as: 'creator', foreignKey: 'createdBy' });
mdl_Users.belongsTo(mdl_Users, { as: 'modifier', foreignKey: 'updatedBy' });
mdl_Users.belongsTo(mdl_Users, { as: 'deleter', foreignKey: 'deletedBy' });
module.exports = { mdl_UserGroups, mdl_UserGroupMembers };
+60
View File
@@ -0,0 +1,60 @@
/***********************************************************************************************************************************************************************
* File Name: user_sessions.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `user_sessions` table.
* Captures login and logout audit data (IP, geo, device) as JSONB.
* Has a Many-to-One relationship with Users.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* Change History:
* DATE AUTHOR LOG DESCRIPTION
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = require('./users.mdl');
const mdl_UserSessions = sequelize.define('UserSessions', {
session_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true },
user_id: {
type: DataTypes.BIGINT,
allowNull: false,
references: { model: mdl_Users, key: 'user_id' },
},
/**
* login_info / logout_info JSONB:
* {
* date: ISO string,
* ip_address: string,
* country: string,
* region: string,
* city: string,
* lat: number,
* long: number,
* device_info: { ua, browser, os, device }
* forced_by: user_id
* }
*/
login_info: { type: DataTypes.JSONB, allowNull: true },
logout_info: { type: DataTypes.JSONB, allowNull: true },
// Store the refresh-token hash so we can invalidate individual sessions
refresh_token_hash: { type: DataTypes.TEXT, allowNull: true },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
// ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, {
tableName: 'user_sessions',
timestamps: true,
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
});
// Associations
mdl_UserSessions.belongsTo(mdl_Users, { foreignKey: 'user_id' });
mdl_Users.hasMany(mdl_UserSessions, { foreignKey: 'user_id' });
module.exports = mdl_UserSessions;
+47
View File
@@ -0,0 +1,47 @@
const excludeAttributes = [
"password", "otp_code", "otp_expires_at",
"personal_info.name.given_name",
"personal_info.name.middle_name",
"personal_info.name.last_name",
"personal_info.name.extension_name",
"personal_info.addresses[].city",
"personal_info.addresses[].country",
"personal_info.addresses[].street",
"personal_info.addresses[].zip",
"personal_info.addresses[].address_type",
"personal_info.addresses[].state",
"personal_info.phone_number[].country_code",
"personal_info.phone_number[].number",
"personal_info.phone_number[].phone_type",
];
const jsonbSchemas = {
personal_info: {
name: {
full_name: { type: "text", label: "Full Name" },
},
date_of_birth: { type: "date", label: "Date of Birth" },
occupation: { type: "text", label: "Occupation" },
addresses: {
full_address: { type: "text", label: "Full Address" },
},
phone_number: {
full_number: { type: "text", label: "Phone Number" },
},
},
};
// Different exclude sets per role
const adminExclude = [
...excludeAttributes,
// admins can see audit fields, so nothing extra excluded
];
const userExclude = [
...excludeAttributes,
// regular users cannot see audit trails
"created_by", "updated_by", "deleted_by",
"deleted_at",
];
module.exports = { excludeAttributes, adminExclude, userExclude, jsonbSchemas };
+59
View File
@@ -0,0 +1,59 @@
/***********************************************************************************************************************************************************************
* File Name: users.mdl.js
* Type of Program: Model
* Description: Sequelize model for the `users` table.
* Stores authentication credentials, account flags, registration type,
* role/account type, and a flexible JSONB personal_info column.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* Change History:
* DATE AUTHOR LOG DESCRIPTION
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
***********************************************************************************************************************************************************************/
const { DataTypes } = require('sequelize');
const sequelize = require('../../config/db.config');
const mdl_Users = sequelize.define('User', {
user_id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true, label: "User ID" },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, label: "Email Address", validate: { isEmail: true } },
password: { type: DataTypes.TEXT, label: "Password" },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true, label: "Active" },
is_verified: { type: DataTypes.BOOLEAN, defaultValue: false, label: "Verified" },
reg_type: { type: DataTypes.ENUM('system', 'google'), defaultValue: 'system', label: "Registration Type" },
/**
* acc_type drives RBAC:
* - "user" → Client endpoints only
* - "staff" → Client + Staff endpoints
* - "admin" → All endpoints
*/
acc_type: { type: DataTypes.ENUM('user', 'staff', 'admin'), defaultValue: 'user', label: "Account Type" },
/**
* personal_info JSONB structure:
* {
* name: { given_name, middle_name, last_name, extension_name, full_name },
* occupation: string,
* addresses: [{ street, city, state, zip, country, address_type, full_address }],
* phone_number: [{ number, country_code, phone_type, full_number }],
* date_of_birth: date,
* avatar: { mime_type, name, size, url, uuid },
* album: [{ uuid, file_url, original_name, uploaded_at, order_index }]
* }
*/
personal_info: { type: DataTypes.JSONB, defaultValue: null, label: "Personal Info" },
// OTP fields (stored temporarily during verification flow)
otp_code: { type: DataTypes.STRING(6), allowNull: true, label: "OTP Code" },
otp_expires_at: { type: DataTypes.DATE, allowNull: true, label: "OTP Expires At" },
// ── Audit trails ────────────────────────────────────────────────────────────
createdBy: { type: DataTypes.BIGINT, allowNull: true, label: "Created By" },
updatedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Modified By" },
deletedBy: { type: DataTypes.BIGINT, allowNull: true, label: "Deleted By" },
}, {
tableName: 'users',
timestamps: true,
paranoid: true, // enables soft delete — sets deleted_at instead of DELETE
});
module.exports = mdl_Users;
+2399
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "star-auth-system",
"version": "1.0.0",
"description": "STAR Phase 1 - Tier 1 Auth System with CSRF, Rate Limiting, RBAC, Google & System Auth",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"csurf": "^1.11.0",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.10.0",
"express-session": "^1.17.3",
"express-validator": "^7.0.1",
"google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.1",
"nodemailer": "^6.9.5",
"passport": "^0.6.0",
"passport-google-oauth20": "^2.0.0",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"rate-limit-redis": "^4.0.0",
"redis": "^4.6.7",
"sequelize": "^6.32.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
+63
View File
@@ -0,0 +1,63 @@
/***********************************************************************************************************************************************************************
* File Name: admin.routes.js
* Type of Program: Router
* Description: Protected routes accessible by admin only — full control.
*
* Route Map:
* Users:
* GET /api/admin/users → all users
* GET /api/admin/users/:id → single user with groups
* PUT /api/admin/users/:id → update user (acc_type, status, profile)
* DELETE /api/admin/users/:id → hard delete user
* GET /api/admin/users/:id/sessions → view all sessions
* DELETE /api/admin/users/:id/sessions/:sid → force-terminate a session
*
* Groups:
* GET /api/admin/groups → list groups
* POST /api/admin/groups → create group
* DELETE /api/admin/groups/:gid → delete group
* POST /api/admin/users/:id/groups/:gid → add user to group
* DELETE /api/admin/users/:id/groups/:gid → remove user from group
*
* Guards: authenticate → requireAdmin()
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const express = require('express');
const router = express.Router();
const usersCtrl = require('../../controllers/admin/users.controller');
const { authenticate } = require('../../middleware/auth.middleware');
const { requireAdmin } = require('../../middleware/rbac.middleware');
const { adminLimiter, sensitiveOpsLimiter } = require('../../middleware/rateLimiter.middleware');
// Apply adminLimiter to ALL admin routes
router.use(authenticate, requireAdmin(), adminLimiter);
// ── User management ────────────────────────────────────────────────────────────
router.get('/users', usersCtrl.getUsers);
router.get('/users/:id', usersCtrl.getUser);
router.put('/users/:id', sensitiveOpsLimiter, usersCtrl.updateUser);
router.delete('/users/:id', sensitiveOpsLimiter, usersCtrl.deactivateUser); // soft delete
router.post('/users/:id/restore', sensitiveOpsLimiter, usersCtrl.restoreUser); // restore
// ── Session management ─────────────────────────────────────────────────────────
router.get('/users/:id/sessions', usersCtrl.getUserSessions);
router.delete('/users/:id/sessions/:sid', sensitiveOpsLimiter, usersCtrl.terminateSession);
// ─── Groups Management ───────────────────────────────────────────────────────────────
router.get('/groups', usersCtrl.getGroups);
router.get('/groups/:gid', usersCtrl.getGroup);
router.post('/groups', sensitiveOpsLimiter, usersCtrl.createGroup);
router.put('/groups/:gid', sensitiveOpsLimiter, usersCtrl.updateGroup);
router.patch('/groups/:gid/deactivate', sensitiveOpsLimiter, usersCtrl.deactivateGroup);
router.patch('/groups/:gid/restore', sensitiveOpsLimiter, usersCtrl.restoreGroup);
// ─── Group membership ──────────────────────────────────────────────────────────
router.get('/groups/:gid/users', usersCtrl.getUsersInGroup);
router.get('/groups/:gid/users/add', usersCtrl.getUsersNotInGroup);
router.post('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.addUserToGroup);
router.delete('/groups/:gid/users', sensitiveOpsLimiter, usersCtrl.removeUserFromGroup);
module.exports = router;
+61
View File
@@ -0,0 +1,61 @@
/***********************************************************************************************************************************************************************
* File Name: auth.routes.js
* Type of Program: Router
* Description: Public authentication routes (no auth required).
* CSRF protection is applied to state-mutating endpoints.
*
* Route Map:
* GET /api/auth/csrf-token → get CSRF token (for cookie-based clients)
* POST /api/auth/register → system registration
* POST /api/auth/verify-otp → OTP verification + auto-login
* POST /api/auth/resend-otp → resend OTP email
* POST /api/auth/login → system login
* POST /api/auth/refresh → refresh access token
* POST /api/auth/logout → logout (requires authenticate)
* POST /api/auth/change-password → change password (requires authenticate)
* GET /api/auth/google → initiate Google OAuth
* GET /api/auth/google/callback → Google OAuth callback
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const express = require('express');
const passport = require('passport');
const router = express.Router();
const authCtrl = require('../controllers/auth.controller');
const { authenticate } = require('../middleware/auth.middleware');
const { authLimiter, otpLimiter, sensitiveOpsLimiter } = require('../middleware/rateLimiter.middleware');
const { csrfProtection, getCsrfToken } = require('../middleware/csrf.middleware');
const { validate } = require('../middleware/validate.middleware');
const {
registerValidator, loginValidator,
verifyOTPValidator, resendOTPValidator, changePassValidator,
} = require('../validators/auth.validator');
// ── CSRF token (GET — no CSRF needed on GETs) ──────────────────────────────────
router.get('/csrf-token', csrfProtection, getCsrfToken);
// ── System auth ────────────────────────────────────────────────────────────────
router.post('/register', ...registerValidator, validate, authCtrl.register);
router.post('/verify-otp', otpLimiter, ...verifyOTPValidator, validate, authCtrl.verifyOTP);
router.post('/resend-otp', otpLimiter, ...resendOTPValidator, validate, authCtrl.resendOTP);
router.post('/login', ...loginValidator, validate, authCtrl.login);
router.post('/refresh', authCtrl.refreshToken);
router.post('/logout', authenticate, authLimiter, authCtrl.logout);
router.post('/change-password', authenticate, sensitiveOpsLimiter, ...changePassValidator, validate, authCtrl.changePassword);
// ── Google OAuth ───────────────────────────────────────────────────────────────
router.get('/google',
authLimiter,
passport.authenticate('google', { scope: ['profile', 'email'], session: false })
);
router.get('/google/callback',
passport.authenticate('google', { session: false, failureRedirect: '/api/auth/google/failed' }),
authCtrl.googleCallback
);
router.get('/google/failed', (req, res) => {
res.status(401).json({ status: 'error', message: 'Google authentication failed.' });
});
module.exports = router;
+34
View File
@@ -0,0 +1,34 @@
/***********************************************************************************************************************************************************************
* File Name: client.routes.js
* Type of Program: Router
* Description: Protected routes accessible by any authenticated user (client, staff, admin).
*
* Route Map:
* GET /api/client/profile → view own profile
* PUT /api/client/profile → update own profile
* GET /api/client/sessions → view own active sessions
* DELETE /api/client/sessions/:id → revoke own session
*
* Guards: authenticate → requireClient()
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const express = require('express');
const router = express.Router();
const profileCtrl = require('../../controllers/client/profile.controller');
const { authenticate } = require('../../middleware/auth.middleware');
const { requireClient } = require('../../middleware/rbac.middleware');
const { validate } = require('../../middleware/validate.middleware');
const { updateProfileValidator } = require('../../validators/profile.validator');
// Apply auth + role to all routes in this file
router.use(authenticate, requireClient());
router.get('/profile', profileCtrl.getProfile);
router.put('/profile', ...updateProfileValidator, validate, profileCtrl.updateProfile);
router.get('/sessions', profileCtrl.getSessions);
router.delete('/sessions/:id', profileCtrl.revokeSession);
module.exports = router;
+33
View File
@@ -0,0 +1,33 @@
/***********************************************************************************************************************************************************************
* File Name: staff.routes.js
* Type of Program: Router
* Description: Protected routes accessible by staff and admin only.
* Staff can view non-admin users and toggle their status.
* Staff cannot view or modify admins.
*
* Route Map:
* GET /api/staff/users → paginated user list (non-admins)
* GET /api/staff/users/:id → view a specific non-admin user
* PUT /api/staff/users/:id/status → activate or deactivate a user
* GET /api/staff/users/:id/sessions → view user's sessions
*
* Guards: authenticate → requireStaff()
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const express = require('express');
const router = express.Router();
const usersCtrl = require('../../controllers/staff/users.controller');
const { authenticate } = require('../../middleware/auth.middleware');
const { requireStaff } = require('../../middleware/rbac.middleware');
router.use(authenticate, requireStaff());
router.get('/users/', usersCtrl.getUsers);
router.get('/users/:id', usersCtrl.getUser);
router.put('/users/:id/status', usersCtrl.setUserStatus);
router.get('/users/:id/sessions', usersCtrl.getUserSessions);
module.exports = router;
+115
View File
@@ -0,0 +1,115 @@
/***********************************************************************************************************************************************************************
* File Name: server.js
* Type of Program: Application Entry Point
* Description: Bootstraps the Express application.
* Registers global middleware (CORS, rate-limit, cookie-parser,
* session, Passport) and mounts all route groups.
* Syncs Sequelize models to the database on startup.
*
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO RUN:
* 1. cp .env.example .env (fill in your values)
* 2. npm install
* 3. npm run dev (development)
* npm start (production)
***********************************************************************************************************************************************************************/
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const passport = require('./config/passport.config');
const sequelize = require('./config/db.config');
// ── Middleware ──────────────────────────────────────────────────────────────────
const { globalLimiter } = require('./middleware/rateLimiter.middleware');
const { csrfErrorHandler } = require('./middleware/csrf.middleware');
// ── Routes ─────────────────────────────────────────────────────────────────────
const authRoutes = require('./routes/auth.routes');
const clientRoutes = require('./routes/client/client.routes');
const staffRoutes = require('./routes/staff/staff.routes');
const adminRoutes = require('./routes/admin/admin.routes');
// ── Models (ensure associations are loaded) ────────────────────────────────────
require('./models/users/users.mdl');
require('./models/users/user_sessions.mdl');
require('./models/users/user_groups.mdl');
const app = express();
const PORT = process.env.PORT || 3000;
// ──────────────────────────────────────────────────────────────────────────────
// Global Middleware Stack
// ──────────────────────────────────────────────────────────────────────────────
app.set('trust proxy', 1); // Required for rate-limiter behind proxies/load balancers
app.use(cors({
origin: process.env.APP_URL || '*',
credentials: true,
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
// Session — used only by csurf; JWT handles auth state
app.use(session({
secret: process.env.SESSION_SECRET || 'change-me',
resave: false,
saveUninitialized: false,
cookie: { secure: process.env.NODE_ENV === 'production', httpOnly: true, sameSite: 'strict' },
}));
app.use(passport.initialize());
// Global rate limiter (1000 req / 15 min)
app.use(globalLimiter);
// ──────────────────────────────────────────────────────────────────────────────
// Routes
// ──────────────────────────────────────────────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/client', clientRoutes);
app.use('/api/staff', staffRoutes);
app.use('/api/admin', adminRoutes);
// Health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString(), env: process.env.NODE_ENV });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ status: 'error', message: 'Route not found.' });
});
// CSRF error handler (must be after routes)
app.use(csrfErrorHandler);
// Global error handler
app.use((err, req, res, next) => {
console.error('[UNHANDLED ERROR]', err);
res.status(500).json({ status: 'error', message: 'Internal server error.' });
});
// ──────────────────────────────────────────────────────────────────────────────
// Database Sync + Listen
// ──────────────────────────────────────────────────────────────────────────────
(async () => {
try {
await sequelize.authenticate();
console.log('✅ Database connected.');
app.listen(PORT, () => {
console.log(`🚀 STARR Server running on http://localhost:${PORT}`);
console.log(` Environment : ${process.env.NODE_ENV}`);
});
} catch (err) {
console.error('❌ Failed to start server:', err);
process.exit(1);
}
})();
+99
View File
@@ -0,0 +1,99 @@
/***********************************************************************************************************************************************************************
* File Name: email.service.js
* Type of Program: Service
* Description: Nodemailer-based email service.
* Provides:
* - sendOTPEmail() → sends a 6-digit OTP verification email
* - sendWelcomeEmail() → sent after successful email verification
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const emailService = require('../services/email.service');
* await emailService.sendOTPEmail(user.email, otp);
***********************************************************************************************************************************************************************/
const nodemailer = require('nodemailer');
const { emailTemplates } = require('../data/email_body.data')
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
});
const buildEmailTemplate = ({ title, body }) => {
return `
<div style="font-family:Arial,sans-serif;max-width:520px;margin:auto;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden">
<!-- LOGO -->
<div style="padding:20px;text-align:center;background:#232f3e">
<img src="${process.env.APP_LOGO_URL}" alt="Philproperties" style="height:42px" />
</div>
<!-- CONTENT -->
<div style="padding:26px">
<h2 style="color:#111827;margin-bottom:14px">${title}</h2>
${body}
<!-- FORMAL CLOSING -->
<div style="margin-top:24px">
<p style="margin:0">Regards,</p>
<p style="margin:4px 0 0;font-weight:600;color:#111827">
Philproperties IT Team
</p>
</div>
</div>
<!-- FOOTER -->
<div style="padding:14px;text-align:center;font-size:12px;color:#6b7280;border-top:1px solid #e5e7eb">
This is an automated message from STARR System. Please do not reply.
</div>
</div>
`;
};
/**
* Sends a 6-digit OTP to the given email address.
* @param {string} to - recipient email
* @param {string} otp - 6-digit code
* @param {number} expiryMinutes
*/
const sendEmail = async ({ to, type, data = {} }) => {
try {
const templateFn = emailTemplates[type];
if (!templateFn) {
throw new Error(`Email template "${type}" not found`);
}
const { subject, title, body } = templateFn(data);
const html = buildEmailTemplate({ title, body });
return await new Promise((resolve, reject) => {
transporter.sendMail(
{
from: {
name: "STARR System",
address: "do-not-reply@philproperties.com",
},
to,
subject,
html,
},
(err, info) => {
if (err) return reject(err);
resolve(info);
}
);
});
} catch (err) {
throw new Error(err.message);
}
};
module.exports = sendEmail;
+81
View File
@@ -0,0 +1,81 @@
// utils/queryBuilder.js
const { Sequelize, Op } = require("sequelize");
/**
* Builds a Sequelize `where` clause from an array of filters.
*
* @param {Array<{ id: string, value: any }>} filters
* @returns {Object} Sequelize where clause
*/
function buildWhere(filters = [], allowedFields = new Set()) {
const where = [];
for (const { id, value } of filters) {
if (!id || value === undefined || value === null || value === "") continue;
// Reject fields not in whitelist (if whitelist is provided)
if (allowedFields.size && !allowedFields.has(id)) continue;
const values = Array.isArray(value) ? value : [value];
const conditions = values.map((v) =>
id.startsWith("personal_info.")
? Sequelize.where(
Sequelize.json(`personal_info.${id.replace("personal_info.", "")}`),
{ [Op.iLike]: `%${v}%` }
)
: Sequelize.where(
Sequelize.cast(Sequelize.col(id), "TEXT"),
{ [Op.iLike]: `%${v}%` }
)
);
where.push({ [Op.or]: conditions });
}
return where.length ? { [Op.and]: where } : {};
}
/**
* Builds a Sequelize `order` clause from an array of sort descriptors.
* Falls back to [["createdAt", "DESC"]] if no valid sort entries.
*
* @param {Array<{ id: string, desc: boolean }>} sort
* @returns {Array} Sequelize order clause
*/
function buildOrder(sort = [], allowedFields = new Set()) {
const order = [];
for (const { id, desc } of sort) {
if (!id) continue;
// Reject fields not in whitelist (if whitelist is provided)
if (allowedFields.size && !allowedFields.has(id)) continue;
order.push(
id.startsWith("personal_info.")
? [Sequelize.json(id), desc ? "DESC" : "ASC"]
: [id, desc ? "DESC" : "ASC"]
);
}
return order.length ? order : [["createdAt", "DESC"]];
}
/**
* Convenience wrapper — returns both where and order in one call.
*
* @param {Array} filters
* @param {Array} sort
* @returns {{ where: Object, order: Array }}
*/
function buildQuery(filters = [], sort = [], allowedFields = []) {
const fieldSet = new Set(allowedFields);
return {
where: buildWhere(filters, fieldSet),
order: buildOrder(sort, fieldSet),
};
}
module.exports = { buildWhere, buildOrder, buildQuery };
+73
View File
@@ -0,0 +1,73 @@
const { Sequelize } = require('sequelize')
/**
* Strips a key from every element in a JSONB array using PostgreSQL's
* jsonb_agg + #- operator in a subquery.
*
* @param {string} column - JSONB column e.g. "personal_info"
* @param {string} arrayField - array field name e.g. "addresses"
* @param {string[]} keys - keys to strip from each array element e.g. ["street", "zip"]
* @returns {string} SQL fragment
*/
function buildArrayStrip(column, arrayField, keys) {
const keyRemovals = keys.reduce(
(acc, key) => `(${acc} #- '{${key}}')`,
"elem"
);
return `(
SELECT jsonb_agg(${keyRemovals})
FROM jsonb_array_elements("${column}"->'${arrayField}') AS elem
)`;
}
/**
* Builds a Sequelize literal that strips JSONB paths at the DB level.
* Supports:
* - nested keys: "personal_info.name.given_name"
* - array item keys: "personal_info.addresses[].street"
*
* @param {string} column - JSONB column name e.g. "personal_info"
* @param {string[]} excludePaths - dot-notation paths
* @returns {Array|null} Sequelize literal attribute tuple
*/
function excludeJsonbPaths(column, excludePaths = []) {
// Separate nested paths from array paths
const nestedPaths = excludePaths.filter(
(p) => p.startsWith(`${column}.`) && !p.includes("[]")
);
const arrayPaths = excludePaths.filter(
(p) => p.startsWith(`${column}.`) && p.includes("[]")
);
// Group array paths by their field name
// e.g. { addresses: ["street", "zip"], phone_number: ["country_code"] }
const arrayGroups = {};
for (const path of arrayPaths) {
const stripped = path.replace(`${column}.`, ""); // addresses[].street
const [arrayField, key] = stripped.split("[]."); // ["addresses", "street"]
if (!arrayGroups[arrayField]) arrayGroups[arrayField] = [];
arrayGroups[arrayField].push(key);
}
if (!nestedPaths.length && !Object.keys(arrayGroups).length) return null;
// Start with the column and chain #- for nested paths
let literal = `"${column}"`;
// Strip nested keys first
for (const path of nestedPaths) {
const keys = path.replace(`${column}.`, "").split(".");
literal = `(${literal} #- '{${keys.join(",")}}')`;
}
// Then rebuild array fields with stripped keys using jsonb_set
for (const [arrayField, keys] of Object.entries(arrayGroups)) {
const arrayStrip = buildArrayStrip(column, arrayField, keys);
literal = `jsonb_set(${literal}, '{${arrayField}}', COALESCE(${arrayStrip}, '[]'))`;
}
return [Sequelize.literal(literal), column];
}
module.exports = { excludeJsonbPaths };
+162
View File
@@ -0,0 +1,162 @@
// utils/modelToAttributes.js
const { DataTypes } = require("sequelize");
/**
* Maps Sequelize DataType to a simple UI type string.
*/
function resolveType(dataType) {
if (!dataType) return "text";
const type = dataType.constructor?.key || dataType.key || "";
if (["BIGINT", "INTEGER", "FLOAT", "DOUBLE", "DECIMAL"].includes(type)) return "number";
if (["DATE", "DATEONLY"].includes(type)) return "date";
if (["BOOLEAN"].includes(type)) return "enum";
if (["ENUM"].includes(type)) return "enum";
if (["JSONB", "JSON"].includes(type)) return "jsonb";
return "text";
}
/**
* Resolves options (e.g. enum choices) from a Sequelize field definition.
*/
function resolveOptions(dataType) {
if (!dataType) return {};
const type = dataType.constructor?.key || dataType.key || "";
if (type === "ENUM") {
return { choices: dataType.values ?? [] };
}
if (type === "BOOLEAN") {
return { choices: ["true", "false"] };
}
return {};
}
/**
* Converts a camelCase or snake_case field name to a readable label.
*/
function toLabel(field) {
return field
.replace(/_/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/**
* Flattens JSONB field paths from a schema definition.
*
* @param {Object} jsonbSchema - e.g. { name: { given_name, full_name }, date_of_birth }
* @param {string} prefix - e.g. "personal_info"
* @returns {Array} flat attribute entries for each leaf path
*/
function flattenJsonb(jsonbSchema = {}, prefix = "", exclude = []) {
const result = [];
for (const [key, value] of Object.entries(jsonbSchema)) {
const path = prefix ? `${prefix}.${key}` : key;
if (exclude.includes(path)) continue;
// Nested object (no `type` key = it's a group, not a leaf)
if (value && typeof value === "object" && !value.type) {
result.push(...flattenJsonb(value, path, exclude));
} else {
result.push({
name: value?.label || toLabel(key), // <-- prefer label
type: value?.type ?? "text",
field: path,
options: {},
});
}
}
return result;
}
/**
* Generates an attributes array from a Sequelize model + optional JSONB schema map.
*
* @param {Object} model - Sequelize model (e.g. mdl_Users)
* @param {Object} jsonbSchemas - map of JSONB field names to their schema definition
* e.g. { personal_info: { name: { full_name: "text", given_name: "text" }, date_of_birth: "date" } }
* @param {string[]} exclude - field names to exclude (e.g. ["password", "otp_code"])
* @returns {Array} attributes array
*/
function modelToAttributes(model, { jsonbSchemas = {}, exclude = [], timestampLabels = {} } = {}) {
const rawAttrs = model.rawAttributes || model.tableAttributes;
const attributes = [];
const defaultTimestampLabels = {
createdAt: "Created",
modifiedAt: "Modified",
updatedAt: "Modified",
deletedAt: "Deleted",
createdBy: "Created By",
updatedBy: "Modified By",
deletedBy: "Deleted By",
...timestampLabels,
};
// Audit fields excluded from normal loop — handled separately at the end
const auditFields = [
'createdAt', 'updatedAt', 'modifiedAt',
'deletedAt',
'createdBy', 'updatedBy', 'deletedBy',
];
// Ordered audit sequence
const auditSequence = [
{ field: 'updatedAt', type: 'date' },
{ field: 'modifiedAt', type: 'date' },
{ field: 'updatedBy', type: 'text' },
{ field: 'createdAt', type: 'date' },
{ field: 'createdBy', type: 'text' },
{ field: 'deletedAt', type: 'date' },
{ field: 'deletedBy', type: 'text' },
];
// ── Normal fields (excluding audit) ─────────────────────────────────────────
for (const [field, def] of Object.entries(rawAttrs)) {
if (exclude.includes(field)) continue;
if (auditFields.includes(field)) continue; // skip audit — added later in order
const dataType = def.type;
const type = resolveType(def);
if (type === "jsonb" && jsonbSchemas[field]) {
attributes.push(...flattenJsonb(jsonbSchemas[field], field, exclude));
continue;
}
if (type === "jsonb") continue;
attributes.push({
name: defaultTimestampLabels[field] ?? def.label ?? toLabel(field),
type,
field,
options: resolveOptions(def),
});
}
// ── Audit fields in correct sequence ────────────────────────────────────────
for (const { field, type } of auditSequence) {
if (exclude.includes(field)) continue;
if (!rawAttrs[field]) continue; // skip if field doesn't exist on model
attributes.push({
name: defaultTimestampLabels[field],
type,
field,
options: {},
});
}
return attributes;
}
module.exports = { modelToAttributes };
+44
View File
@@ -0,0 +1,44 @@
/***********************************************************************************************************************************************************************
* File Name: otp.util.js
* Type of Program: Utility
* Description: One-Time Password (OTP) generation and validation helpers.
* - generateOTP() → 6-digit numeric string
* - getOTPExpiry() → Date object N minutes from now
* - isOTPExpired() → boolean check on the stored expiry
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { generateOTP, getOTPExpiry, isOTPExpired } = require('../utils/otp.util');
* user.otp_code = generateOTP();
* user.otp_expires_at = getOTPExpiry();
***********************************************************************************************************************************************************************/
const crypto = require('crypto');
/**
* Generates a cryptographically secure 6-digit OTP.
* @returns {string} e.g. "048291"
*/
const generateOTP = () => {
const bytes = crypto.randomBytes(3); // 3 bytes = 0–16777215
const num = bytes.readUIntBE(0, 3) % 1_000_000; // force to 0–999999
return num.toString().padStart(6, '0');
};
/**
* Returns a Date object N minutes in the future.
* @param {number} [minutes=10]
* @returns {Date}
*/
const getOTPExpiry = (minutes = Number(process.env.OTP_EXPIRY_MINUTES) || 10) => {
return new Date(Date.now() + minutes * 60 * 1000);
};
/**
* Checks whether the stored OTP has expired.
* @param {Date|string} expiresAt
* @returns {boolean}
*/
const isOTPExpired = (expiresAt) => !expiresAt || new Date() > new Date(expiresAt);
module.exports = { generateOTP, getOTPExpiry, isOTPExpired };
+120
View File
@@ -0,0 +1,120 @@
// utils/paginate.util.js
const { Sequelize } = require('sequelize');
const { modelToAttributes } = require('./modelToAttributes');
const { excludeJsonbPaths } = require('./excludeJsonbPaths');
const { buildQuery } = require('./buildQuery.util');
const PAGE_START = 1;
const PAGE_SIZE = 10;
const MAX_LIMIT = 100;
function safeParseJSON(value, fallback = []) {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : fallback;
} catch {
return fallback;
}
}
/**
* Generates audit subquery attributes for createdBy, updatedBy, deletedBy
*
* @param {Object} mdl_Users - Users model
* @param {string} parentAlias - Sequelize model alias e.g. 'User', 'UserGroup'
* @returns {Array} - Sequelize attribute include array
*/
function auditInclude(mdl_Users, parentAlias = 'User') {
const tableName = mdl_Users.getTableName();
const fullNameSubquery = (foreignKey) =>
Sequelize.literal(`(
SELECT (u."personal_info"->>'name')::jsonb->>'full_name'
FROM "${tableName}" AS u
WHERE u."user_id" = "${parentAlias}"."${foreignKey}"
LIMIT 1
)`);
return {
attributes: [
[fullNameSubquery('createdBy'), 'createdByName'],
[fullNameSubquery('updatedBy'), 'updatedByName'],
[fullNameSubquery('deletedBy'), 'deletedByName'],
],
};
}
/**
* Reusable paginated findAndCountAll
*
* @param {Object} model - Sequelize model
* @param {Object} req - Express request
* @param {Object} options
* @param {string[]} options.excludeAttributes - fields to exclude
* @param {Object} options.jsonbSchemas - JSONB schema map
* @param {string} options.jsonbColumn - JSONB column name e.g. "personal_info"
* @param {Object} options.findOptions - extra Sequelize options (include, where, etc.)
* @param {Object} options.auditOptions - { mdl_Users, parentAlias } to auto-include audit subqueries
*/
async function paginate(model, req, {
excludeAttributes = [],
jsonbSchemas = {},
jsonbColumn = null,
findOptions = {},
auditOptions = null, // ← { mdl_Users, parentAlias }
} = {}) {
const page = Math.max(PAGE_START, parseInt(req.query.page, 10) || PAGE_START);
const limit = Math.min(parseInt(req.query.limit, 10) || PAGE_SIZE, MAX_LIMIT);
const offset = (page - PAGE_START) * limit;
const filters = safeParseJSON(req.query.filters);
const sort = safeParseJSON(req.query.sort);
const topLevelExclude = excludeAttributes.filter((f) => !f.includes('.'));
const jsonbExclude = excludeAttributes.filter((f) => f.includes('.'));
const jsonbAttr = jsonbColumn ? excludeJsonbPaths(jsonbColumn, jsonbExclude) : null;
const attributes = modelToAttributes(model, { exclude: excludeAttributes, jsonbSchemas });
const ALLOWED_FIELDS = attributes.map((a) => a.field);
const { where, order } = buildQuery(filters, sort, ALLOWED_FIELDS);
// Build attribute includes: jsonb + audit subqueries + any extra from findOptions
const baseIncludes = jsonbAttr ? [jsonbAttr] : [];
const auditAttrs = auditOptions
? auditInclude(auditOptions.mdl_Users, auditOptions.parentAlias).attributes
: [];
const extraIncludes = findOptions.attributes?.include ?? [];
const mergedAttributeIncludes = [...baseIncludes, ...auditAttrs, ...extraIncludes];
const { attributes: _attr, ...restFindOptions } = findOptions;
const { count, rows } = await model.findAndCountAll({
...restFindOptions,
where: { ...where, ...(restFindOptions.where ?? {}) },
order,
limit,
offset,
attributes: {
exclude: topLevelExclude,
include: mergedAttributeIncludes,
},
});
const totalPages = Math.ceil(count / limit);
return {
data: rows,
pagination: {
page,
limit,
totalRecords: count,
totalPages,
hasPrevPage: page > PAGE_START,
hasNextPage: page < totalPages,
},
attributes,
};
}
module.exports = { paginate, auditInclude, safeParseJSON };
+59
View File
@@ -0,0 +1,59 @@
// utils/personalInfo.util.js
/**
* Computes full_name, full_address, full_number
* from a personal_info object before saving to DB.
*
* @param {Object} personalInfo
* @returns {Object} enriched personal_info
*/
function enrichPersonalInfo(personalInfo = {}) {
if (!personalInfo || typeof personalInfo !== 'object') return personalInfo;
const pi = { ...personalInfo };
// ── Full Name ────────────────────────────────────────────────────────────────
if (pi.name) {
const { last_name, given_name, middle_name, extension_name } = pi.name;
pi.name = {
...pi.name,
full_name: [
last_name ? `${last_name},` : null,
given_name,
middle_name,
extension_name,
]
.filter((v) => v && v.trim() !== '')
.join(' '),
};
}
// ── Full Addresses ───────────────────────────────────────────────────────────
if (Array.isArray(pi.addresses)) {
pi.addresses = pi.addresses.map((addr) => ({
...addr,
full_address: [
addr.street,
addr.city,
addr.state,
addr.country,
addr.zip,
]
.filter((v) => v && v.trim() !== '')
.join(', '),
}));
}
// ── Full Phone Numbers ───────────────────────────────────────────────────────
if (Array.isArray(pi.phone_number)) {
pi.phone_number = pi.phone_number.map((phone) => ({
...phone,
full_number: `${phone.country_code || ''}${phone.number || ''}`,
}));
}
return pi;
}
module.exports = { enrichPersonalInfo };
+28
View File
@@ -0,0 +1,28 @@
/***********************************************************************************************************************************************************************
* File Name: response.util.js
* Type of Program: Utility
* Description: Standardised HTTP response helpers.
* Wraps all responses in a consistent envelope:
* { status, message, data?, errors? }
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const R = require('../utils/response.util');
* return R.success(res, 'User created', user, 201);
* return R.error(res, 'Not found', 404);
***********************************************************************************************************************************************************************/
const success = (res, message = 'OK', data = null, statusCode = 200) =>
res.status(statusCode).json({ status: 'success', message, data });
const error = (res, message = 'An error occurred', statusCode = 500, errors = null) => {
const body = { status: 'error', message };
if (errors) body.errors = errors;
return res.status(statusCode).json(body);
};
const validationError = (res, errors) =>
res.status(422).json({ status: 'error', message: 'Validation failed', errors });
module.exports = { success, error, validationError };
+70
View File
@@ -0,0 +1,70 @@
/***********************************************************************************************************************************************************************
* File Name: token.util.js
* Type of Program: Utility
* Description: JWT access & refresh token helpers.
* - generateTokens() → produces { accessToken, refreshToken }
* - verifyAccessToken() → validates and returns decoded payload
* - verifyRefreshToken() → validates the long-lived refresh token
* - hashToken() → SHA-256 hash used to store refresh tokens in DB
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************
* HOW TO USE:
* const { generateTokens, verifyAccessToken } = require('../utils/token.util');
* const { accessToken, refreshToken } = generateTokens(user);
***********************************************************************************************************************************************************************/
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
/**
* Generates a signed access token and a signed refresh token.
* @param {object} user - Sequelize User instance
* @returns {{ accessToken: string, refreshToken: string }}
*/
const generateTokens = (user) => {
const payload = {
user_id: user.user_id,
email: user.email,
acc_type: user.acc_type,
reg_type: user.reg_type,
};
const accessToken = jwt.sign(payload, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '1d',
});
const refreshToken = jwt.sign(
{ user_id: user.user_id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '7d' }
);
return { accessToken, refreshToken };
};
/**
* Verifies an access token.
* @param {string} token
* @returns {object} decoded payload
* @throws if invalid / expired
*/
const verifyAccessToken = (token) =>
jwt.verify(token, process.env.JWT_SECRET);
/**
* Verifies a refresh token.
* @param {string} token
* @returns {object} decoded payload
*/
const verifyRefreshToken = (token) =>
jwt.verify(token, process.env.JWT_REFRESH_SECRET);
/**
* SHA-256 hash a token string for safe DB storage.
* @param {string} token
* @returns {string} hex digest
*/
const hashToken = (token) =>
crypto.createHash('sha256').update(token).digest('hex');
module.exports = { generateTokens, verifyAccessToken, verifyRefreshToken, hashToken };
+46
View File
@@ -0,0 +1,46 @@
/***********************************************************************************************************************************************************************
* File Name: auth.validator.js
* Type of Program: Validator
* Description: express-validator rule chains for authentication endpoints.
* - registerValidator → POST /auth/register
* - loginValidator → POST /auth/login
* - verifyOTPValidator → POST /auth/verify-otp
* - resendOTPValidator → POST /auth/resend-otp
* - changePassValidator → POST /auth/change-password
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const { body } = require('express-validator');
const registerValidator = [
body('email').isEmail().withMessage('Valid email is required.'),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters.')
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter.')
.matches(/[0-9]/).withMessage('Password must contain a number.'),
];
const loginValidator = [
body('email').isEmail().withMessage('Valid email is required.'),
body('password').notEmpty().withMessage('Password is required.'),
];
const verifyOTPValidator = [
body('email').isEmail(),
body('otp').isLength({ min: 6, max: 6 }).isNumeric().withMessage('OTP must be 6 digits.'),
];
const resendOTPValidator = [
body('email').isEmail().withMessage('Valid email is required.'),
];
const changePassValidator = [
body('current_password').notEmpty().withMessage('Current password is required.'),
body('new_password')
.isLength({ min: 8 }).withMessage('New password must be at least 8 characters.')
.matches(/[A-Z]/).withMessage('Must contain an uppercase letter.')
.matches(/[0-9]/).withMessage('Must contain a number.'),
];
module.exports = { registerValidator, loginValidator, verifyOTPValidator, resendOTPValidator, changePassValidator };
+19
View File
@@ -0,0 +1,19 @@
/***********************************************************************************************************************************************************************
* File Name: profile.validator.js
* Type of Program: Validator
* Description: express-validator rule chains for profile update endpoints.
* Author: rgrgogu
* Date Created: Oct. 6, 2025
***********************************************************************************************************************************************************************/
const { body } = require('express-validator');
const updateProfileValidator = [
body('personal_info').optional().isObject().withMessage('personal_info must be an object.'),
body('personal_info.name').optional().isObject(),
body('personal_info.name.given_name').optional().isString().trim().isLength({ max: 100 }),
body('personal_info.name.last_name').optional().isString().trim().isLength({ max: 100 }),
body('personal_info.occupation').optional().isString().trim().isLength({ max: 150 }),
body('personal_info.date_of_birth').optional().isDate().withMessage('date_of_birth must be a valid date.'),
];
module.exports = { updateProfileValidator };