mbkauthe 3.5.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,35 +1,28 @@
1
- # MBKAuthe - Authentication System for Node.js
1
+ # MBKAuthe - Node.js Authentication System
2
2
 
3
3
  [![Version](https://img.shields.io/npm/v/mbkauthe.svg)](https://www.npmjs.com/package/mbkauthe)
4
4
  [![License](https://img.shields.io/badge/License-GPL--2.0-blue.svg)](LICENSE)
5
5
  [![Node.js](https://img.shields.io/badge/node-%3E%3D14.0.0-brightgreen.svg)](https://nodejs.org/)
6
- [![Publish to npm](https://github.com/MIbnEKhalid/mbkauthe/actions/workflows/publish.yml/badge.svg?branch=main)](https://github.com/MIbnEKhalid/mbkauthe/actions/workflows/publish.yml)
7
- [![CodeQL Advanced](https://github.com/MIbnEKhalid/mbkauthe/actions/workflows/codeql.yml/badge.svg?branch=main)](https://github.com/MIbnEKhalid/mbkauthe/actions/workflows/codeql.yml)
8
-
9
-
10
- <p align="center">
11
- <img height="64px" src="./public/icon.svg" alt="MBK Chat Platform" />
12
- </p>
6
+ [![Publish](https://github.com/MIbnEKhalid/mbkauthe/actions/workflows/publish.yml/badge.svg?branch=main)](https://github.com/MIbnEKhalid/mbkauthe/actions/workflows/publish.yml)
13
7
 
14
8
  <p align="center">
15
- <img src="https://skillicons.dev/icons?i=nodejs,express,postgres" />
16
- <img height="48px" src="https://handlebarsjs.com/handlebars-icon.svg" alt="Handlebars" />
9
+ <img height="64px" src="./public/icon.svg" alt="MBKAuthe" />
17
10
  </p>
18
11
 
19
- **MBKAuthe** is a production-ready authentication system for Node.js applications. Built with Express and PostgreSQL, it provides secure authentication, 2FA, role-based access, and OAuth integration (GitHub & Google) out of the box.
12
+ **MBKAuthe** is a production-ready authentication system for Node.js with Express and PostgreSQL. Features include secure login, 2FA, role-based access, OAuth (GitHub & Google), multi-session support, and multi-app user management.
20
13
 
21
14
  ## ✨ Key Features
22
15
 
23
- - 🔐 Secure password authentication with PBKDF2 hashing
24
- - 🔑 PostgreSQL session management with cross-subdomain support
25
- - 📱 Optional TOTP-based 2FA with trusted device memory
26
- - 🔄 OAuth integration (GitHub & Google)
27
- - 👥 Role-based access control (SuperAdmin, NormalUser, Guest)
28
- - 🎯 Multi-application user management
29
- - 🛡️ CSRF protection & advanced rate limiting
30
- - 🚀 Easy Express.js integration
31
- - 🎨 Customizable Handlebars templates
32
- - 🔒 Enhanced security with session fixation prevention
16
+ - Secure password authentication (PBKDF2)
17
+ - PostgreSQL session management
18
+ - Multi-session support (configurable concurrent sessions per user)
19
+ - Optional TOTP-based 2FA with trusted devices
20
+ - OAuth login (GitHub & Google)
21
+ - Role-based access: SuperAdmin, NormalUser, Guest
22
+ - CSRF protection & rate limiting
23
+ - Easy Express.js integration
24
+ - Customizable Handlebars templates
25
+ - Session fixation prevention
33
26
 
34
27
  ## 📦 Installation
35
28
 
@@ -39,49 +32,15 @@ npm install mbkauthe
39
32
 
40
33
  ## 🚀 Quick Start
41
34
 
42
- **1. Configure Environment (.env)**
35
+ **1. Configure Environment**
43
36
 
44
- ```env
45
- APP_NAME=your-app
46
- SESSION_SECRET_KEY=your-secret-key
47
- MAIN_SECRET_TOKEN=api-token
48
- IS_DEPLOYED=false
49
- DOMAIN=localhost
50
- LOGIN_DB=postgresql://user:pass@localhost:5432/db
51
-
52
- # Optional Features
53
- MBKAUTH_TWO_FA_ENABLE=false
54
- COOKIE_EXPIRE_TIME=2
55
-
56
- # OAuth Configuration (Optional)
57
- GITHUB_LOGIN_ENABLED=false
58
- GITHUB_CLIENT_ID=
59
- GITHUB_CLIENT_SECRET=
60
-
61
- GOOGLE_LOGIN_ENABLED=false
62
- GOOGLE_CLIENT_ID=
63
- GOOGLE_CLIENT_SECRET=
64
- ```
65
-
66
- **2. Set Up Database**
67
-
68
- ```sql
69
- CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest');
70
-
71
- CREATE TABLE "Users" (
72
- id SERIAL PRIMARY KEY,
73
- "UserName" VARCHAR(50) NOT NULL UNIQUE,
74
- "Password" VARCHAR(61) NOT NULL,
75
- "Role" role DEFAULT 'NormalUser',
76
- "Active" BOOLEAN DEFAULT FALSE,
77
- "AllowedApps" JSONB DEFAULT '["mbkauthe"]',
78
- "SessionId" VARCHAR(213),
79
- created_at TIMESTAMP DEFAULT NOW(),
80
- updated_at TIMESTAMP DEFAULT NOW()
81
- );
37
+ ```bash
38
+ Copy-Item .env.example .env
82
39
  ```
40
+ See [docs/env.md](docs/env.md).
83
41
 
84
- See [docs/db.md](docs/db.md) for complete schemas.
42
+ **2. Set Up Database**
43
+ Run [docs/db.sql](docs/db.sql) to create tables and a default SuperAdmin (`support` / `12345678`). Change the password immediately. See [docs/db.md](docs/db.md).
85
44
 
86
45
  **3. Integrate with Express**
87
46
 
@@ -89,295 +48,86 @@ See [docs/db.md](docs/db.md) for complete schemas.
89
48
  import express from 'express';
90
49
  import mbkauthe, { validateSession, checkRolePermission } from 'mbkauthe';
91
50
  import dotenv from 'dotenv';
92
-
93
51
  dotenv.config();
94
52
 
95
- // App-specific configuration (as JSON string)
96
- process.env.mbkautheVar = JSON.stringify({
97
- APP_NAME: process.env.APP_NAME,
98
- SESSION_SECRET_KEY: process.env.SESSION_SECRET_KEY,
99
- Main_SECRET_TOKEN: process.env.MAIN_SECRET_TOKEN,
100
- IS_DEPLOYED: process.env.IS_DEPLOYED,
101
- DOMAIN: process.env.DOMAIN,
102
- LOGIN_DB: process.env.LOGIN_DB,
103
- loginRedirectURL: '/dashboard'
104
- });
105
-
106
- // Optional shared configuration (useful for shared OAuth credentials across multiple projects)
107
- process.env.mbkauthShared = JSON.stringify({
108
- GITHUB_CLIENT_ID: process.env.GITHUB_CLIENT_ID,
109
- GITHUB_CLIENT_SECRET: process.env.GITHUB_CLIENT_SECRET,
110
- GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
111
- GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET
112
- });
113
-
114
- // MBKAuth prioritizes values in mbkautheVar, then mbkauthShared, then built-in defaults.
115
-
116
53
  const app = express();
117
-
118
- // Mount authentication routes
119
54
  app.use(mbkauthe);
120
55
 
121
- // Protected routes
122
- app.get('/dashboard', validateSession, (req, res) => {
123
- res.send(`Welcome ${req.session.user.username}!`);
124
- });
125
-
126
- app.get('/admin', validateSession, checkRolePermission(['SuperAdmin']), (req, res) => {
127
- res.send('Admin Panel');
128
- });
56
+ app.get('/dashboard', validateSession, (req, res) => res.send(`Welcome ${req.session.user.username}!`));
57
+ app.get('/admin', validateSession, checkRolePermission(['SuperAdmin']), (req, res) => res.send('Admin Panel'));
129
58
 
130
59
  app.listen(3000);
131
60
  ```
132
61
 
133
-
134
- ## 🧪 Testing & Git Hooks
135
-
136
- MBKAuthe includes comprehensive test coverage for all authentication features. **A pre-commit hook is provided to ensure code quality:**
137
-
138
- ### Pre-commit Hook (Automatic Test Runner)
139
-
140
- - Located at `scripts/pre-commit` and `scripts/pre-commit` (Node.js, cross-platform)
141
- - Starts the dev server, runs all tests, and blocks commits if any test fails
142
- - The dev server is automatically stopped after tests complete
143
- - Ensures you never commit code that breaks tests
144
-
145
- ### Git Hook Setup
146
-
147
- Hooks are auto-configured every time you run `npm run dev`, `npm test`, or `npm run test:watch` (see `scripts/setup-hooks.js`).
148
-
149
- If you ever need to manually set up hooks:
150
-
151
- ```bash
152
- node scripts/setup-hooks.js
153
- ```
154
-
155
- ### Running Tests
62
+ ## 🧪 Testing
156
63
 
157
64
  ```bash
158
- # Run all tests (auto-configures hooks)
159
65
  npm test
160
-
161
- # Run tests in watch mode (auto-configures hooks)
162
66
  npm run test:watch
163
-
164
- # Run with development flags (auto-configures hooks)
165
67
  npm run dev
166
68
  ```
167
69
 
168
- **Test Coverage:**
169
- - ✅ Authentication flows (login, 2FA, logout)
170
- - ✅ OAuth integration (GitHub)
171
- - ✅ Session management and security
172
- - ✅ Role-based access control
173
- - ✅ API endpoints and error handling
174
- - ✅ CSRF protection and rate limiting
175
- - ✅ Static asset serving
176
-
177
- ## 📂 Architecture (v3.0)
178
-
179
- ```
180
- lib/
181
- ├── config/ # Configuration & security
182
- ├── database/ # PostgreSQL pool
183
- ├── utils/ # Errors & response helpers
184
- ├── middleware/ # Auth & session middleware
185
- └── routes/ # Auth, OAuth, misc routes
186
- ```
187
-
188
- **Key Improvements in v3.0:**
189
- - Modular structure with clear separation of concerns
190
- - Organized config, database, utils, middleware, and routes
191
- - Better maintainability and scalability
192
-
193
70
  ## 🔧 Core API
194
71
 
195
- ### Middleware
196
-
197
- ```javascript
198
- // Session validation
199
- app.get('/protected', validateSession, handler);
200
-
201
- // Role checking
202
- app.get('/admin', validateSession, checkRolePermission(['SuperAdmin']), handler);
72
+ - **Session Validation:** `validateSession`
73
+ - **Role Check:** `checkRolePermission(['Role'])`
74
+ - **Combined:** `validateSessionAndRole(['SuperAdmin', 'NormalUser'])`
75
+ - **API Token Auth:** `authenticate(process.env.API_TOKEN)`
203
76
 
204
- // Combined
205
- import { validateSessionAndRole } from 'mbkauthe';
206
- app.get('/mod', validateSessionAndRole(['SuperAdmin', 'NormalUser']), handler);
207
-
208
- // API token auth
209
- import { authenticate } from 'mbkauthe';
210
- app.post('/api/data', authenticate(process.env.API_TOKEN), handler);
211
- ```
77
+ ## 🔐 Security
212
78
 
213
- ### Built-in Routes
214
-
215
- **Authentication Routes:**
216
- - `GET /login`, `/signin` - Redirect to main login page
217
- - `GET /mbkauthe/login` - Login page (8/min rate limit)
218
- - `POST /mbkauthe/api/login` - Login endpoint (8/min rate limit)
219
- - `POST /mbkauthe/api/logout` - Logout endpoint (10/min rate limit)
220
- - `GET /mbkauthe/2fa` - 2FA verification page (if enabled)
221
- - `POST /mbkauthe/api/verify-2fa` - 2FA verification API (5/min rate limit)
222
-
223
- **OAuth Routes:**
224
- - `GET /mbkauthe/api/github/login` - GitHub OAuth initiation (10/5min rate limit)
225
- - `GET /mbkauthe/api/github/login/callback` - GitHub OAuth callback
226
- - `GET /mbkauthe/api/google/login` - Google OAuth initiation (10/5min rate limit)
227
- - `GET /mbkauthe/api/google/login/callback` - Google OAuth callback
228
-
229
- **Information & Utility Routes:**
230
- - `GET /mbkauthe/info`, `/mbkauthe/i` - Version & config info (8/min rate limit)
231
- - `GET /mbkauthe/ErrorCode` - Error code documentation
232
- - `GET /mbkauthe/test` - Test authentication status (8/min rate limit)
233
-
234
- **Static Asset Routes:**
235
- - `GET /mbkauthe/main.js` - Client-side JavaScript utilities
236
- - `GET /mbkauthe/bg.webp` - Background image for auth pages
237
- - `GET /icon.svg` - Application SVG icon (root level)
238
- - `GET /favicon.ico`, `/icon.ico` - Application favicon
239
-
240
- **Admin API Routes:**
241
- - `POST /mbkauthe/api/terminateAllSessions` - Terminate all sessions (admin only)
242
-
243
- ## 🔐 Security Features
244
-
245
- - **Rate Limiting**: Login (8/min), Logout (10/min), 2FA (5/min), OAuth (10/5min), Admin (3/5min)
246
- - **CSRF Protection**: All state-changing routes protected with token validation
247
- - **Secure Cookies**: httpOnly, sameSite, secure in production
248
- - **Password Hashing**: PBKDF2 with 100k iterations
249
- - **Session Security**: PostgreSQL-backed, automatic cleanup, session fixation prevention
250
- - **OAuth Security**: State validation, token expiry handling, secure callback validation
79
+ - Rate limiting, CSRF protection, secure cookies
80
+ - Password hashing (PBKDF2, 100k iterations)
81
+ - PostgreSQL-backed sessions with automatic cleanup
82
+ - OAuth with state validation and secure callbacks
251
83
 
252
84
  ## 📱 Two-Factor Authentication
253
85
 
254
- Enable with `MBKAUTH_TWO_FA_ENABLE=true`:
255
-
256
- ```sql
257
- CREATE TABLE "TwoFA" (
258
- "UserName" VARCHAR(50) PRIMARY KEY REFERENCES "Users"("UserName"),
259
- "TwoFAStatus" BOOLEAN NOT NULL,
260
- "TwoFASecret" TEXT
261
- );
262
- ```
263
-
264
- Users can mark devices as trusted to skip 2FA for configurable duration.
86
+ Enable via `MBKAUTH_TWO_FA_ENABLE=true`. Trusted devices can skip 2FA for a set duration.
265
87
 
266
88
  ## 🔄 OAuth Integration
267
89
 
268
- ### GitHub OAuth
269
-
270
- **Setup:**
271
-
272
- 1. Create GitHub OAuth App with callback: `https://yourdomain.com/mbkauthe/api/github/login/callback`
273
- 2. Configure environment:
274
- ```env
275
- GITHUB_LOGIN_ENABLED=true
276
- GITHUB_CLIENT_ID=your_client_id
277
- GITHUB_CLIENT_SECRET=your_client_secret
278
- ```
279
- 3. Create table:
280
- ```sql
281
- CREATE TABLE user_github (
282
- id SERIAL PRIMARY KEY,
283
- user_name VARCHAR(50) REFERENCES "Users"("UserName"),
284
- github_id VARCHAR(255) UNIQUE,
285
- github_username VARCHAR(255),
286
- access_token VARCHAR(255),
287
- created_at TIMESTAMP DEFAULT NOW()
288
- );
289
- ```
290
-
291
- ### Google OAuth
292
-
293
- **Setup:**
294
-
295
- 1. Create Google OAuth 2.0 Client in [Google Cloud Console](https://console.cloud.google.com/)
296
- 2. Add authorized redirect URI: `https://yourdomain.com/mbkauthe/api/google/login/callback`
297
- 3. Configure environment:
298
- ```env
299
- GOOGLE_LOGIN_ENABLED=true
300
- GOOGLE_CLIENT_ID=your_client_id
301
- GOOGLE_CLIENT_SECRET=your_client_secret
302
- ```
303
- 4. Create table:
304
- ```sql
305
- CREATE TABLE user_google (
306
- id SERIAL PRIMARY KEY,
307
- user_name VARCHAR(50) REFERENCES "Users"("UserName"),
308
- google_id VARCHAR(255) UNIQUE,
309
- google_email VARCHAR(255),
310
- access_token VARCHAR(255),
311
- created_at TIMESTAMP DEFAULT NOW()
312
- );
313
- ```
314
-
315
- **Note:** Users must link their OAuth accounts before they can use OAuth login.
90
+ **GitHub / Google OAuth:** Configure apps and credentials via `.env` or `mbkautheVar`. Users must link accounts before login.
316
91
 
317
92
  ## 🎨 Customization
318
93
 
319
- **Redirect URL:**
320
- ```javascript
321
- process.env.mbkautheVar = JSON.stringify({
322
- // ...
323
- loginRedirectURL: '/dashboard'
324
- });
325
- ```
326
-
327
- **Custom Views:** Create in `views/` directory:
328
- - `loginmbkauthe.handlebars` - Login page
329
- - `2fa.handlebars` - 2FA page
330
- - `Error/dError.handlebars` - Error page
331
-
332
- **Database Access:**
333
- ```javascript
334
- import { dblogin } from 'mbkauthe';
335
- const result = await dblogin.query('SELECT * FROM "Users"');
336
- ```
94
+ - **Redirect URL:** `mbkautheVar={"loginRedirectURL":"/dashboard"}`
95
+ - **Custom Views:** `views/loginmbkauthe.handlebars`, `2fa.handlebars`, `Error/dError.handlebars`
96
+ - **Database Access:** `import { dblogin } from 'mbkauthe'; const result = await dblogin.query('SELECT * FROM "Users"');`
337
97
 
338
98
  ## 🚢 Deployment
339
99
 
340
- **Production Checklist:**
341
- - ✅ Set `IS_DEPLOYED=true`
342
- - Use strong secrets for SESSION_SECRET_KEY and Main_SECRET_TOKEN
343
- - Enable HTTPS
344
- - Configure correct DOMAIN
345
- - Set appropriate COOKIE_EXPIRE_TIME
346
- - ✅ Use environment variables for all secrets
347
-
348
- **Vercel:**
349
-
350
- Tip: On Vercel you can set `mbkauthShared` at the project or team level to share common OAuth credentials across multiple deployments. MBKAuth will use values from `mbkautheVar` first and fall back to `mbkauthShared`.
351
- ```json
352
- {
353
- "version": 2,
354
- "builds": [{ "src": "index.js", "use": "@vercel/node" }],
355
- "routes": [{ "src": "/(.*)", "dest": "/index.js" }]
356
- }
357
- ```
100
+ Checklist for production:
101
+ - `IS_DEPLOYED=true`
102
+ - Strong secrets for SESSION_SECRET_KEY & Main_SECRET_TOKEN
103
+ - HTTPS enabled
104
+ - Correct DOMAIN & COOKIE_EXPIRE_TIME
105
+ - Use environment variables for all secrets
106
+
107
+ **Vercel:** Supports shared OAuth credentials via `mbkauthShared`.
358
108
 
359
109
  ## 📚 Documentation
360
110
 
361
- - [API Documentation](docs/api.md) - Complete API reference
362
- - [Database Guide](docs/db.md) - Schema details
363
- - [Environment Config](docs/env.md) - Configuration options
364
- - [Error Messages](docs/error-messages.md) - Error code reference
111
+ - [API Reference](docs/api.md)
112
+ - [Database Schema](docs/db.md)
113
+ - [Environment Config](docs/env.md)
114
+ - [Error Codes](docs/error-messages.md)
365
115
 
366
116
  ## 📝 License
367
117
 
368
- GNU General Public License v2.0 - see [LICENSE](LICENSE)
118
+ GPL v2.0 see [LICENSE](LICENSE)
369
119
 
370
120
  ## 👨‍💻 Author
371
121
 
372
122
  **Muhammad Bin Khalid**
373
123
  📧 [support@mbktech.org](mailto:support@mbktech.org) | [chmuhammadbinkhalid28@gmail.com](mailto:chmuhammadbinkhalid28@gmail.com)
374
- 🔗 [@MIbnEKhalid](https://github.com/MIbnEKhalid)
124
+ 🔗 [GitHub @MIbnEKhalid](https://github.com/MIbnEKhalid)
375
125
 
376
126
  ## 🔗 Links
377
127
 
378
- - [npm Package](https://www.npmjs.com/package/mbkauthe)
379
- - [GitHub Repository](https://github.com/MIbnEKhalid/mbkauthe)
380
- - [Issues & Support](https://github.com/MIbnEKhalid/mbkauthe/issues)
128
+ - [npm](https://www.npmjs.com/package/mbkauthe)
129
+ - [GitHub](https://github.com/MIbnEKhalid/mbkauthe)
130
+ - [Support](https://github.com/MIbnEKhalid/mbkauthe/issues)
381
131
 
382
132
  ---
383
133
 
package/docs/api.md CHANGED
@@ -38,7 +38,7 @@ When a user logs in, MBKAuthe creates a session and sets the following cookies:
38
38
  | Cookie Name | Description | HttpOnly | Secure | SameSite |
39
39
  |------------|-------------|----------|--------|----------|
40
40
  | `mbkauthe.sid` | Session identifier | ✓ | Auto* | lax |
41
- | `sessionId` | User session ID | ✓ | Auto* | lax |
41
+ | `sessionId` | Opaque session token (backed by `Sessions` table) | ✓ | Auto* | lax |
42
42
  | `username` | Username | ✗ | Auto* | lax |
43
43
 
44
44
  \* `secure` flag is automatically set to `true` in production when `IS_DEPLOYED=true`
@@ -46,7 +46,7 @@ When a user logs in, MBKAuthe creates a session and sets the following cookies:
46
46
  ### Session Lifetime
47
47
 
48
48
  - Default: 2 days (configurable via `COOKIE_EXPIRE_TIME`)
49
- - Sessions are stored in PostgreSQL
49
+ - Application sessions are stored in the `Sessions` table in PostgreSQL
50
50
  - Sessions persist across subdomains in production
51
51
 
52
52
  ---
package/docs/db.md CHANGED
@@ -170,11 +170,6 @@ GITHUB_CLIENT_SECRET=your_github_client_secret
170
170
 
171
171
  The GitHub login feature is now fully integrated into your mbkauthe system and ready to use!
172
172
 
173
-
174
-
175
-
176
-
177
-
178
173
  ## Database structure
179
174
 
180
175
  [<- Back](README.md)
@@ -198,7 +193,7 @@ The GitHub login feature is now fully integrated into your mbkauthe system and r
198
193
  - `Role` (ENUM): The role of the user. Possible values: `SuperAdmin`, `NormalUser`, `Guest`.
199
194
  - `Active` (BOOLEAN): Indicates whether the user account is active.
200
195
  - `HaveMailAccount` (BOOLEAN)(optional): Indicates if the user has a linked mail account.
201
- - `SessionId` (TEXT): The session ID associated with the user.
196
+ - (SessionId removed) The application now stores multiple concurrent sessions in the `Sessions` table.
202
197
  - `GuestRole` (JSONB): Stores additional guest-specific role information in binary JSON format.
203
198
  - `AllowedApps`(JSONB): Array of applications the user is authorized to access.
204
199
 
@@ -215,19 +210,32 @@ CREATE TABLE "Users" (
215
210
  "Active" BOOLEAN DEFAULT FALSE,
216
211
  "HaveMailAccount" BOOLEAN DEFAULT FALSE,
217
212
  "AllowedApps" JSONB DEFAULT '["mbkauthe", "portal"]',
218
- "SessionId" VARCHAR(213),
219
213
  "created_at" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
220
214
  "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
221
215
  "last_login" TIMESTAMP WITH TIME ZONE
222
216
  );
223
217
 
224
218
  -- Add indexes for performance optimization
225
- CREATE INDEX IF NOT EXISTS idx_users_sessionid ON "Users" (LOWER("SessionId"));
226
219
  CREATE INDEX IF NOT EXISTS idx_users_username ON "Users" ("UserName");
227
220
  CREATE INDEX IF NOT EXISTS idx_users_active ON "Users" ("Active");
228
221
  CREATE INDEX IF NOT EXISTS idx_users_role ON "Users" ("Role");
229
222
  CREATE INDEX IF NOT EXISTS idx_users_last_login ON "Users" (last_login);
230
- CREATE INDEX IF NOT EXISTS idx_users_id_sessionid_active_role ON "Users" ("id", LOWER("SessionId"), "Active", "Role");
223
+
224
+ -- Application Sessions table (stores multiple concurrent sessions per user)
225
+ -- Note: this is separate from the express-session store table named "session"
226
+ CREATE TABLE "Sessions" (
227
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- requires pgcrypto or uuid-ossp
228
+ "UserName" VARCHAR(50) NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
229
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
230
+ expires_at TIMESTAMP WITH TIME ZONE,
231
+ meta JSONB
232
+ );
233
+
234
+ -- Indexes optimized by username instead of numeric user id
235
+ CREATE INDEX IF NOT EXISTS idx_sessions_username ON "Sessions" ("UserName");
236
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_created ON "Sessions" ("UserName", created_at);
237
+
238
+ **Multi-session behavior:** MBKAuthe supports multiple concurrent application sessions per user. The maximum number of concurrent sessions is controlled by `mbkautheVar.MAX_SESSIONS_PER_USER` (default: 5). When a new session would exceed that limit, the system prunes the oldest session(s) for that user (ordered by `created_at`) to keep the count within the configured maximum.
231
239
  ```
232
240
 
233
241
  **Password Storage Notes:**
@@ -250,12 +258,10 @@ CREATE TABLE "session" (
250
258
  sid VARCHAR(33) PRIMARY KEY NOT NULL,
251
259
  sess JSONB NOT NULL,
252
260
  expire TimeStamp WITH TIME ZONE Not Null,
253
- last_activity TimeStamp WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
254
261
  );
255
262
 
256
263
  -- Add indexes for performance optimization
257
264
  CREATE INDEX IF NOT EXISTS idx_session_expire ON "session" ("expire");
258
- CREATE INDEX IF NOT EXISTS idx_session_last_activity ON "session" (last_activity);
259
265
  CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session" ((sess->'user'->>'id'));
260
266
  ```
261
267
 
@@ -286,7 +292,7 @@ CREATE INDEX IF NOT EXISTS idx_twofa_username_status ON "TwoFA" ("UserName", "Tw
286
292
 
287
293
  - `id` (INTEGER, auto-increment, primary key): Unique identifier for each trusted device.
288
294
  - `UserName` (VARCHAR): The username of the device owner (foreign key to Users).
289
- - `DeviceToken` (VARCHAR): Unique token identifying the trusted device.
295
+ - `DeviceToken` (VARCHAR): **HMAC-SHA256 hash** of the device token (raw token is only sent to the client in an httpOnly cookie and **not** stored in plaintext).
290
296
  - `DeviceName` (VARCHAR): Optional friendly name for the device.
291
297
  - `UserAgent` (TEXT): Browser/client user agent string.
292
298
  - `IpAddress` (VARCHAR): IP address when device was trusted.
@@ -320,22 +326,22 @@ To add new users to the `Users` table, use the following SQL queries:
320
326
 
321
327
  **For Raw Password Storage (EncPass=false):**
322
328
  ```sql
323
- INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount", "SessionId", "GuestRole")
324
- VALUES ('support', '12345678', 'SuperAdmin', true, false, NULL, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
329
+ INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount", "GuestRole")
330
+ VALUES ('support', '12345678', 'SuperAdmin', true, false, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
325
331
 
326
- INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount", "SessionId", "GuestRole")
327
- VALUES ('test', '12345678', 'NormalUser', true, false, NULL, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
332
+ INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount", "GuestRole")
333
+ VALUES ('test', '12345678', 'NormalUser', true, false, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
328
334
  ```
329
335
 
330
336
  **For Encrypted Password Storage (EncPass=true):**
331
337
  ```sql
332
338
  -- Note: You'll need to hash the password using the hashPassword function
333
339
  -- Example with pre-hashed password (PBKDF2 with username as salt)
334
- INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "SessionId", "GuestRole")
335
- VALUES ('support', 'your_hashed_password_here', 'SuperAdmin', true, false, NULL, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
340
+ INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "GuestRole")
341
+ VALUES ('support', 'your_hashed_password_here', 'SuperAdmin', true, false, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
336
342
 
337
- INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "SessionId", "GuestRole")
338
- VALUES ('test', 'your_hashed_password_here', 'NormalUser', true, false, NULL, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
343
+ INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "GuestRole")
344
+ VALUES ('test', 'your_hashed_password_here', 'NormalUser', true, false, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
339
345
  ```
340
346
 
341
347
  **Configuration Notes:**
package/docs/db.sql ADDED
@@ -0,0 +1,116 @@
1
+
2
+ -- GitHub users table
3
+ CREATE TABLE user_github (
4
+ id SERIAL PRIMARY KEY,
5
+ user_name VARCHAR(50) REFERENCES "Users"("UserName"),
6
+ github_id VARCHAR(255) UNIQUE,
7
+ github_username VARCHAR(255),
8
+ access_token TEXT,
9
+ created_at TimeStamp WITH TIME ZONE DEFAULT NOW(),
10
+ updated_at TimeStamp WITH TIME ZONE DEFAULT NOW()
11
+ );
12
+
13
+ -- Add indexes for performance optimization
14
+ CREATE INDEX IF NOT EXISTS idx_user_github_github_id ON user_github (github_id);
15
+ CREATE INDEX IF NOT EXISTS idx_user_github_user_name ON user_github (user_name);
16
+
17
+ -- Google users table
18
+ CREATE TABLE user_google (
19
+ id SERIAL PRIMARY KEY,
20
+ user_name VARCHAR(50) REFERENCES "Users"("UserName"),
21
+ google_id VARCHAR(255) UNIQUE,
22
+ google_email VARCHAR(255),
23
+ access_token TEXT,
24
+ created_at TimeStamp WITH TIME ZONE DEFAULT NOW(),
25
+ updated_at TimeStamp WITH TIME ZONE DEFAULT NOW()
26
+ );
27
+
28
+ -- Add indexes for performance optimization
29
+ CREATE INDEX IF NOT EXISTS idx_user_google_google_id ON user_google (google_id);
30
+ CREATE INDEX IF NOT EXISTS idx_user_google_user_name ON user_google (user_name);
31
+
32
+
33
+
34
+
35
+ CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest');
36
+
37
+ CREATE TABLE "Users" (
38
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ "UserName" VARCHAR(50) NOT NULL UNIQUE,
40
+ "Password" VARCHAR(61), -- For raw passwords (when EncPass=false)
41
+ "PasswordEnc" VARCHAR(128), -- For encrypted passwords (when EncPass=true)
42
+ "Role" role DEFAULT 'NormalUser' NOT NULL,
43
+ "Active" BOOLEAN DEFAULT FALSE,
44
+ "HaveMailAccount" BOOLEAN DEFAULT FALSE,
45
+ "AllowedApps" JSONB DEFAULT '["mbkauthe", "portal"]',
46
+ "created_at" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
47
+ "updated_at" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
48
+ "last_login" TIMESTAMP WITH TIME ZONE
49
+ );
50
+
51
+ -- Add indexes for performance optimization
52
+ CREATE INDEX IF NOT EXISTS idx_users_username ON "Users" ("UserName");
53
+ CREATE INDEX IF NOT EXISTS idx_users_active ON "Users" ("Active");
54
+ CREATE INDEX IF NOT EXISTS idx_users_role ON "Users" ("Role");
55
+ CREATE INDEX IF NOT EXISTS idx_users_last_login ON "Users" (last_login);
56
+
57
+ -- Application Sessions table (stores multiple concurrent sessions per user)
58
+ -- Note: this is separate from the express-session store table named "session"
59
+ CREATE TABLE "Sessions" (
60
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- requires pgcrypto or uuid-ossp
61
+ "UserName" VARCHAR(50) NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
62
+ created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
63
+ expires_at TIMESTAMP WITH TIME ZONE,
64
+ meta JSONB
65
+ );
66
+
67
+ -- Indexes optimized by username instead of numeric user id
68
+ CREATE INDEX IF NOT EXISTS idx_sessions_username ON "Sessions" ("UserName");
69
+ CREATE INDEX IF NOT EXISTS idx_sessions_user_created ON "Sessions" ("UserName", created_at);
70
+
71
+
72
+ CREATE TABLE "session" (
73
+ sid VARCHAR(33) PRIMARY KEY NOT NULL,
74
+ sess JSONB NOT NULL,
75
+ expire TimeStamp WITH TIME ZONE Not Null,
76
+ );
77
+
78
+ -- Add indexes for performance optimization
79
+ CREATE INDEX IF NOT EXISTS idx_session_expire ON "session" ("expire");
80
+ CREATE INDEX IF NOT EXISTS idx_session_user_id ON "session" ((sess->'user'->>'id'));
81
+
82
+
83
+
84
+ CREATE TABLE "TwoFA" (
85
+ "UserName" VARCHAR(50) primary key REFERENCES "Users"("UserName"),
86
+ "TwoFAStatus" boolean NOT NULL,
87
+ "TwoFASecret" TEXT
88
+ );
89
+
90
+ -- Add indexes for performance optimization
91
+ CREATE INDEX IF NOT EXISTS idx_twofa_username ON "TwoFA" ("UserName");
92
+ CREATE INDEX IF NOT EXISTS idx_twofa_username_status ON "TwoFA" ("UserName", "TwoFAStatus");
93
+
94
+
95
+
96
+ CREATE TABLE "TrustedDevices" (
97
+ "id" SERIAL PRIMARY KEY,
98
+ "UserName" VARCHAR(50) NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
99
+ "DeviceToken" VARCHAR(64) UNIQUE NOT NULL,
100
+ "DeviceName" VARCHAR(255),
101
+ "UserAgent" TEXT,
102
+ "IpAddress" VARCHAR(45),
103
+ "CreatedAt" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
104
+ "ExpiresAt" TIMESTAMP WITH TIME ZONE NOT NULL,
105
+ "LastUsed" TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
106
+ );
107
+
108
+ -- Add indexes for performance optimization
109
+ CREATE INDEX IF NOT EXISTS idx_trusted_devices_token ON "TrustedDevices"("DeviceToken");
110
+ CREATE INDEX IF NOT EXISTS idx_trusted_devices_username ON "TrustedDevices"("UserName");
111
+ CREATE INDEX IF NOT EXISTS idx_trusted_devices_expires ON "TrustedDevices"("ExpiresAt");
112
+
113
+
114
+ -- No Encrypted password for 'support' user
115
+ INSERT INTO "Users" ("UserName", "Password", "Role", "Active", "HaveMailAccount", "GuestRole")
116
+ VALUES ('support', '12345678', 'SuperAdmin', true, false, '{"allowPages": [""], "NotallowPages": [""]}'::jsonb);
package/docs/env.md CHANGED
@@ -42,6 +42,7 @@ This document describes the environment variables MBKAuth expects and keeps brie
42
42
  - Description: PostgreSQL connection string for auth (must start with `postgresql://` or `postgres://`).
43
43
  - Example: `"LOGIN_DB":"postgresql://user:pass@localhost:5432/mbkauth"`
44
44
  - Required: Yes
45
+ - Create free postgres db: https://neon.com/
45
46
 
46
47
  - MBKAUTH_TWO_FA_ENABLE
47
48
  - Description: Enable Two-Factor Authentication.
@@ -67,6 +68,13 @@ This document describes the environment variables MBKAuth expects and keeps brie
67
68
  - Example: `"DEVICE_TRUST_DURATION_DAYS":30`
68
69
  - Required: No
69
70
 
71
+ - MAX_SESSIONS_PER_USER
72
+ - Description: Maximum number of concurrent application sessions allowed per user. When creating a new session that would exceed this number, the oldest session(s) for that user are pruned to make room for the new session.
73
+ - Default: `5`
74
+ - Example: `"MAX_SESSIONS_PER_USER": 10`
75
+ - Notes: Must be a positive integer. Validation is performed at startup by `lib/config/index.js`.
76
+ - Required: No
77
+
70
78
  - loginRedirectURL
71
79
  - Description: Post-login redirect path.
72
80
  - Default: `/dashboard`
@@ -81,6 +89,8 @@ This document describes the environment variables MBKAuth expects and keeps brie
81
89
  - GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET / GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
82
90
  - Description: OAuth credentials (put in `mbkautheVar` preferred, or `mbkauthShared`).
83
91
  - Required when provider enabled.
92
+ - Create Github OAuth App: https://github.com/settings/developers
93
+ - Create Google OAuth: https://console.cloud.google.com/
84
94
 
85
95
  ---
86
96
 
package/index.d.ts CHANGED
@@ -23,7 +23,7 @@ declare global {
23
23
  username: string;
24
24
  fullname?: string;
25
25
  role: 'SuperAdmin' | 'NormalUser' | 'Guest';
26
- sessionId: string;
26
+ sessionId?: string;
27
27
  allowedApps?: string[];
28
28
  };
29
29
  preAuthUser?: {
@@ -79,7 +79,7 @@ declare module 'mbkauthe' {
79
79
  username: string;
80
80
  fullname?: string;
81
81
  role: UserRole;
82
- sessionId: string;
82
+ sessionId?: string;
83
83
  allowedApps?: string[];
84
84
  }
85
85
 
@@ -101,7 +101,7 @@ declare module 'mbkauthe' {
101
101
  Role: UserRole;
102
102
  Active: boolean;
103
103
  AllowedApps: string[];
104
- SessionId?: string;
104
+
105
105
  created_at?: Date;
106
106
  updated_at?: Date;
107
107
  last_login?: Date;
@@ -32,6 +32,12 @@ export const generateDeviceToken = () => {
32
32
  return crypto.randomBytes(32).toString('hex');
33
33
  };
34
34
 
35
+ // Hash a device token for safe storage in the database
36
+ export const hashDeviceToken = (token) => {
37
+ if (!token || typeof token !== 'string') return null;
38
+ return crypto.createHmac('sha256').update(token).digest('hex');
39
+ };
40
+
35
41
  export const getDeviceTokenCookieOptions = () => ({
36
42
  maxAge: DEVICE_TRUST_DURATION_MS,
37
43
  domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
@@ -62,9 +62,10 @@ function validateConfiguration() {
62
62
 
63
63
  // Ensure specific keys are checked in mbkautheVar first, then mbkauthShared, then apply config defaults
64
64
  const keysToCheck = [
65
- "APP_NAME","DEVICE_TRUST_DURATION_DAYS","EncPass","Main_SECRET_TOKEN","SESSION_SECRET_KEY","IS_DEPLOYED",
66
- "LOGIN_DB","MBKAUTH_TWO_FA_ENABLE","COOKIE_EXPIRE_TIME","DOMAIN","loginRedirectURL",
67
- "GITHUB_LOGIN_ENABLED","GITHUB_CLIENT_ID","GITHUB_CLIENT_SECRET","GOOGLE_LOGIN_ENABLED","GOOGLE_CLIENT_ID","GOOGLE_CLIENT_SECRET"
65
+ "APP_NAME","DEVICE_TRUST_DURATION_DAYS","EncPass","Main_SECRET_TOKEN","SESSION_SECRET_KEY",
66
+ "IS_DEPLOYED","LOGIN_DB","MBKAUTH_TWO_FA_ENABLE","COOKIE_EXPIRE_TIME","DOMAIN","loginRedirectURL",
67
+ "GITHUB_LOGIN_ENABLED","GITHUB_CLIENT_ID","GITHUB_CLIENT_SECRET","GOOGLE_LOGIN_ENABLED","GOOGLE_CLIENT_ID",
68
+ "GOOGLE_CLIENT_SECRET","MAX_SESSIONS_PER_USER"
68
69
  ];
69
70
 
70
71
  const defaults = {
@@ -75,7 +76,8 @@ function validateConfiguration() {
75
76
  COOKIE_EXPIRE_TIME: 2,
76
77
  loginRedirectURL: '/dashboard',
77
78
  GITHUB_LOGIN_ENABLED: 'false',
78
- GOOGLE_LOGIN_ENABLED: 'false'
79
+ GOOGLE_LOGIN_ENABLED: 'false',
80
+ MAX_SESSIONS_PER_USER: 5
79
81
  };
80
82
 
81
83
  keysToCheck.forEach(key => {
@@ -183,6 +185,20 @@ function validateConfiguration() {
183
185
  mbkautheVar.DEVICE_TRUST_DURATION_DAYS = 7;
184
186
  }
185
187
 
188
+ // Validate MAX_SESSIONS_PER_USER if provided (must be positive integer)
189
+ if (mbkautheVar.MAX_SESSIONS_PER_USER !== undefined) {
190
+ const maxSessions = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
191
+ if (isNaN(maxSessions) || maxSessions <= 0) {
192
+ errors.push("mbkautheVar.MAX_SESSIONS_PER_USER must be a valid positive integer");
193
+ } else {
194
+ // Normalize to integer
195
+ mbkautheVar.MAX_SESSIONS_PER_USER = maxSessions;
196
+ }
197
+ } else {
198
+ // Ensure default value is set
199
+ mbkautheVar.MAX_SESSIONS_PER_USER = 5;
200
+ }
201
+
186
202
  // Validate LOGIN_DB connection string format
187
203
  if (mbkautheVar.LOGIN_DB && !mbkautheVar.LOGIN_DB.startsWith('postgresql://') && !mbkautheVar.LOGIN_DB.startsWith('postgres://')) {
188
204
  errors.push("mbkautheVar.LOGIN_DB must be a valid PostgreSQL connection string");
@@ -5,4 +5,4 @@ export const hashPassword = (password, username) => {
5
5
  const salt = username;
6
6
  // 128 characters returned
7
7
  return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
8
- };
8
+ };
@@ -33,19 +33,18 @@ async function validateSession(req, res, next) {
33
33
  });
34
34
  }
35
35
 
36
- // Normalize sessionId to lowercase for consistent comparison
37
- const normalizedSessionId = sessionId.toLowerCase();
36
+ // Normalize sessionId (DB id) for consistent comparison
37
+ const normalizedSessionId = sessionId;
38
38
 
39
- // Single optimized query to validate session and get role
40
- const query = `SELECT "SessionId", "Active", "Role" FROM "Users" WHERE "id" = $1`;
41
- const result = await dblogin.query({ name: 'validate-user-session', text: query, values: [id] });
39
+ // Validate session by DB primary key id and join to user
40
+ const query = `SELECT s.id as sid, s.expires_at, u."Active", u."Role"
41
+ FROM "Sessions" s
42
+ JOIN "Users" u ON s."UserName" = u."UserName"
43
+ WHERE s.id = $1 LIMIT 1`;
44
+ const result = await dblogin.query({ name: 'validate-app-session', text: query, values: [normalizedSessionId] });
42
45
 
43
- const dbSessionId = result.rows.length > 0 && result.rows[0].SessionId ? String(result.rows[0].SessionId).toLowerCase() : null;
44
- if (!dbSessionId || dbSessionId !== normalizedSessionId) {
45
- if (result.rows.length > 0 && !result.rows[0].SessionId) {
46
- console.warn(`[mbkauthe] DB sessionId is null for user "${req.session.user.username}"`);
47
- }
48
- console.log(`[mbkauthe] Session invalidated for user "${req.session.user.username}"`);
46
+ if (result.rows.length === 0) {
47
+ console.log(`[mbkauthe] Session not found for user "${req.session.user.username}"`);
49
48
  req.session.destroy();
50
49
  clearSessionCookies(res);
51
50
  return renderError(res, {
@@ -57,7 +56,25 @@ async function validateSession(req, res, next) {
57
56
  });
58
57
  }
59
58
 
60
- if (!result.rows[0].Active) {
59
+ const sessionRow = result.rows[0];
60
+
61
+ // Check expired
62
+ if (sessionRow.expires_at && new Date(sessionRow.expires_at) <= new Date()) {
63
+ console.log(`[mbkauthe] Session invalidated (expired) for user "${req.session.user.username}"`);
64
+ // destroy and clear cookies
65
+ req.session.destroy();
66
+ clearSessionCookies(res);
67
+ return renderError(res, {
68
+ code: 401,
69
+ error: "Session Expired",
70
+ message: "Your Session Has Expired. Please Log In Again.",
71
+ pagename: "Login",
72
+ page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
73
+ });
74
+ }
75
+
76
+
77
+ if (!sessionRow.Active) {
61
78
  console.log(`[mbkauthe] Account is inactive for user "${req.session.user.username}"`);
62
79
  req.session.destroy();
63
80
  clearSessionCookies(res);
@@ -117,24 +134,32 @@ async function validateApiSession(req, res, next) {
117
134
  return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
118
135
  }
119
136
 
120
- // Normalize sessionId to lowercase for consistent comparison
121
- const normalizedSessionId = sessionId.toLowerCase();
137
+ // Normalize sessionId (DB id) for consistent comparison
138
+ const normalizedSessionId = sessionId;
122
139
 
123
- // Single optimized query to validate session and get role
124
- const query = `SELECT "SessionId", "Active", "Role" FROM "Users" WHERE "id" = $1`;
125
- const result = await dblogin.query({ name: 'validate-user-session-for-api', text: query, values: [id] });
140
+ // Validate session by DB primary key id and join to user
141
+ const query = `SELECT s.id as sid, s.expires_at, u."Active", u."Role"
142
+ FROM "Sessions" s
143
+ JOIN "Users" u ON s."UserName" = u."UserName"
144
+ WHERE s.id = $1 LIMIT 1`;
145
+ const result = await dblogin.query({ name: 'validate-app-session-for-api', text: query, values: [normalizedSessionId] });
126
146
 
127
- const dbSessionId = result.rows.length > 0 && result.rows[0].SessionId ? String(result.rows[0].SessionId).toLowerCase() : null;
128
- if (!dbSessionId || dbSessionId !== normalizedSessionId) {
129
- if (result.rows.length > 0 && !result.rows[0].SessionId) {
130
- console.warn(`[mbkauthe] DB sessionId is null for user "${req.session.user.username}"`);
131
- }
132
- console.log(`[mbkauthe] Session invalidated for user "${req.session.user.username}"`);
147
+ if (result.rows.length === 0) {
133
148
  req.session.destroy();
134
149
  clearSessionCookies(res);
135
150
  return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_INVALID));
136
151
  }
137
152
 
153
+ const sessionRow = result.rows[0];
154
+
155
+ // Check expired
156
+ if (sessionRow.expires_at && new Date(sessionRow.expires_at) <= new Date()) {
157
+ req.session.destroy();
158
+ clearSessionCookies(res);
159
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
160
+ }
161
+
162
+
138
163
  if (!result.rows[0].Active) {
139
164
  console.log(`[mbkauthe] Account is inactive for user "${req.session.user.username}"`);
140
165
  req.session.destroy();
@@ -176,21 +201,30 @@ export async function reloadSessionUser(req, res) {
176
201
  try {
177
202
  const { id, sessionId: currentSessionId } = req.session.user;
178
203
 
179
- // Fetch fresh user record
180
- const query = `SELECT id, "UserName", "Active", "Role", "AllowedApps", "SessionId" FROM "Users" WHERE id = $1`;
181
- const result = await dblogin.query({ name: 'reload-session-user', text: query, values: [id] });
204
+ if (!currentSessionId) {
205
+ req.session.destroy(() => {});
206
+ clearSessionCookies(res);
207
+ return false;
208
+ }
209
+
210
+ const normalizedSessionId = String(currentSessionId);
211
+ const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
212
+ FROM "Sessions" s
213
+ JOIN "Users" u ON s."UserName" = u."UserName"
214
+ WHERE s.id = $1 LIMIT 1`;
215
+ const result = await dblogin.query({ name: 'reload-session-user', text: query, values: [normalizedSessionId] });
182
216
 
183
217
  if (result.rows.length === 0) {
184
- // User not found — invalidate session
218
+ // Session not found — invalidate session
185
219
  req.session.destroy(() => {});
186
220
  clearSessionCookies(res);
187
221
  return false;
188
222
  }
189
223
 
190
224
  const row = result.rows[0];
191
- const dbSessionId = row.SessionId ? String(row.SessionId).toLowerCase() : null;
192
- if (!dbSessionId || dbSessionId !== String(currentSessionId).toLowerCase()) {
193
- // Session invalidated in DB
225
+
226
+ // Check expired
227
+ if (row.expires_at && new Date(row.expires_at) <= new Date()) {
194
228
  req.session.destroy(() => {});
195
229
  clearSessionCookies(res);
196
230
  return false;
@@ -57,8 +57,8 @@ export async function sessionRestorationMiddleware(req, res, next) {
57
57
  if (!req.session.user && req.cookies.sessionId) {
58
58
  const sessionId = req.cookies.sessionId;
59
59
 
60
- // Early validation to avoid unnecessary processing
61
- if (typeof sessionId !== 'string' || !/^[a-f0-9]{64}$/i.test(sessionId)) {
60
+ // Early validation to avoid unnecessary processing (expect DB UUID id)
61
+ if (typeof sessionId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
62
62
  // Clear invalid cookie to prevent repeated attempts
63
63
  res.clearCookie('sessionId', {
64
64
  domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
@@ -71,37 +71,46 @@ export async function sessionRestorationMiddleware(req, res, next) {
71
71
  }
72
72
 
73
73
  try {
74
- const normalizedSessionId = sessionId.toLowerCase();
75
-
76
- const query = `SELECT id, "UserName", "Active", "Role", "SessionId", "AllowedApps" FROM "Users" WHERE LOWER("SessionId") = $1 AND "Active" = true`;
77
- const result = await dblogin.query({ name: 'restore-user-session', text: query, values: [normalizedSessionId] });
74
+ // Validate session by DB primary key id and join to user
75
+ const query = `SELECT u.id, u."UserName", u."Active", u."Role", u."AllowedApps", s.expires_at
76
+ FROM "Sessions" s
77
+ JOIN "Users" u ON s."UserName" = u."UserName"
78
+ WHERE s.id = $1 LIMIT 1`;
79
+ const result = await dblogin.query({ name: 'restore-user-session', text: query, values: [sessionId] });
78
80
 
79
81
  if (result.rows.length > 0) {
80
- const user = result.rows[0];
81
- req.session.user = {
82
- id: user.id,
83
- username: user.UserName,
84
- role: user.Role,
85
- sessionId: normalizedSessionId,
86
- allowedApps: user.AllowedApps,
87
- };
82
+ const row = result.rows[0];
88
83
 
89
- // Use cached FullName from client cookie when available to avoid extra DB queries
90
- if (req.cookies.fullName && typeof req.cookies.fullName === 'string') {
91
- req.session.user.fullname = req.cookies.fullName;
84
+ // Reject expired sessions or inactive users
85
+ if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
86
+ // leave cookies cleared and don't restore session
92
87
  } else {
93
- // Fallback: attempt to fetch FullName from profiledata to populate session
94
- try {
95
- const profileRes = await dblogin.query({
96
- name: 'restore-get-fullname',
97
- text: 'SELECT "FullName" FROM "profiledata" WHERE "UserName" = $1 LIMIT 1',
98
- values: [user.UserName]
99
- });
100
- if (profileRes.rows.length > 0 && profileRes.rows[0].FullName) {
101
- req.session.user.fullname = profileRes.rows[0].FullName;
88
+ const normalizedSessionId = String(sessionId);
89
+ req.session.user = {
90
+ id: row.id,
91
+ username: row.UserName,
92
+ role: row.Role,
93
+ sessionId: normalizedSessionId,
94
+ allowedApps: row.AllowedApps,
95
+ };
96
+
97
+ // Use cached FullName from client cookie when available to avoid extra DB queries
98
+ if (req.cookies.fullName && typeof req.cookies.fullName === 'string') {
99
+ req.session.user.fullname = req.cookies.fullName;
100
+ } else {
101
+ // Fallback: attempt to fetch FullName from profiledata to populate session
102
+ try {
103
+ const profileRes = await dblogin.query({
104
+ name: 'restore-get-fullname',
105
+ text: 'SELECT "FullName" FROM "profiledata" WHERE "UserName" = $1 LIMIT 1',
106
+ values: [row.UserName]
107
+ });
108
+ if (profileRes.rows.length > 0 && profileRes.rows[0].FullName) {
109
+ req.session.user.fullname = profileRes.rows[0].FullName;
110
+ }
111
+ } catch (profileErr) {
112
+ console.error("[mbkauthe] Error fetching FullName during session restore:", profileErr);
102
113
  }
103
- } catch (profileErr) {
104
- console.error("[mbkauthe] Error fetching FullName during session restore:", profileErr);
105
114
  }
106
115
  }
107
116
  }
@@ -7,7 +7,7 @@ import { dblogin } from "../database/pool.js";
7
7
  import { mbkautheVar } from "../config/index.js";
8
8
  import {
9
9
  cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
10
- generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS
10
+ generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS, hashDeviceToken
11
11
  } from "../config/cookies.js";
12
12
  import { packageJson } from "../config/index.js";
13
13
  import { hashPassword } from "../config/security.js";
@@ -63,6 +63,8 @@ export async function checkTrustedDevice(req, username) {
63
63
  }
64
64
 
65
65
  try {
66
+ // Hash the provided device token before querying DB (we store token hashes in DB)
67
+ const deviceTokenHash = hashDeviceToken(deviceToken);
66
68
  const deviceQuery = `
67
69
  SELECT td."UserName", td."LastUsed", td."ExpiresAt", u."id", u."Active", u."Role", u."AllowedApps"
68
70
  FROM "TrustedDevices" td
@@ -72,7 +74,7 @@ export async function checkTrustedDevice(req, username) {
72
74
  const deviceResult = await dblogin.query({
73
75
  name: 'check-trusted-device',
74
76
  text: deviceQuery,
75
- values: [deviceToken, username]
77
+ values: [deviceTokenHash, username]
76
78
  });
77
79
 
78
80
  if (deviceResult.rows.length > 0) {
@@ -95,7 +97,7 @@ export async function checkTrustedDevice(req, username) {
95
97
  await dblogin.query({
96
98
  name: 'update-device-last-used',
97
99
  text: 'UPDATE "TrustedDevices" SET "LastUsed" = NOW() WHERE "DeviceToken" = $1',
98
- values: [deviceToken]
100
+ values: [deviceTokenHash]
99
101
  });
100
102
 
101
103
  console.log(`[mbkauthe] Trusted device validated for user: ${username}`);
@@ -124,13 +126,9 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
124
126
  throw new Error('Username is required in user object');
125
127
  }
126
128
 
127
- // smaller session id is sufficient and faster to generate/serialize
128
- const sessionId = crypto.randomBytes(32).toString("hex");
129
- console.log(`[mbkauthe] Generated session ID for username: ${username}`);
130
-
131
129
  // Fix session fixation: Delete old session BEFORE regenerating to prevent timing window
132
130
  const oldSessionId = req.sessionID;
133
-
131
+
134
132
  // Delete old session first to prevent session fixation attacks
135
133
  await dblogin.query({
136
134
  name: 'login-delete-old-session-before-regen',
@@ -146,27 +144,50 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
146
144
  });
147
145
  });
148
146
 
149
- // Run both queries in parallel for better performance
150
- await Promise.all([
151
- // Delete old sessions using indexed lookup on sess->'user'->>'id'
152
- dblogin.query({
153
- name: 'login-delete-old-user-sessions',
154
- text: 'DELETE FROM "session" WHERE (sess->\'user\'->>\'id\')::int = $1',
155
- values: [user.id]
156
- }),
157
- // Update session ID and last login time in Users table
158
- dblogin.query({
159
- name: 'login-update-session-and-last-login',
160
- text: `UPDATE "Users" SET "SessionId" = $1, "last_login" = NOW() WHERE "id" = $2`,
161
- values: [sessionId, user.id]
162
- })
163
- ]);
147
+ // Enforce max sessions per user (configurable via mbkautheVar.MAX_SESSIONS_PER_USER) and persist a new application session record (keyed by username)
148
+ const configuredMax = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
149
+ const MAX_SESSIONS = Number.isInteger(configuredMax) && configuredMax > 0 ? configuredMax : 5;
150
+
151
+ // Count active sessions for this user (by username)
152
+ const countRes = await dblogin.query({
153
+ name: 'count-user-sessions',
154
+ text: `SELECT id FROM "Sessions" WHERE "UserName" = $1 AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at ASC`,
155
+ values: [username]
156
+ });
157
+
158
+ const currentSessions = countRes.rows.length;
159
+ if (currentSessions >= MAX_SESSIONS) {
160
+ console.log(`[mbkauthe] User "${username}" has ${currentSessions} active sessions, exceeding max of ${MAX_SESSIONS}. Pruning oldest sessions.`);
161
+ // prune the oldest session(s) to make room for the new one
162
+ await dblogin.query({
163
+ name: 'prune-oldest-user-session',
164
+ text: `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at ASC LIMIT $2)` ,
165
+ values: [username, (currentSessions - (MAX_SESSIONS - 1))]
166
+ });
167
+ }
168
+
169
+ const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0));
170
+
171
+ // Insert new session record for the user (store username) and return the DB id
172
+ const insertRes = await dblogin.query({
173
+ name: 'insert-app-session',
174
+ text: `INSERT INTO "Sessions" ("UserName", expires_at, meta) VALUES ($1, $2, $3) RETURNING id`,
175
+ values: [username, expiresAt, JSON.stringify({ ip: req.ip, ua: req.headers['user-agent'] || null })]
176
+ });
177
+ const dbSessionId = insertRes.rows[0].id;
178
+
179
+ // Update last_login timestamp for the user
180
+ await dblogin.query({
181
+ name: 'login-update-last-login',
182
+ text: `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1`,
183
+ values: [user.id]
184
+ });
164
185
 
165
186
  req.session.user = {
166
187
  id: user.id,
167
188
  username: username,
168
189
  role: user.role || user.Role,
169
- sessionId,
190
+ sessionId: dbSessionId,
170
191
  allowedApps: user.allowedApps || user.AllowedApps,
171
192
  };
172
193
 
@@ -194,11 +215,11 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
194
215
  return res.status(500).json({ success: false, message: "Internal Server Error" });
195
216
  }
196
217
 
197
- // Expose sessionId and display name to client for UI (fullName falls back to username)
198
- res.cookie("sessionId", sessionId, cachedCookieOptions);
218
+ // Expose DB session id and display name to client for UI (fullName falls back to username)
219
+ res.cookie("sessionId", dbSessionId, cachedCookieOptions);
199
220
  res.cookie("fullName", req.session.user.fullname || username, { ...cachedCookieOptions, httpOnly: false });
200
221
 
201
- // Handle trusted device if requested
222
+ // Handle trusted device if requested (token no longer stored in DB as token_hash)
202
223
  if (trustDevice) {
203
224
  try {
204
225
  const deviceToken = generateDeviceToken();
@@ -208,13 +229,16 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
208
229
  const ipAddress = req.ip || req.connection.remoteAddress || 'Unknown';
209
230
  const expiresAt = new Date(Date.now() + DEVICE_TRUST_DURATION_MS);
210
231
 
232
+ // Store only the HASH of the device token in DB; send the raw token to the client (httpOnly cookie)
233
+ const deviceTokenHash = hashDeviceToken(deviceToken);
211
234
  await dblogin.query({
212
235
  name: 'insert-trusted-device',
213
236
  text: `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
214
237
  VALUES ($1, $2, $3, $4, $5, $6)`,
215
- values: [username, deviceToken, deviceName, userAgent, ipAddress, expiresAt]
238
+ values: [username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt]
216
239
  });
217
240
 
241
+ // Send raw token to client as httpOnly cookie only
218
242
  res.cookie("device_token", deviceToken, getDeviceTokenCookieOptions());
219
243
  console.log(`[mbkauthe] Trusted device token created for user: ${username}`);
220
244
  } catch (deviceErr) {
@@ -228,7 +252,7 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
228
252
  const responsePayload = {
229
253
  success: true,
230
254
  message: "Login successful",
231
- sessionId,
255
+ sessionId: dbSessionId,
232
256
  };
233
257
 
234
258
  if (redirectUrl) {
@@ -506,15 +530,14 @@ router.post("/api/logout", LogoutLimit, async (req, res) => {
506
530
  try {
507
531
  const { id, username } = req.session.user;
508
532
 
509
- // Run both database operations in parallel
510
- const operations = [
511
- dblogin.query({ name: 'logout-clear-session', text: `UPDATE "Users" SET "SessionId" = NULL WHERE "id" = $1`, values: [id] })
512
- ];
533
+ // Remove the application session record for this token (if present)
534
+ const operations = [];
535
+ if (req.session && req.session.user && req.session.user.sessionId) {
536
+ operations.push(dblogin.query({ name: 'logout-delete-app-session', text: 'DELETE FROM "Sessions" WHERE id = $1', values: [req.session.user.sessionId] }));
537
+ }
513
538
 
514
539
  if (req.sessionID) {
515
- operations.push(
516
- dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] })
517
- );
540
+ operations.push(dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] }));
518
541
  }
519
542
 
520
543
  await Promise.all(operations);
@@ -79,10 +79,15 @@ router.get('/test', validateSession, LoginLimit, async (req, res) => {
79
79
  <p class="success">✅ Authentication successful! User is logged in.</p>
80
80
  <p>Welcome, <strong>${req.session.user.username}</strong>! Your role: <strong>${req.session.user.role}</strong></p>
81
81
  <div class="user-info">
82
+ Username: ${req.session.user.username}<br>
83
+ Role: ${req.session.user.role}<br>
84
+ Full Name: ${req.session.user.fullname || 'N/A'}<br>
82
85
  User ID: ${req.session.user.id}<br>
83
86
  Session ID: ${req.session.user.sessionId.slice(0, 5)}...
84
87
  </div>
85
88
  <button onclick="logout()">Logout</button>
89
+ <a href="https://portal.mbktech.org/">Web Portal</a>
90
+ <a href="https://portal.mbktech.org/user/settings">User Settings</a>
86
91
  <a href="/mbkauthe/info">Info Page</a>
87
92
  <a href="/mbkauthe/login">Login Page</a>
88
93
  </div>
@@ -104,19 +109,27 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
104
109
  return res.status(200).json({ sessionValid: false, expiry: null });
105
110
  }
106
111
 
107
- const normalizedSessionId = String(sessionId).toLowerCase();
108
- const result = await dblogin.query({ name: 'check-session-validity', text: 'SELECT "SessionId", "Active" FROM "Users" WHERE id = $1', values: [id] });
112
+ const result = await dblogin.query({ name: 'check-session-validity', text: `SELECT s.expires_at, u."Active" FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1`, values: [sessionId] });
109
113
 
110
- const dbSessionId = result.rows.length > 0 && result.rows[0].SessionId ? String(result.rows[0].SessionId).toLowerCase() : null;
111
- if (!dbSessionId || dbSessionId !== normalizedSessionId || !result.rows[0].Active) {
114
+ if (result.rows.length === 0) {
112
115
  req.session.destroy(() => { });
113
116
  clearSessionCookies(res);
114
117
  return res.status(200).json({ sessionValid: false, expiry: null });
115
118
  }
116
119
 
117
- // Fetch expiry timestamp from session table (connect-pg-simple)
118
- const sessResult = await dblogin.query({ name: 'get-session-expiry', text: 'SELECT expire FROM "session" WHERE sid = $1', values: [req.sessionID] });
119
- const expiry = sessResult.rows.length > 0 && sessResult.rows[0].expire ? new Date(sessResult.rows[0].expire).toISOString() : null;
120
+ const row = result.rows[0];
121
+ if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
122
+ req.session.destroy(() => { });
123
+ clearSessionCookies(res);
124
+ return res.status(200).json({ sessionValid: false, expiry: null });
125
+ }
126
+
127
+ // Determine expiry: prefer application session expiry if present else fallback to connect-pg-simple expire
128
+ let expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
129
+ if (!expiry) {
130
+ const sessResult = await dblogin.query({ name: 'get-session-expiry', text: 'SELECT expire FROM "session" WHERE sid = $1', values: [req.sessionID] });
131
+ expiry = sessResult.rows.length > 0 && sessResult.rows[0].expire ? new Date(sessResult.rows[0].expire).toISOString() : null;
132
+ }
120
133
 
121
134
  return res.status(200).json({ sessionValid: true, expiry });
122
135
  } catch (err) {
@@ -287,8 +300,8 @@ router.post("/api/terminateAllSessions", AdminOperationLimit, authenticate(mbkau
287
300
  // Run both operations in parallel for better performance
288
301
  await Promise.all([
289
302
  dblogin.query({
290
- name: 'terminate-all-user-sessions',
291
- text: 'UPDATE "Users" SET "SessionId" = NULL WHERE "SessionId" IS NOT NULL'
303
+ name: 'terminate-all-app-sessions',
304
+ text: 'DELETE FROM "Sessions"'
292
305
  }),
293
306
  dblogin.query({
294
307
  name: 'terminate-all-db-sessions',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "3.5.0",
3
+ "version": "4.0.0",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -167,8 +167,6 @@
167
167
  window.location.href = `/mbkauthe/2fa${redirectQuery}`;
168
168
  } else {
169
169
  loginButtonText.textContent = 'Success! Redirecting...';
170
- sessionStorage.setItem('sessionId', data.sessionId);
171
-
172
170
  if (rememberMe) {
173
171
  setCookie('rememberedUsername', username, 30); // 30 days
174
172
  } else {