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.
- package/README.md +54 -304
- package/docs/api.md +71 -3
- package/docs/db.md +26 -20
- package/docs/db.sql +116 -0
- package/docs/env.md +10 -0
- package/index.d.ts +10 -3
- package/index.js +4 -1
- package/lib/config/cookies.js +7 -0
- package/lib/config/index.js +20 -4
- package/lib/config/security.js +1 -1
- package/lib/middleware/auth.js +202 -15
- package/lib/middleware/index.js +45 -15
- package/lib/routes/auth.js +74 -35
- package/lib/routes/misc.js +54 -6
- package/package.json +1 -1
- package/test.spec.js +15 -0
- package/views/loginmbkauthe.handlebars +0 -2
package/lib/routes/auth.js
CHANGED
|
@@ -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: [
|
|
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: [
|
|
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
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
494
|
-
const operations = [
|
|
495
|
-
|
|
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);
|
package/lib/routes/misc.js
CHANGED
|
@@ -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-
|
|
256
|
-
text: '
|
|
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
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 {
|