mbkauthe 3.4.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.
@@ -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,30 +144,67 @@ 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
+ // Attempt to fetch FullName from profiledata and store it in session for display purposes
195
+ try {
196
+ const profileResult = await dblogin.query({
197
+ name: 'login-get-fullname',
198
+ text: 'SELECT "FullName" FROM "profiledata" WHERE "UserName" = $1 LIMIT 1',
199
+ values: [username]
200
+ });
201
+ if (profileResult.rows.length > 0 && profileResult.rows[0].FullName) {
202
+ req.session.user.fullname = profileResult.rows[0].FullName;
203
+ }
204
+ } catch (profileErr) {
205
+ console.error("[mbkauthe] Error fetching FullName for user:", profileErr);
206
+ }
207
+
173
208
  if (req.session.preAuthUser) {
174
209
  delete req.session.preAuthUser;
175
210
  }
@@ -180,9 +215,11 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
180
215
  return res.status(500).json({ success: false, message: "Internal Server Error" });
181
216
  }
182
217
 
183
- 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);
220
+ res.cookie("fullName", req.session.user.fullname || username, { ...cachedCookieOptions, httpOnly: false });
184
221
 
185
- // Handle trusted device if requested
222
+ // Handle trusted device if requested (token no longer stored in DB as token_hash)
186
223
  if (trustDevice) {
187
224
  try {
188
225
  const deviceToken = generateDeviceToken();
@@ -192,13 +229,16 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
192
229
  const ipAddress = req.ip || req.connection.remoteAddress || 'Unknown';
193
230
  const expiresAt = new Date(Date.now() + DEVICE_TRUST_DURATION_MS);
194
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);
195
234
  await dblogin.query({
196
235
  name: 'insert-trusted-device',
197
236
  text: `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
198
237
  VALUES ($1, $2, $3, $4, $5, $6)`,
199
- values: [username, deviceToken, deviceName, userAgent, ipAddress, expiresAt]
238
+ values: [username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt]
200
239
  });
201
240
 
241
+ // Send raw token to client as httpOnly cookie only
202
242
  res.cookie("device_token", deviceToken, getDeviceTokenCookieOptions());
203
243
  console.log(`[mbkauthe] Trusted device token created for user: ${username}`);
204
244
  } catch (deviceErr) {
@@ -212,7 +252,7 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
212
252
  const responsePayload = {
213
253
  success: true,
214
254
  message: "Login successful",
215
- sessionId,
255
+ sessionId: dbSessionId,
216
256
  };
217
257
 
218
258
  if (redirectUrl) {
@@ -490,15 +530,14 @@ router.post("/api/logout", LogoutLimit, async (req, res) => {
490
530
  try {
491
531
  const { id, username } = req.session.user;
492
532
 
493
- // Run both database operations in parallel
494
- const operations = [
495
- dblogin.query({ name: 'logout-clear-session', text: `UPDATE "Users" SET "SessionId" = NULL WHERE "id" = $1`, values: [id] })
496
- ];
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
+ }
497
538
 
498
539
  if (req.sessionID) {
499
- operations.push(
500
- dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] })
501
- );
540
+ operations.push(dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] }));
502
541
  }
503
542
 
504
543
  await Promise.all(operations);
@@ -3,7 +3,7 @@ import fetch from 'node-fetch';
3
3
  import rateLimit from 'express-rate-limit';
4
4
  import { mbkautheVar, packageJson, appVersion } from "../config/index.js";
5
5
  import { renderError } from "../utils/response.js";
6
- import { authenticate, validateSession } from "../middleware/auth.js";
6
+ import { authenticate, validateSession, validateApiSession } from "../middleware/auth.js";
7
7
  import { ErrorCodes, ErrorMessages } from "../utils/errors.js";
8
8
  import { dblogin } from "../database/pool.js";
9
9
  import { clearSessionCookies } from "../config/cookies.js";
@@ -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>
@@ -90,6 +95,49 @@ router.get('/test', validateSession, LoginLimit, async (req, res) => {
90
95
  }
91
96
  });
92
97
 
98
+ // API: check current session validity (JSON) — minimal response
99
+ router.get('/api/checkSession', LoginLimit, async (req, res) => {
100
+ try {
101
+ if (!req.session?.user) {
102
+ return res.status(200).json({ sessionValid: false, expiry: null });
103
+ }
104
+
105
+ const { id, sessionId } = req.session.user;
106
+ if (!sessionId) {
107
+ req.session.destroy(() => { });
108
+ clearSessionCookies(res);
109
+ return res.status(200).json({ sessionValid: false, expiry: null });
110
+ }
111
+
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] });
113
+
114
+ if (result.rows.length === 0) {
115
+ req.session.destroy(() => { });
116
+ clearSessionCookies(res);
117
+ return res.status(200).json({ sessionValid: false, expiry: null });
118
+ }
119
+
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
+ }
133
+
134
+ return res.status(200).json({ sessionValid: true, expiry });
135
+ } catch (err) {
136
+ console.error('[mbkauthe] checkSession error:', err);
137
+ return res.status(200).json({ sessionValid: false, expiry: null });
138
+ }
139
+ });
140
+
93
141
  // Error codes page
94
142
  router.get("/ErrorCode", (req, res) => {
95
143
  try {
@@ -251,12 +299,12 @@ router.post("/api/terminateAllSessions", AdminOperationLimit, authenticate(mbkau
251
299
  try {
252
300
  // Run both operations in parallel for better performance
253
301
  await Promise.all([
254
- dblogin.query({
255
- name: 'terminate-all-user-sessions',
256
- text: 'UPDATE "Users" SET "SessionId" = NULL WHERE "SessionId" IS NOT NULL'
302
+ dblogin.query({
303
+ name: 'terminate-all-app-sessions',
304
+ text: 'DELETE FROM "Sessions"'
257
305
  }),
258
- dblogin.query({
259
- name: 'terminate-all-db-sessions',
306
+ dblogin.query({
307
+ name: 'terminate-all-db-sessions',
260
308
  text: 'DELETE FROM "session" WHERE expire > NOW()'
261
309
  })
262
310
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "3.4.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",
package/test.spec.js CHANGED
@@ -192,5 +192,20 @@ describe('mbkauthe Routes', () => {
192
192
  expect(response.headers['content-type']).toContain('application/json');
193
193
  }
194
194
  });
195
+
196
+ test('GET /mbkauthe/api/checkSession handles session check', async () => {
197
+ const response = await request(BASE_URL).get('/mbkauthe/api/checkSession');
198
+ expect(response.status).toBe(200);
199
+ expect(response.headers['content-type']).toContain('application/json');
200
+ expect(response.body).toHaveProperty('sessionValid');
201
+ });
202
+
203
+ test('POST /mbkauthe/api/logout handles logout', async () => {
204
+ const response = await request(BASE_URL).post('/mbkauthe/api/logout').send();
205
+ expect([200, 400, 401, 403, 429]).toContain(response.status);
206
+ if (response.status === 200) {
207
+ expect(response.headers['content-type']).toContain('application/json');
208
+ }
209
+ });
195
210
  });
196
211
  });
@@ -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 {