mbkauthe 4.3.1 → 4.3.3
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/docs/api.md +92 -1
- package/index.js +18 -1
- package/lib/config/cookies.js +12 -4
- package/lib/middleware/auth.js +1 -3
- package/lib/middleware/index.js +1 -3
- package/lib/routes/auth.js +31 -22
- package/lib/routes/misc.js +153 -28
- package/lib/utils/response.js +47 -9
- package/package.json +1 -1
- package/views/accountSwitch.handlebars +57 -7
- package/views/profilemenu.handlebars +1 -1
- package/views/sharedStyles.handlebars +1 -1
package/docs/api.md
CHANGED
|
@@ -108,7 +108,7 @@ When a user logs in, MBKAuthe creates a session and sets the following cookies:
|
|
|
108
108
|
| Cookie Name | Description | HttpOnly | Secure | SameSite |
|
|
109
109
|
|------------|-------------|----------|--------|----------|
|
|
110
110
|
| `mbkauthe.sid` | Session identifier | ✓ | Auto* | lax |
|
|
111
|
-
| `sessionId` | Encrypted session token (AES-256-GCM). This cookie is encrypted and treated as an opaque value by clients; do not attempt to parse or rely on the raw cookie contents. Use server endpoints (e.g.,
|
|
111
|
+
| `sessionId` | Encrypted session token (AES-256-GCM). This cookie is encrypted and treated as an opaque value by clients; do not attempt to parse or rely on the raw cookie contents. Use server endpoints (e.g., `GET /mbkauthe/api/checkSession`, `POST /mbkauthe/api/checkSession` (body) or `POST /mbkauthe/api/verifySession`) to validate or query session information. | ✓ | Auto* | lax |
|
|
112
112
|
| `username` | Username | ✗ | Auto* | lax |
|
|
113
113
|
|
|
114
114
|
\* `secure` flag is automatically set to `true` in production when `IS_DEPLOYED=true`
|
|
@@ -301,6 +301,97 @@ fetch('/mbkauthe/api/checkSession')
|
|
|
301
301
|
|
|
302
302
|
---
|
|
303
303
|
|
|
304
|
+
#### `POST /mbkauthe/api/checkSession` (body)
|
|
305
|
+
|
|
306
|
+
Validate a session by providing a session identifier in the request body. Useful for server-to-server checks or when you have an encrypted `sessionId` value from a cookie and need to validate it server-side.
|
|
307
|
+
|
|
308
|
+
**Rate Limit:** 8 requests per minute (same limiter used by public session endpoints)
|
|
309
|
+
|
|
310
|
+
**Request Body (JSON):**
|
|
311
|
+
```json
|
|
312
|
+
{
|
|
313
|
+
"sessionId": "string (uuid or encrypted string)",
|
|
314
|
+
"isEncrypt": "boolean | 'true' (optional, indicates sessionId is encrypted)
|
|
315
|
+
}
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
**Notes:**
|
|
319
|
+
- The endpoint accepts `isEncrypt` or the misspelled `isEncryt` (both `true` or the string `'true'` are accepted).
|
|
320
|
+
- If `isEncrypt` is true, the server will first attempt `decodeURIComponent()` on the value and then decrypt it (AES) to obtain a UUID session id. If decryption fails or the resulting value is not a UUID, the server returns `400 Bad Request` with `SESSION_INVALID`.
|
|
321
|
+
- A missing `sessionId` returns `400 Bad Request` with `MISSING_REQUIRED_FIELD`.
|
|
322
|
+
|
|
323
|
+
**Success Response (200 OK):**
|
|
324
|
+
```json
|
|
325
|
+
{
|
|
326
|
+
"sessionValid": true,
|
|
327
|
+
"expiry": "2025-12-27T12:34:56.000Z"
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
**Invalid/Expired Session:**
|
|
332
|
+
- Returns 200 with `{ "sessionValid": false, "expiry": null }` for unknown/expired/inactive sessions.
|
|
333
|
+
|
|
334
|
+
**Example Request (Fetch):**
|
|
335
|
+
```javascript
|
|
336
|
+
fetch('/mbkauthe/api/checkSession', {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
headers: { 'Content-Type': 'application/json' },
|
|
339
|
+
body: JSON.stringify({ sessionId: '550e8400-e29b-41d4-a716-446655440000' })
|
|
340
|
+
}).then(r => r.json()).then(console.log);
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**Example Request (Encrypted sessionId):**
|
|
344
|
+
```javascript
|
|
345
|
+
fetch('/mbkauthe/api/checkSession', {
|
|
346
|
+
method: 'POST',
|
|
347
|
+
headers: { 'Content-Type': 'application/json' },
|
|
348
|
+
body: JSON.stringify({ sessionId: 'ENCRYPTED_VALUE', isEncrypt: true })
|
|
349
|
+
}).then(r => r.json()).then(console.log);
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
#### `POST /mbkauthe/api/verifySession`
|
|
355
|
+
|
|
356
|
+
Returns session details for a provided `sessionId`. Intended for server-side validation and to retrieve associated user metadata without relying on an active cookie session.
|
|
357
|
+
|
|
358
|
+
**Request Body (JSON):**
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"sessionId": "string (uuid or encrypted string)",
|
|
362
|
+
"isEncrypt": "boolean | 'true' (optional)"
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
**Behavior and Notes:**
|
|
367
|
+
- `isEncrypt`/`isEncryt` have the same behavior as in `/api/checkSession`.
|
|
368
|
+
- If the session is valid and active, the response includes `username` and `role`.
|
|
369
|
+
- Missing or invalid `sessionId` results in `400 Bad Request` with an appropriate error code (`MISSING_REQUIRED_FIELD` or `SESSION_INVALID`).
|
|
370
|
+
|
|
371
|
+
**Success Response (200 OK):**
|
|
372
|
+
```json
|
|
373
|
+
{
|
|
374
|
+
"valid": true,
|
|
375
|
+
"expiry": "2025-12-27T12:34:56.000Z",
|
|
376
|
+
"username": "john.doe",
|
|
377
|
+
"role": "NormalUser"
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
**Invalid/Expired Session:**
|
|
382
|
+
- Returns 200 with `{ "valid": false, "expiry": null }` for unknown/expired/inactive sessions.
|
|
383
|
+
|
|
384
|
+
**Example Request:**
|
|
385
|
+
```javascript
|
|
386
|
+
fetch('/mbkauthe/api/verifySession', {
|
|
387
|
+
method: 'POST',
|
|
388
|
+
headers: { 'Content-Type': 'application/json' },
|
|
389
|
+
body: JSON.stringify({ sessionId: '550e8400-e29b-41d4-a716-446655440000' })
|
|
390
|
+
}).then(r => r.json()).then(console.log);
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
304
395
|
#### `GET /mbkauthe/2fa`
|
|
305
396
|
|
|
306
397
|
Renders the Two-Factor Authentication verification page.
|
package/index.js
CHANGED
|
@@ -112,11 +112,28 @@ export {
|
|
|
112
112
|
validateSessionAndRole, authenticate, reloadSessionUser,
|
|
113
113
|
strictValidateSession, strictValidateSessionAndRole
|
|
114
114
|
} from "./lib/middleware/auth.js";
|
|
115
|
-
export {
|
|
115
|
+
export {
|
|
116
|
+
sessionConfig,
|
|
117
|
+
corsMiddleware,
|
|
118
|
+
sessionRestorationMiddleware,
|
|
119
|
+
sessionCookieSyncMiddleware
|
|
120
|
+
} from "./lib/middleware/index.js";
|
|
121
|
+
export { validateTokenScope } from "./lib/middleware/scopeValidator.js";
|
|
122
|
+
export { renderError, getUserContext, renderPage, proxycall } from "#response.js";
|
|
116
123
|
export { dblogin } from "#pool.js";
|
|
124
|
+
export { getLatestVersion } from "./lib/routes/misc.js";
|
|
125
|
+
export { checkTrustedDevice, completeLoginProcess } from "./lib/routes/auth.js";
|
|
117
126
|
export {
|
|
118
127
|
ErrorCodes, ErrorMessages, getErrorByCode,
|
|
119
128
|
createErrorResponse, logError
|
|
120
129
|
} from "./lib/utils/errors.js";
|
|
130
|
+
export {
|
|
131
|
+
encryptSessionId, decryptSessionId, cachedCookieOptions, cachedClearCookieOptions,
|
|
132
|
+
DEVICE_TRUST_DURATION_DAYS, DEVICE_TRUST_DURATION_MS,
|
|
133
|
+
generateDeviceToken, hashDeviceToken, getDeviceTokenCookieOptions,
|
|
134
|
+
getCookieOptions, getClearCookieOptions, clearSessionCookies,
|
|
135
|
+
readAccountListFromCookie, upsertAccountListCookie, removeAccountFromCookie, clearAccountListCookie
|
|
136
|
+
} from "./lib/config/cookies.js";
|
|
137
|
+
export { hashPassword, hashApiToken } from "./lib/config/security.js";
|
|
121
138
|
export { mbkautheVar } from "#config.js";
|
|
122
139
|
export default router;
|
package/lib/config/cookies.js
CHANGED
|
@@ -148,7 +148,6 @@ export const getDeviceTokenCookieOptions = () => ({
|
|
|
148
148
|
export const clearSessionCookies = (res) => {
|
|
149
149
|
res.clearCookie("mbkauthe.sid", cachedClearCookieOptions);
|
|
150
150
|
res.clearCookie("sessionId", cachedClearCookieOptions);
|
|
151
|
-
res.clearCookie("username", cachedClearCookieOptions);
|
|
152
151
|
res.clearCookie("fullName", cachedClearCookieOptions);
|
|
153
152
|
res.clearCookie("device_token", cachedClearCookieOptions);
|
|
154
153
|
};
|
|
@@ -183,7 +182,8 @@ const parseAccountList = (raw, req) => {
|
|
|
183
182
|
.map(item => ({
|
|
184
183
|
sessionId: typeof item.sessionId === 'string' ? item.sessionId : null,
|
|
185
184
|
username: typeof item.username === 'string' ? item.username : null,
|
|
186
|
-
fullName: typeof item.fullName === 'string' ? item.fullName : null
|
|
185
|
+
fullName: typeof item.fullName === 'string' ? item.fullName : null,
|
|
186
|
+
image: typeof item.image === 'string' ? item.image : null
|
|
187
187
|
}))
|
|
188
188
|
.filter(item => item.sessionId && item.username)
|
|
189
189
|
.slice(0, MAX_REMEMBERED_ACCOUNTS);
|
|
@@ -196,9 +196,17 @@ const parseAccountList = (raw, req) => {
|
|
|
196
196
|
const writeAccountList = (res, list, req) => {
|
|
197
197
|
const sanitized = Array.isArray(list) ? list.slice(0, MAX_REMEMBERED_ACCOUNTS) : [];
|
|
198
198
|
|
|
199
|
+
// Clean and limit fields to safe values (limit image URL length)
|
|
200
|
+
const cleaned = sanitized.map(item => ({
|
|
201
|
+
sessionId: item && item.sessionId ? item.sessionId : null,
|
|
202
|
+
username: item && item.username ? item.username : null,
|
|
203
|
+
fullName: item && item.fullName ? item.fullName : null,
|
|
204
|
+
image: (item && typeof item.image === 'string' && item.image.length <= 2048) ? item.image : null
|
|
205
|
+
})).filter(i => i && i.sessionId && i.username);
|
|
206
|
+
|
|
199
207
|
// Create payload with fingerprint
|
|
200
208
|
const payload = {
|
|
201
|
-
accounts:
|
|
209
|
+
accounts: cleaned,
|
|
202
210
|
fingerprint: generateFingerprint(req)
|
|
203
211
|
};
|
|
204
212
|
|
|
@@ -221,7 +229,7 @@ export const upsertAccountListCookie = (req, res, entry) => {
|
|
|
221
229
|
if (!entry || !entry.sessionId || !entry.username) return;
|
|
222
230
|
const current = readAccountListFromCookie(req);
|
|
223
231
|
const filtered = current.filter(item => item.sessionId !== entry.sessionId && item.username !== entry.username);
|
|
224
|
-
const next = [{ sessionId: entry.sessionId, username: entry.username, fullName: entry.fullName || entry.username }, ...filtered];
|
|
232
|
+
const next = [{ sessionId: entry.sessionId, username: entry.username, fullName: entry.fullName || entry.username, image: entry.image || null }, ...filtered];
|
|
225
233
|
writeAccountList(res, next, req);
|
|
226
234
|
};
|
|
227
235
|
|
package/lib/middleware/auth.js
CHANGED
|
@@ -421,16 +421,14 @@ async function reloadSessionUser(req, res) {
|
|
|
421
421
|
// Persist session changes
|
|
422
422
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
|
423
423
|
|
|
424
|
-
// Sync cookies for client UI (
|
|
424
|
+
// Sync cookies for client UI (sessionId + fullName)
|
|
425
425
|
try {
|
|
426
|
-
res.cookie('username', req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
427
426
|
res.cookie('fullName', req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
428
427
|
const encryptedSid = encryptSessionId(req.session.user.sessionId);
|
|
429
428
|
if (encryptedSid) {
|
|
430
429
|
res.cookie('sessionId', encryptedSid, cachedCookieOptions);
|
|
431
430
|
}
|
|
432
431
|
} catch (cookieErr) {
|
|
433
|
-
// Ignore cookie setting errors, session is still refreshed
|
|
434
432
|
console.error('[mbkauthe] Error syncing cookies during reload:', cookieErr);
|
|
435
433
|
}
|
|
436
434
|
|
package/lib/middleware/index.js
CHANGED
|
@@ -96,7 +96,7 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
96
96
|
};
|
|
97
97
|
|
|
98
98
|
// Use cached FullName from client cookie when available to avoid extra DB queries
|
|
99
|
-
if (req.cookies.fullName && typeof req.cookies.fullName === 'string') {
|
|
99
|
+
if (req.cookies && req.cookies.fullName && typeof req.cookies.fullName === 'string') {
|
|
100
100
|
req.session.user.fullname = req.cookies.fullName;
|
|
101
101
|
} else {
|
|
102
102
|
// Fallback: attempt to fetch FullName from Users to populate session
|
|
@@ -130,9 +130,7 @@ export function sessionCookieSyncMiddleware(req, res, next) {
|
|
|
130
130
|
|
|
131
131
|
// Only set cookies if they're missing or different
|
|
132
132
|
if (currentDecryptedId !== req.session.user.sessionId) {
|
|
133
|
-
res.cookie("username", req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
134
133
|
res.cookie("fullName", req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
135
|
-
|
|
136
134
|
const encryptedSessionId = encryptSessionId(req.session.user.sessionId);
|
|
137
135
|
if (encryptedSessionId) {
|
|
138
136
|
res.cookie("sessionId", encryptedSessionId, cachedCookieOptions);
|
package/lib/routes/auth.js
CHANGED
|
@@ -252,18 +252,20 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
252
252
|
// Clear profile picture cache to fetch fresh data
|
|
253
253
|
clearProfilePicCache(req, username);
|
|
254
254
|
|
|
255
|
-
// Attempt to fetch FullName from Users and store it in session for display purposes
|
|
255
|
+
// Attempt to fetch FullName and Image from Users and store it in session for display purposes
|
|
256
|
+
let loginProfileImage = null;
|
|
256
257
|
try {
|
|
257
258
|
const profileResult = await dblogin.query({
|
|
258
|
-
name: 'login-get-fullname',
|
|
259
|
-
text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
259
|
+
name: 'login-get-fullname-and-image',
|
|
260
|
+
text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
260
261
|
values: [username]
|
|
261
262
|
});
|
|
262
|
-
if (profileResult.rows.length > 0
|
|
263
|
-
req.session.user.fullname = profileResult.rows[0].FullName;
|
|
263
|
+
if (profileResult.rows.length > 0) {
|
|
264
|
+
if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName;
|
|
265
|
+
if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image;
|
|
264
266
|
}
|
|
265
267
|
} catch (profileErr) {
|
|
266
|
-
console.error("[mbkauthe] Error fetching FullName for user:", profileErr);
|
|
268
|
+
console.error("[mbkauthe] Error fetching FullName/Image for user:", profileErr);
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
if (req.session.preAuthUser) {
|
|
@@ -276,12 +278,12 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
276
278
|
return res.status(500).json({ success: false, message: "Internal Server Error" });
|
|
277
279
|
}
|
|
278
280
|
|
|
279
|
-
// Expose DB session id
|
|
281
|
+
// Expose DB session id to client
|
|
280
282
|
const encryptedSessionId = encryptSessionId(dbSessionId);
|
|
281
283
|
if (encryptedSessionId) {
|
|
282
284
|
res.cookie("sessionId", encryptedSessionId, cachedCookieOptions);
|
|
283
285
|
}
|
|
284
|
-
|
|
286
|
+
// Cache display name client-side to avoid extra DB lookups
|
|
285
287
|
res.cookie("fullName", req.session.user.fullname || username, { ...cachedCookieOptions, httpOnly: false });
|
|
286
288
|
// Record which method was used to login (client-visible badge)
|
|
287
289
|
if (method && typeof method === 'string') {
|
|
@@ -296,7 +298,8 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
296
298
|
upsertAccountListCookie(req, res, {
|
|
297
299
|
sessionId: dbSessionId,
|
|
298
300
|
username,
|
|
299
|
-
fullName: req.session.user.fullname || username
|
|
301
|
+
fullName: req.session.user.fullname || username,
|
|
302
|
+
image: loginProfileImage || null
|
|
300
303
|
});
|
|
301
304
|
|
|
302
305
|
// Handle trusted device if requested (token no longer stored in DB as token_hash)
|
|
@@ -677,18 +680,20 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
|
|
|
677
680
|
}
|
|
678
681
|
|
|
679
682
|
let fullName = acct.fullName || acct.username;
|
|
680
|
-
|
|
683
|
+
let image = acct.image || null;
|
|
684
|
+
if (!acct.fullName || !acct.image) {
|
|
681
685
|
try {
|
|
682
686
|
const prof = await dblogin.query({
|
|
683
|
-
name: 'multi-session-fullname',
|
|
684
|
-
text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
687
|
+
name: 'multi-session-fullname-image',
|
|
688
|
+
text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
685
689
|
values: [row.UserName]
|
|
686
690
|
});
|
|
687
|
-
if (prof.rows.length > 0
|
|
688
|
-
fullName = prof.rows[0].FullName;
|
|
691
|
+
if (prof.rows.length > 0) {
|
|
692
|
+
if (!acct.fullName && prof.rows[0].FullName) fullName = prof.rows[0].FullName;
|
|
693
|
+
if (!acct.image && prof.rows[0].Image && prof.rows[0].Image.trim() !== '') image = prof.rows[0].Image;
|
|
689
694
|
}
|
|
690
695
|
} catch (profileErr) {
|
|
691
|
-
console.error('[mbkauthe] Error fetching fullname for account list:', profileErr);
|
|
696
|
+
console.error('[mbkauthe] Error fetching fullname/image for account list:', profileErr);
|
|
692
697
|
}
|
|
693
698
|
}
|
|
694
699
|
|
|
@@ -696,6 +701,7 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
|
|
|
696
701
|
sessionId: row.sid,
|
|
697
702
|
username: row.UserName,
|
|
698
703
|
fullName,
|
|
704
|
+
image,
|
|
699
705
|
isCurrent: currentSessionId && row.sid === currentSessionId
|
|
700
706
|
});
|
|
701
707
|
} catch (err) {
|
|
@@ -729,15 +735,19 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
|
|
|
729
735
|
}
|
|
730
736
|
|
|
731
737
|
let fullName = row.UserName;
|
|
738
|
+
let switchProfileImage = null;
|
|
732
739
|
try {
|
|
733
740
|
const prof = await dblogin.query({
|
|
734
|
-
name: 'multi-session-switch-fullname',
|
|
735
|
-
text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
741
|
+
name: 'multi-session-switch-fullname-image',
|
|
742
|
+
text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
736
743
|
values: [row.UserName]
|
|
737
744
|
});
|
|
738
|
-
if (prof.rows.length > 0
|
|
745
|
+
if (prof.rows.length > 0) {
|
|
746
|
+
if (prof.rows[0].FullName) fullName = prof.rows[0].FullName;
|
|
747
|
+
if (prof.rows[0].Image && prof.rows[0].Image.trim() !== '') switchProfileImage = prof.rows[0].Image;
|
|
748
|
+
}
|
|
739
749
|
} catch (profileErr) {
|
|
740
|
-
console.error('[mbkauthe] Error fetching fullname during switch:', profileErr);
|
|
750
|
+
console.error('[mbkauthe] Error fetching fullname/image during switch:', profileErr);
|
|
741
751
|
}
|
|
742
752
|
|
|
743
753
|
// Regenerate session to avoid fixation
|
|
@@ -759,14 +769,13 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
|
|
|
759
769
|
|
|
760
770
|
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
|
761
771
|
|
|
762
|
-
// Sync
|
|
763
|
-
res.cookie('username', row.UserName, { ...cachedCookieOptions, httpOnly: false });
|
|
772
|
+
// Sync sessionId cookie and remember list
|
|
764
773
|
res.cookie('fullName', fullName, { ...cachedCookieOptions, httpOnly: false });
|
|
765
774
|
const encryptedSid = encryptSessionId(row.sid);
|
|
766
775
|
if (encryptedSid) {
|
|
767
776
|
res.cookie('sessionId', encryptedSid, cachedCookieOptions);
|
|
768
777
|
}
|
|
769
|
-
upsertAccountListCookie(req, res, { sessionId: row.sid, username: row.UserName, fullName });
|
|
778
|
+
upsertAccountListCookie(req, res, { sessionId: row.sid, username: row.UserName, fullName, image: switchProfileImage || null });
|
|
770
779
|
|
|
771
780
|
const safeRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')
|
|
772
781
|
? redirect
|
package/lib/routes/misc.js
CHANGED
|
@@ -4,9 +4,9 @@ import rateLimit from 'express-rate-limit';
|
|
|
4
4
|
import { mbkautheVar, packageJson, appVersion } from "#config.js";
|
|
5
5
|
import { renderError } from "#response.js";
|
|
6
6
|
import { authenticate, validateSession, validateApiSession } from "../middleware/auth.js";
|
|
7
|
-
import { ErrorCodes, ErrorMessages } from "../utils/errors.js";
|
|
7
|
+
import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js";
|
|
8
8
|
import { dblogin } from "#pool.js";
|
|
9
|
-
import { clearSessionCookies } from "#cookies.js";
|
|
9
|
+
import { clearSessionCookies, decryptSessionId } from "#cookies.js";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
11
|
import path from "path";
|
|
12
12
|
import fs from "fs";
|
|
@@ -153,35 +153,59 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
153
153
|
router.get('/test', validateSession, LoginLimit, async (req, res) => {
|
|
154
154
|
if (req.session?.user) {
|
|
155
155
|
return res.send(`
|
|
156
|
-
<head>
|
|
157
|
-
<
|
|
156
|
+
<head>
|
|
157
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
158
|
+
<script src="/mbkauthe/main.js"></script>
|
|
158
159
|
<style>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
160
|
+
:root{--bg:#f7fafc;--card:#ffffff;--muted:#6b7280;--accent:#2563eb;--success:#059669;--danger:#ef4444;--radius:12px}
|
|
161
|
+
html,body{height:100%;margin:0;font-family:Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial}
|
|
162
|
+
body{background:linear-gradient(180deg, #f0f4ff 0%, var(--bg) 100%);display:flex;align-items:center;justify-content:center;padding:32px}
|
|
163
|
+
.container{width:100%;max-width:920px}
|
|
164
|
+
.card{background:var(--card);border-radius:var(--radius);padding:22px;box-shadow:0 6px 24px rgba(16,24,40,0.06);display:grid;grid-template-columns:120px 1fr;gap:20px;align-items:start}
|
|
165
|
+
.avatar{width:96px;height:96px;border-radius:50%;background:linear-gradient(135deg,#e6eefc,#fff);display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--accent);font-size:28px;border:2px solid rgba(37,99,235,0.08);position:relative;overflow:hidden}
|
|
166
|
+
.avatar img{width:100%;height:100%;object-fit:cover;display:block}
|
|
167
|
+
.avatar .initials{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font-weight:700;color:var(--accent);font-size:28px;background:linear-gradient(135deg,#eef2ff,#fff)}
|
|
168
|
+
.meta{display:flex;flex-direction:column;gap:8px}
|
|
169
|
+
.status{color:var(--success);font-weight:600;display:flex;align-items:center;gap:8px}
|
|
170
|
+
.user-title{font-size:18px;font-weight:700;margin:0}
|
|
171
|
+
.user-sub{color:var(--muted);margin:0;font-size:13px}
|
|
172
|
+
.details{background:#fbfdff;border-radius:10px;padding:12px;font-family:monospace;font-size:13px;color:#111;display:flex;flex-direction:column;gap:6px}
|
|
173
|
+
.actions{margin-top:14px;display:flex;flex-wrap:wrap;gap:8px}
|
|
174
|
+
.btn{display:inline-flex;align-items:center;gap:8px;padding:9px 14px;border-radius:10px;border:1px solid transparent;cursor:pointer;text-decoration:none}
|
|
175
|
+
.btn-primary{background:var(--accent);color:white}
|
|
176
|
+
.btn-outline{background:transparent;border-color:rgba(37,99,235,0.12);color:var(--accent)}
|
|
177
|
+
.btn-danger{background:var(--danger);color:#fff}
|
|
178
|
+
a{color:inherit}
|
|
179
|
+
@media (max-width:640px){.card{grid-template-columns:1fr;align-items:stretch}.avatar{width:80px;height:80px}}
|
|
167
180
|
</style>
|
|
168
181
|
</head>
|
|
169
|
-
<div class="
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
182
|
+
<div class="container">
|
|
183
|
+
<div class="card" role="region" aria-label="User Session">
|
|
184
|
+
<div class="avatar" aria-hidden="true">
|
|
185
|
+
<img src="/mbkauthe/user/profilepic?u=${encodeURIComponent(req.session.user.username)}" alt="Avatar for ${req.session.user.username}" title="${req.session.user.fullname || req.session.user.username}" loading="lazy" decoding="async" width="96" height="96" onerror="this.style.display='none';var s=this.nextElementSibling; if(s) s.style.display='flex';" />
|
|
186
|
+
<div class="initials" aria-hidden="true" style="display:none">${(req.session.user.fullname && req.session.user.fullname[0]) || req.session.user.username[0]}</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="meta">
|
|
189
|
+
<div>
|
|
190
|
+
<div class="status">✅ Authentication successful</div>
|
|
191
|
+
<h3 class="user-title">${req.session.user.username} <small style="color:var(--muted);font-weight:600">· ${req.session.user.role}</small></h3>
|
|
192
|
+
<p class="user-sub">ID: ${req.session.user.id} · Session: ${req.session.user.sessionId.slice(0,8)}…</p>
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
<div class="details" aria-live="polite">
|
|
196
|
+
<div>Full Name: ${req.session.user.fullname || 'N/A'}</div>
|
|
197
|
+
<div>Allowed Apps: ${Array.isArray(req.session.user.allowedApps) ? req.session.user.allowedApps.join(', ') : 'N/A'}</div>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<div class="actions">
|
|
201
|
+
<button class="btn btn-primary" onclick="logout()" aria-label="Log out">Logout</button>
|
|
202
|
+
<a class="btn btn-outline" href="https://portal.mbktech.org/">Web Portal</a>
|
|
203
|
+
<a class="btn btn-outline" href="https://portal.mbktech.org/user/settings">User Settings</a>
|
|
204
|
+
<a class="btn btn-outline" href="/mbkauthe/info">Info Page</a>
|
|
205
|
+
<a class="btn btn-outline" href="/mbkauthe/login">Login Page</a>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
179
208
|
</div>
|
|
180
|
-
<button onclick="logout()">Logout</button>
|
|
181
|
-
<a href="https://portal.mbktech.org/">Web Portal</a>
|
|
182
|
-
<a href="https://portal.mbktech.org/user/settings">User Settings</a>
|
|
183
|
-
<a href="/mbkauthe/info">Info Page</a>
|
|
184
|
-
<a href="/mbkauthe/login">Login Page</a>
|
|
185
209
|
</div>
|
|
186
210
|
`);
|
|
187
211
|
}
|
|
@@ -236,6 +260,107 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
|
236
260
|
}
|
|
237
261
|
});
|
|
238
262
|
|
|
263
|
+
// UUID helper used by session endpoints
|
|
264
|
+
const isUuid = (val) => typeof val === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(val);
|
|
265
|
+
|
|
266
|
+
// POST /api/checkSession — accept sessionId in request body { sessionId: "<uuid>" }
|
|
267
|
+
router.post('/api/checkSession', LoginLimit, async (req, res) => {
|
|
268
|
+
try {
|
|
269
|
+
const { sessionId: rawSessionId, isEncrypt, isEncryt } = req.body || {};
|
|
270
|
+
let sessionId = rawSessionId;
|
|
271
|
+
if (!sessionId) {
|
|
272
|
+
return res.status(400).json(createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// If body indicates the sessionId is encrypted, decode (if URL-encoded) then decrypt it first
|
|
276
|
+
const encryptedFlag = isEncrypt === true || isEncrypt === 'true' || isEncryt === true || isEncryt === 'true';
|
|
277
|
+
if (encryptedFlag) {
|
|
278
|
+
// Some clients URL-encode cookie values when posting; safely try to decode first
|
|
279
|
+
let toDecrypt = typeof sessionId === 'string' ? sessionId : String(sessionId);
|
|
280
|
+
try {
|
|
281
|
+
toDecrypt = decodeURIComponent(toDecrypt);
|
|
282
|
+
} catch (decodeErr) {
|
|
283
|
+
// ignore decode errors and continue with original value
|
|
284
|
+
}
|
|
285
|
+
const decrypted = decryptSessionId(toDecrypt);
|
|
286
|
+
if (!decrypted || !isUuid(decrypted)) {
|
|
287
|
+
return res.status(400).json(createErrorResponse(400, ErrorCodes.SESSION_INVALID));
|
|
288
|
+
}
|
|
289
|
+
sessionId = decrypted;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const result = await dblogin.query({
|
|
293
|
+
name: 'check-session-validity-by-id',
|
|
294
|
+
text: `SELECT s.expires_at, u."Active" FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1`,
|
|
295
|
+
values: [sessionId]
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
if (result.rows.length === 0) {
|
|
299
|
+
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const row = result.rows[0];
|
|
303
|
+
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
|
|
304
|
+
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
|
|
308
|
+
return res.status(200).json({ sessionValid: true, expiry });
|
|
309
|
+
} catch (err) {
|
|
310
|
+
console.error('[mbkauthe] checkSession (body) error:', err);
|
|
311
|
+
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// POST /api/verifySession — returns details about sessionId provided in body
|
|
316
|
+
router.post('/api/verifySession', LoginLimit, async (req, res) => {
|
|
317
|
+
try {
|
|
318
|
+
const { sessionId: rawSessionId, isEncrypt, isEncryt } = req.body || {};
|
|
319
|
+
let sessionId = rawSessionId;
|
|
320
|
+
if (!sessionId) {
|
|
321
|
+
return res.status(400).json(createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// If body indicates the sessionId is encrypted, decode (if URL-encoded) then decrypt it first
|
|
325
|
+
const encryptedFlag = isEncrypt === true || isEncrypt === 'true' || isEncryt === true || isEncryt === 'true';
|
|
326
|
+
if (encryptedFlag) {
|
|
327
|
+
// Some clients URL-encode cookie values when posting; safely try to decode first
|
|
328
|
+
let toDecrypt = typeof sessionId === 'string' ? sessionId : String(sessionId);
|
|
329
|
+
try {
|
|
330
|
+
toDecrypt = decodeURIComponent(toDecrypt);
|
|
331
|
+
} catch (decodeErr) {
|
|
332
|
+
// ignore decode errors and continue with original value
|
|
333
|
+
}
|
|
334
|
+
const decrypted = decryptSessionId(toDecrypt);
|
|
335
|
+
if (!decrypted || !isUuid(decrypted)) {
|
|
336
|
+
return res.status(400).json(createErrorResponse(400, ErrorCodes.SESSION_INVALID));
|
|
337
|
+
}
|
|
338
|
+
sessionId = decrypted;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
|
|
342
|
+
FROM "Sessions" s
|
|
343
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
344
|
+
WHERE s.id = $1 LIMIT 1`;
|
|
345
|
+
const result = await dblogin.query({ name: 'verify-session', text: query, values: [sessionId] });
|
|
346
|
+
|
|
347
|
+
if (result.rows.length === 0) {
|
|
348
|
+
return res.status(200).json({ valid: false, expiry: null });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const row = result.rows[0];
|
|
352
|
+
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
|
|
353
|
+
return res.status(200).json({ valid: false, expiry: null });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
|
|
357
|
+
return res.status(200).json({ valid: true, expiry, username: row.UserName, role: row.Role });
|
|
358
|
+
} catch (err) {
|
|
359
|
+
console.error('[mbkauthe] verifySession error:', err);
|
|
360
|
+
return res.status(200).json({ valid: false, expiry: null });
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
239
364
|
// Error codes page
|
|
240
365
|
router.get("/ErrorCode", (req, res) => {
|
|
241
366
|
try {
|
|
@@ -427,4 +552,4 @@ router.post("/api/terminateAllSessions", AdminOperationLimit, authenticate(mbkau
|
|
|
427
552
|
}
|
|
428
553
|
});
|
|
429
554
|
|
|
430
|
-
export default router;
|
|
555
|
+
export default router;
|
package/lib/utils/response.js
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import { mbkautheVar, packageJson } from "#config.js";
|
|
2
2
|
|
|
3
3
|
export function getUserContext(req) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
4
|
+
const user = req?.session?.user || {};
|
|
5
|
+
return {
|
|
6
|
+
userLoggedIn: !!user.username,
|
|
7
|
+
isuserlogin: !!user.username,
|
|
8
|
+
username: user.username || 'N/A',
|
|
9
|
+
fullname: user.fullname || 'N/A',
|
|
10
|
+
role: user.role || 'N/A',
|
|
11
|
+
allowedApps: Array.isArray(user.allowedApps) ? user.allowedApps : [],
|
|
12
|
+
};
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
// Helper function to render error pages consistently
|
|
@@ -42,4 +42,42 @@ export async function renderPage(req, res, fileLocation, layout = true, data = {
|
|
|
42
42
|
...(layout === false ? { layout: false } : {}),
|
|
43
43
|
};
|
|
44
44
|
return res.render(fileLocation, renderOptions);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function proxycall(req, res, url, method = 'GET', headerOption = {}) {
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
const timeout = setTimeout(() => controller.abort(), 30000);
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const sessionCookie = req.cookies?.sessionId;
|
|
53
|
+
|
|
54
|
+
const headers = { ...headerOption };
|
|
55
|
+
|
|
56
|
+
if (sessionCookie && !headers.Cookie) {
|
|
57
|
+
headers.Cookie = `sessionId=${sessionCookie}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const body = ['GET', 'HEAD'].includes(method) ? undefined : typeof req.body === 'string' || req.body instanceof Buffer ? req.body : JSON.stringify(req.body);
|
|
61
|
+
|
|
62
|
+
if (body && !headers['Content-Type']) {
|
|
63
|
+
headers['Content-Type'] = 'application/json';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const response = await fetch(url, { method, headers, body, signal: controller.signal });
|
|
67
|
+
|
|
68
|
+
response.headers.forEach((value, key) => {
|
|
69
|
+
res.setHeader(key, value);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const contentType = response.headers.get('content-type');
|
|
73
|
+
const data = contentType?.includes('application/json') ? await response.json() : await response.text();
|
|
74
|
+
|
|
75
|
+
return res.status(response.status).send(data);
|
|
76
|
+
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('Proxy error:', err);
|
|
79
|
+
return res.status(err.name === 'AbortError' ? 504 : 500).json({ error: 'Proxy request failed' });
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timeout);
|
|
82
|
+
}
|
|
45
83
|
}
|
package/package.json
CHANGED
|
@@ -147,6 +147,27 @@
|
|
|
147
147
|
font-size: 18px;
|
|
148
148
|
flex-shrink: 0;
|
|
149
149
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
|
150
|
+
position: relative;
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.avatar img {
|
|
155
|
+
width: 100%;
|
|
156
|
+
height: 100%;
|
|
157
|
+
object-fit: cover;
|
|
158
|
+
display: block;
|
|
159
|
+
border-radius: 50%;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.avatar .initials {
|
|
163
|
+
position: absolute;
|
|
164
|
+
inset: 0;
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
font-weight: 600;
|
|
169
|
+
font-size: 18px;
|
|
170
|
+
background: linear-gradient(135deg, rgba(255,255,255,0.02), rgba(255,255,255,0));
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
.account-info {
|
|
@@ -367,15 +388,44 @@
|
|
|
367
388
|
item.role = 'button';
|
|
368
389
|
item.tabIndex = 0;
|
|
369
390
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
391
|
+
// Build avatar with image (if available) and initials fallback to avoid HTML injection
|
|
392
|
+
const avatar = document.createElement('div');
|
|
393
|
+
avatar.className = 'avatar';
|
|
394
|
+
|
|
395
|
+
const initials = document.createElement('div');
|
|
396
|
+
initials.className = 'initials';
|
|
397
|
+
initials.textContent = initial;
|
|
398
|
+
initials.style.display = acct.image ? 'none' : 'flex';
|
|
399
|
+
|
|
400
|
+
if (acct.image) {
|
|
401
|
+
const img = document.createElement('img');
|
|
402
|
+
img.className = 'avatar-img';
|
|
403
|
+
img.src = acct.image;
|
|
404
|
+
img.alt = `Avatar for ${acct.username}`;
|
|
405
|
+
img.loading = 'lazy';
|
|
406
|
+
img.decoding = 'async';
|
|
407
|
+
img.onerror = function () { img.style.display = 'none'; initials.style.display = 'flex'; };
|
|
408
|
+
avatar.appendChild(img);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
avatar.appendChild(initials);
|
|
412
|
+
|
|
413
|
+
const info = document.createElement('div');
|
|
414
|
+
info.className = 'account-info';
|
|
415
|
+
info.innerHTML = `
|
|
416
|
+
<div class="account-name">${acct.fullName || acct.username}</div>
|
|
417
|
+
<div class="account-email">${acct.username}</div>
|
|
377
418
|
`;
|
|
378
419
|
|
|
420
|
+
item.appendChild(avatar);
|
|
421
|
+
item.appendChild(info);
|
|
422
|
+
if (isCurrent) {
|
|
423
|
+
const badge = document.createElement('div');
|
|
424
|
+
badge.className = 'current-badge';
|
|
425
|
+
badge.textContent = 'Current';
|
|
426
|
+
item.appendChild(badge);
|
|
427
|
+
};
|
|
428
|
+
|
|
379
429
|
item.addEventListener('click', () => {
|
|
380
430
|
if (!isCurrent) {
|
|
381
431
|
switchSession(acct.sessionId);
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
/* prefers-color-scheme fallbacks scoped to component */
|
|
178
178
|
@media (prefers-color-scheme: dark) {
|
|
179
179
|
.mbkauthe-profile-dropdown {
|
|
180
|
-
--mbk-bg: #
|
|
180
|
+
--mbk-bg: #0a2125;
|
|
181
181
|
--mbk-border: rgba(255, 255, 255, 0.06);
|
|
182
182
|
--mbk-item-hover: rgba(255, 255, 255, 0.03);
|
|
183
183
|
--mbk-text: #e6eef6;
|