mbkauthe 5.0.2 → 5.1.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 +9 -4
- package/docs/guides/configuration.md +1 -6
- package/docs/guides/database.md +1 -1
- package/docs/schema/db.sql +22 -1
- package/index.d.ts +19 -6
- package/lib/config/cookies.js +35 -10
- package/lib/config/index.js +162 -153
- package/lib/config/security.js +48 -8
- package/lib/db/AuthRepository.js +8 -8
- package/lib/main.js +2 -0
- package/lib/middleware/auth.js +36 -9
- package/lib/middleware/index.js +20 -20
- package/lib/routes/auth.js +21 -26
- package/lib/routes/misc.js +87 -24
- package/lib/routes/oauth.js +5 -5
- package/lib/utils/appAccess.js +8 -0
- package/lib/utils/dbQueryLogger.js +1 -1
- package/lib/utils/redirect.js +14 -0
- package/lib/utils/urlSafety.js +67 -0
- package/package.json +10 -7
- package/public/main.js +158 -99
- package/test.spec.js +73 -1
- package/views/head.handlebars +1 -0
- package/views/pages/accountSwitch.handlebars +11 -11
- package/views/pages/loginmbkauthe.handlebars +32 -24
- package/views/pages/test.handlebars +2 -3
- package/views/profilemenu.handlebars +1 -15
package/lib/middleware/index.js
CHANGED
|
@@ -3,8 +3,9 @@ import pgSession from "connect-pg-simple";
|
|
|
3
3
|
const PgSession = pgSession(session);
|
|
4
4
|
import { dblogin, runWithRequestContext } from "#pool.js";
|
|
5
5
|
import { mbkautheVar } from "#config.js";
|
|
6
|
-
import { cachedCookieOptions, decryptSessionId, encryptSessionId } from "#cookies.js";
|
|
6
|
+
import { cachedCookieOptions, decryptSessionId, encryptSessionId, cachedClearCookieOptions, getCookieDomain, getCookieSecure, isAllowedOriginHostname } from "#cookies.js";
|
|
7
7
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
8
|
+
import { isUserAuthorizedForApp } from "../utils/appAccess.js";
|
|
8
9
|
|
|
9
10
|
// Session configuration
|
|
10
11
|
export const sessionConfig = {
|
|
@@ -24,10 +25,10 @@ export const sessionConfig = {
|
|
|
24
25
|
cookie: {
|
|
25
26
|
maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
|
|
26
27
|
// Don't set domain in development/localhost to avoid cookie issues
|
|
27
|
-
domain: (
|
|
28
|
+
domain: getCookieDomain(),
|
|
28
29
|
httpOnly: true,
|
|
29
30
|
// Only use secure cookies in production with HTTPS
|
|
30
|
-
secure:
|
|
31
|
+
secure: getCookieSecure(),
|
|
31
32
|
sameSite: 'lax',
|
|
32
33
|
path: '/'
|
|
33
34
|
},
|
|
@@ -37,16 +38,23 @@ export const sessionConfig = {
|
|
|
37
38
|
const authRepo = new AuthRepository({ db: dblogin });
|
|
38
39
|
const hasAuthorizationHeader = (req) => typeof req.headers?.authorization === 'string' && req.headers.authorization.trim().length > 0;
|
|
39
40
|
|
|
41
|
+
export function securityHeadersMiddleware(req, res, next) {
|
|
42
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
43
|
+
res.setHeader("X-Frame-Options", "SAMEORIGIN");
|
|
44
|
+
res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
45
|
+
if (getCookieSecure()) {
|
|
46
|
+
res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
47
|
+
}
|
|
48
|
+
next();
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
// CORS middleware
|
|
41
52
|
export function corsMiddleware(req, res, next) {
|
|
42
53
|
const origin = req.headers.origin;
|
|
43
54
|
if (origin) {
|
|
44
55
|
try {
|
|
45
56
|
const originUrl = new URL(origin);
|
|
46
|
-
|
|
47
|
-
// Exact match or subdomain match
|
|
48
|
-
if (originUrl.hostname === mbkautheVar.DOMAIN ||
|
|
49
|
-
(originUrl.hostname.endsWith(allowedDomain) && originUrl.hostname.charAt(originUrl.hostname.length - allowedDomain.length - 1) !== '.')) {
|
|
57
|
+
if (isAllowedOriginHostname(originUrl.hostname)) {
|
|
50
58
|
res.header('Access-Control-Allow-Origin', origin);
|
|
51
59
|
res.header('Access-Control-Allow-Credentials', 'true');
|
|
52
60
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
|
|
@@ -73,13 +81,7 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
73
81
|
// Early validation to avoid unnecessary processing (expect DB UUID id)
|
|
74
82
|
if (!sessionId || 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)) {
|
|
75
83
|
// Clear invalid cookie to prevent repeated attempts
|
|
76
|
-
res.clearCookie('sessionId',
|
|
77
|
-
domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
|
|
78
|
-
path: '/',
|
|
79
|
-
httpOnly: true,
|
|
80
|
-
secure: mbkautheVar.IS_DEPLOYED === 'true',
|
|
81
|
-
sameSite: 'lax'
|
|
82
|
-
});
|
|
84
|
+
res.clearCookie('sessionId', cachedClearCookieOptions);
|
|
83
85
|
return next();
|
|
84
86
|
}
|
|
85
87
|
|
|
@@ -91,10 +93,12 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
91
93
|
// Reject expired sessions or inactive users
|
|
92
94
|
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
|
|
93
95
|
// leave cookies cleared and don't restore session
|
|
96
|
+
} else if (!isUserAuthorizedForApp(row.Role, row.AllowedApps)) {
|
|
97
|
+
// Session exists but user is not authorized for this app
|
|
94
98
|
} else {
|
|
95
99
|
const normalizedSessionId = String(sessionId);
|
|
96
100
|
req.session.user = {
|
|
97
|
-
|
|
101
|
+
userId: row.UserId || undefined,
|
|
98
102
|
username: row.UserName,
|
|
99
103
|
role: row.Role,
|
|
100
104
|
sessionId: normalizedSessionId,
|
|
@@ -122,11 +126,7 @@ export function sessionCookieSyncMiddleware(req, res, next) {
|
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
if (req.session && req.session.user) {
|
|
125
|
-
|
|
126
|
-
const currentDecryptedId = decryptSessionId(req.cookies.sessionId);
|
|
127
|
-
|
|
128
|
-
// Only set cookies if they're missing or different
|
|
129
|
-
if (currentDecryptedId !== req.session.user.sessionId) {
|
|
129
|
+
if (!req.cookies.sessionId) {
|
|
130
130
|
res.cookie("fullName", req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
|
|
131
131
|
const encryptedSessionId = encryptSessionId(req.session.user.sessionId);
|
|
132
132
|
if (encryptedSessionId) {
|
package/lib/routes/auth.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import crypto from "crypto";
|
|
3
2
|
import csurf from "csurf";
|
|
4
3
|
import speakeasy from "speakeasy";
|
|
5
4
|
import rateLimit from 'express-rate-limit';
|
|
@@ -9,10 +8,10 @@ import {
|
|
|
9
8
|
cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
|
|
10
9
|
generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS, hashDeviceToken,
|
|
11
10
|
upsertAccountListCookie, readAccountListFromCookie, removeAccountFromCookie, clearAccountListCookie,
|
|
12
|
-
encryptSessionId
|
|
11
|
+
encryptSessionId, getCookieDomain
|
|
13
12
|
} from "#cookies.js";
|
|
14
13
|
import { packageJson } from "#config.js";
|
|
15
|
-
import { hashPassword } from "#config.js";
|
|
14
|
+
import { hashPassword, verifyPassword } from "#config.js";
|
|
16
15
|
import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
|
|
17
16
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
18
17
|
import { createLogger } from "../utils/logger.js";
|
|
@@ -78,7 +77,7 @@ async function fetchActiveSession(sessionId) {
|
|
|
78
77
|
if (row.Role !== 'SuperAdmin') {
|
|
79
78
|
const allowedApps = row.AllowedApps;
|
|
80
79
|
const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
|
|
81
|
-
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
80
|
+
if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
82
81
|
return null;
|
|
83
82
|
}
|
|
84
83
|
}
|
|
@@ -109,7 +108,6 @@ export async function checkTrustedDevice(req, username) {
|
|
|
109
108
|
try {
|
|
110
109
|
// Hash the provided device token before querying DB (we store token hashes in DB)
|
|
111
110
|
const deviceTokenHash = hashDeviceToken(deviceToken);
|
|
112
|
-
// Single round-trip: validate trusted device AND refresh LastUsed.
|
|
113
111
|
const deviceUser = await authRepo.touchTrustedDevice(deviceTokenHash, username);
|
|
114
112
|
|
|
115
113
|
if (deviceUser) {
|
|
@@ -121,7 +119,7 @@ export async function checkTrustedDevice(req, username) {
|
|
|
121
119
|
|
|
122
120
|
if (deviceUser.Role !== "SuperAdmin") {
|
|
123
121
|
const allowedApps = deviceUser.AllowedApps;
|
|
124
|
-
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
122
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
125
123
|
console.warn(`[mbkauthe] Trusted device check: User "${username}" is not authorized to use the application "${mbkautheVar.APP_NAME}"`);
|
|
126
124
|
return null;
|
|
127
125
|
}
|
|
@@ -129,7 +127,7 @@ export async function checkTrustedDevice(req, username) {
|
|
|
129
127
|
|
|
130
128
|
logAuth(`Trusted device validated for user: ${username}`);
|
|
131
129
|
return {
|
|
132
|
-
|
|
130
|
+
userId: deviceUser.UserId || undefined,
|
|
133
131
|
username: username,
|
|
134
132
|
role: deviceUser.Role,
|
|
135
133
|
allowedApps: deviceUser.AllowedApps,
|
|
@@ -205,13 +203,13 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
205
203
|
// Update last_login and fetch FullName/Image in a single query.
|
|
206
204
|
let profileRow = null;
|
|
207
205
|
try {
|
|
208
|
-
profileRow = await authRepo.updateLastLoginReturnProfile(
|
|
206
|
+
profileRow = await authRepo.updateLastLoginReturnProfile(username);
|
|
209
207
|
} catch (profileUpdateErr) {
|
|
210
208
|
console.error(`[mbkauthe] Error updating last_login/returning profile:`, profileUpdateErr);
|
|
211
209
|
}
|
|
212
210
|
|
|
213
211
|
req.session.user = {
|
|
214
|
-
|
|
212
|
+
userId: user.userId || user.UserId || undefined,
|
|
215
213
|
username: username,
|
|
216
214
|
role: user.role || user.Role,
|
|
217
215
|
sessionId: dbSessionId,
|
|
@@ -311,7 +309,6 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
311
309
|
const responsePayload = {
|
|
312
310
|
success: true,
|
|
313
311
|
message: "Login successful",
|
|
314
|
-
sessionId: dbSessionId,
|
|
315
312
|
};
|
|
316
313
|
|
|
317
314
|
if (redirectUrl) {
|
|
@@ -376,10 +373,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
376
373
|
// Password verification (hash-only). We never read/compare plaintext passwords.
|
|
377
374
|
let passwordMatches = false;
|
|
378
375
|
if (user.PasswordEnc) {
|
|
379
|
-
|
|
380
|
-
const stored = Buffer.from(String(user.PasswordEnc), 'utf8');
|
|
381
|
-
const computed = Buffer.from(String(hashedInputPassword), 'utf8');
|
|
382
|
-
passwordMatches = stored.length === computed.length && crypto.timingSafeEqual(stored, computed);
|
|
376
|
+
passwordMatches = await verifyPassword(password, user.UserName, user.PasswordEnc);
|
|
383
377
|
}
|
|
384
378
|
|
|
385
379
|
if (!passwordMatches) {
|
|
@@ -398,7 +392,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
398
392
|
|
|
399
393
|
if (user.Role !== "SuperAdmin") {
|
|
400
394
|
const allowedApps = user.AllowedApps;
|
|
401
|
-
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
395
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
402
396
|
logError('Login attempt', ErrorCodes.APP_NOT_AUTHORIZED, {
|
|
403
397
|
username: user.UserName,
|
|
404
398
|
app: mbkautheVar.APP_NAME
|
|
@@ -418,7 +412,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
418
412
|
logAuth(`Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
|
|
419
413
|
|
|
420
414
|
const userForSession = {
|
|
421
|
-
|
|
415
|
+
userId: user.UserId || undefined,
|
|
422
416
|
username: user.UserName,
|
|
423
417
|
role: user.Role,
|
|
424
418
|
allowedApps: user.AllowedApps,
|
|
@@ -431,7 +425,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
431
425
|
// 2FA is enabled, prompt for token on a separate page
|
|
432
426
|
const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
|
|
433
427
|
req.session.preAuthUser = {
|
|
434
|
-
|
|
428
|
+
userId: user.UserId || undefined,
|
|
435
429
|
username: user.UserName,
|
|
436
430
|
role: user.Role,
|
|
437
431
|
allowedApps: user.AllowedApps,
|
|
@@ -443,7 +437,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
443
437
|
|
|
444
438
|
// If 2FA is not enabled, proceed with login
|
|
445
439
|
const userForSession = {
|
|
446
|
-
|
|
440
|
+
userId: user.UserId || undefined,
|
|
447
441
|
username: user.UserName,
|
|
448
442
|
role: user.Role,
|
|
449
443
|
allowedApps: user.AllowedApps,
|
|
@@ -476,7 +470,7 @@ router.get("/2fa", csrfProtection, (req, res) => {
|
|
|
476
470
|
layout: false,
|
|
477
471
|
customURL: redirectToUse,
|
|
478
472
|
csrfToken: req.csrfToken(),
|
|
479
|
-
appName: mbkautheVar.APP_NAME
|
|
473
|
+
appName: mbkautheVar.APP_NAME,
|
|
480
474
|
version: packageJson.version,
|
|
481
475
|
DEVICE_TRUST_DURATION_DAYS: mbkautheVar.DEVICE_TRUST_DURATION_DAYS
|
|
482
476
|
});
|
|
@@ -493,7 +487,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
493
487
|
}
|
|
494
488
|
|
|
495
489
|
const { token, trustDevice } = req.body;
|
|
496
|
-
const { username,
|
|
490
|
+
const { username, role, userId } = req.session.preAuthUser;
|
|
497
491
|
|
|
498
492
|
// Validate 2FA token
|
|
499
493
|
if (!token || typeof token !== 'string') {
|
|
@@ -541,7 +535,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
541
535
|
}
|
|
542
536
|
|
|
543
537
|
// 2FA successful, complete login with optional device trust
|
|
544
|
-
const userForSession = {
|
|
538
|
+
const userForSession = { userId, username, role, allowedApps };
|
|
545
539
|
// Prefer redirect stored in preAuthUser or in query/body, fallback to configured default
|
|
546
540
|
let redirectFromSession = req.session.preAuthUser && req.session.preAuthUser.redirectUrl ? req.session.preAuthUser.redirectUrl : null;
|
|
547
541
|
if (redirectFromSession && (!(typeof redirectFromSession === 'string') || !redirectFromSession.startsWith('/') || redirectFromSession.startsWith('//'))) {
|
|
@@ -564,7 +558,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
564
558
|
router.post("/api/logout", LogoutLimit, async (req, res) => {
|
|
565
559
|
if (req.session.user) {
|
|
566
560
|
try {
|
|
567
|
-
const {
|
|
561
|
+
const { username } = req.session.user;
|
|
568
562
|
|
|
569
563
|
// Clear profile picture cache
|
|
570
564
|
clearProfilePicCache(req, username);
|
|
@@ -636,7 +630,7 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
|
|
|
636
630
|
const expired = row?.expires_at && new Date(row.expires_at) <= new Date();
|
|
637
631
|
const authorized = row && row.Active && (
|
|
638
632
|
row.Role === "SuperAdmin" ||
|
|
639
|
-
(Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
633
|
+
(Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME))
|
|
640
634
|
);
|
|
641
635
|
|
|
642
636
|
if (!row || expired || !authorized) {
|
|
@@ -692,7 +686,7 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
|
|
|
692
686
|
});
|
|
693
687
|
|
|
694
688
|
req.session.user = {
|
|
695
|
-
|
|
689
|
+
userId: row.UserId || undefined,
|
|
696
690
|
username: row.UserName,
|
|
697
691
|
role: row.Role,
|
|
698
692
|
sessionId: row.sid,
|
|
@@ -771,10 +765,11 @@ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
|
|
|
771
765
|
githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
|
|
772
766
|
googleLoginEnabled: mbkautheVar.GOOGLE_LOGIN_ENABLED,
|
|
773
767
|
customURL: mbkautheVar.loginRedirectURL || '/dashboard',
|
|
768
|
+
cookieDomain: getCookieDomain() || '',
|
|
774
769
|
userLoggedIn: !!req.session?.user,
|
|
775
770
|
username: req.session?.user?.username || '',
|
|
776
771
|
version: packageJson.version,
|
|
777
|
-
appName: mbkautheVar.APP_NAME
|
|
772
|
+
appName: mbkautheVar.APP_NAME,
|
|
778
773
|
csrfToken: req.csrfToken(),
|
|
779
774
|
// Last-login method flags for immediate server-side badge rendering
|
|
780
775
|
lastLoginMethod: lastLogin,
|
|
@@ -797,7 +792,7 @@ router.get("/accounts", LoginLimit, csrfProtection, (req, res) => {
|
|
|
797
792
|
layout: false,
|
|
798
793
|
customURL: safeRedirect,
|
|
799
794
|
version: packageJson.version,
|
|
800
|
-
appName: mbkautheVar.APP_NAME
|
|
795
|
+
appName: mbkautheVar.APP_NAME,
|
|
801
796
|
csrfToken: req.csrfToken(),
|
|
802
797
|
userLoggedIn: !!req.session?.user,
|
|
803
798
|
username: req.session?.user?.username,
|
package/lib/routes/misc.js
CHANGED
|
@@ -6,8 +6,9 @@ import { renderError, renderPage } from "#response.js";
|
|
|
6
6
|
import { authenticate, sessVal, sessRole } from "../middleware/auth.js";
|
|
7
7
|
import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js";
|
|
8
8
|
import { dblogin } from "#pool.js";
|
|
9
|
-
import { clearSessionCookies, decryptSessionId, cachedCookieOptions } from "#cookies.js";
|
|
9
|
+
import { clearSessionCookies, decryptSessionId, cachedCookieOptions, getCookieDomain } from "#cookies.js";
|
|
10
10
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
11
|
+
import { isSafeFetchUrl } from "../utils/urlSafety.js";
|
|
11
12
|
import { fileURLToPath } from "url";
|
|
12
13
|
import path from "path";
|
|
13
14
|
import fs from "fs";
|
|
@@ -22,6 +23,23 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
22
23
|
const router = express.Router();
|
|
23
24
|
const authRepo = new AuthRepository({ db: dblogin });
|
|
24
25
|
const logMisc = createLogger("misc");
|
|
26
|
+
const PROFILE_IMAGE_CACHE_SECONDS = 300;
|
|
27
|
+
const PROFILE_IMAGE_CACHE_CONTROL = `private, max-age=${PROFILE_IMAGE_CACHE_SECONDS}, stale-while-revalidate=${PROFILE_IMAGE_CACHE_SECONDS}`;
|
|
28
|
+
const LATEST_VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
const LATEST_VERSION_FAILURE_CACHE_TTL_MS = 60 * 1000;
|
|
30
|
+
const latestVersionCache = {
|
|
31
|
+
value: null,
|
|
32
|
+
expiresAt: 0,
|
|
33
|
+
pending: null
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function setProfileImageCacheHeaders(res, eTag = null) {
|
|
37
|
+
res.setHeader('Cache-Control', PROFILE_IMAGE_CACHE_CONTROL);
|
|
38
|
+
if (eTag) {
|
|
39
|
+
res.setHeader('ETag', eTag);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
25
43
|
// Rate limiter for info/test routes
|
|
26
44
|
const LoginLimit = rateLimit({
|
|
27
45
|
windowMs: 1 * 60 * 1000,
|
|
@@ -48,9 +66,26 @@ const AdminOperationLimit = rateLimit({
|
|
|
48
66
|
});
|
|
49
67
|
|
|
50
68
|
// Static file routes
|
|
69
|
+
const mainJsPath = path.join(__dirname, '..', '..', 'public', 'main.js');
|
|
70
|
+
let mainJsSource = null;
|
|
71
|
+
|
|
72
|
+
const getMainJsSource = () => {
|
|
73
|
+
if (mainJsSource === null) {
|
|
74
|
+
mainJsSource = fs.readFileSync(mainJsPath, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
return mainJsSource;
|
|
77
|
+
};
|
|
78
|
+
|
|
51
79
|
router.get('/main.js', (req, res) => {
|
|
80
|
+
const clientConfig = JSON.stringify({
|
|
81
|
+
cookieDomain: getCookieDomain() || null,
|
|
82
|
+
domain: mbkautheVar.DOMAIN,
|
|
83
|
+
isDeployed: mbkautheVar.IS_DEPLOYED === 'true'
|
|
84
|
+
});
|
|
85
|
+
const injectedSource = `window.mbkautheConfig=${clientConfig};\n${getMainJsSource()}`;
|
|
86
|
+
|
|
52
87
|
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
|
53
|
-
res.
|
|
88
|
+
res.type('application/javascript').send(injectedSource);
|
|
54
89
|
});
|
|
55
90
|
|
|
56
91
|
router.get('/main.css', (req, res) => {
|
|
@@ -76,9 +111,8 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
76
111
|
const serveDefaultIcon = () => {
|
|
77
112
|
const iconPath = path.join(__dirname, "..", "..", "public", "M.png");
|
|
78
113
|
res.setHeader('Content-Type', 'image/png');
|
|
79
|
-
// Ensure we don't override the Cache-Control we set earlier, or set a default if not set
|
|
80
114
|
if (!res.getHeader('Cache-Control')) {
|
|
81
|
-
res
|
|
115
|
+
setProfileImageCacheHeaders(res);
|
|
82
116
|
}
|
|
83
117
|
const stream = fs.createReadStream(iconPath);
|
|
84
118
|
stream.on('error', (err) => {
|
|
@@ -118,9 +152,7 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
118
152
|
// Generate ETag based on username and image URL
|
|
119
153
|
const eTag = `"${Buffer.from(username + ':' + imageUrl).toString('base64')}"`;
|
|
120
154
|
|
|
121
|
-
|
|
122
|
-
res.setHeader('Cache-Control', 'private, no-cache');
|
|
123
|
-
res.setHeader('ETag', eTag);
|
|
155
|
+
setProfileImageCacheHeaders(res, eTag);
|
|
124
156
|
|
|
125
157
|
// Check for conditional request
|
|
126
158
|
if (req.headers['if-none-match'] === eTag) {
|
|
@@ -131,6 +163,13 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
131
163
|
return serveDefaultIcon();
|
|
132
164
|
}
|
|
133
165
|
|
|
166
|
+
if (!isSafeFetchUrl(imageUrl)) {
|
|
167
|
+
console.warn(`[mbkauthe] Blocked unsafe profile image URL for user ${username}`);
|
|
168
|
+
res.cookie('profileImageUrl', 'default', { ...cachedCookieOptions, httpOnly: false });
|
|
169
|
+
res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false });
|
|
170
|
+
return serveDefaultIcon();
|
|
171
|
+
}
|
|
172
|
+
|
|
134
173
|
// Fetch and stream the image
|
|
135
174
|
try {
|
|
136
175
|
const imageResponse = await fetch(imageUrl, {
|
|
@@ -173,7 +212,7 @@ if (process.env.env === 'dev') {
|
|
|
173
212
|
success: true,
|
|
174
213
|
message: 'SuperAdmin access granted',
|
|
175
214
|
user: user ? {
|
|
176
|
-
|
|
215
|
+
userId: user.userId,
|
|
177
216
|
username: user.username,
|
|
178
217
|
role: user.role,
|
|
179
218
|
sessionId: user.sessionId
|
|
@@ -188,7 +227,7 @@ if (process.env.env === 'dev') {
|
|
|
188
227
|
|
|
189
228
|
// Test route
|
|
190
229
|
router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
|
|
191
|
-
const { username, fullname, role,
|
|
230
|
+
const { username, fullname, role, userId, sessionId, allowedApps } = req.session.user;
|
|
192
231
|
|
|
193
232
|
const sessionExpiry = req.session.cookie?.expires
|
|
194
233
|
? new Date(req.session.cookie.expires).toISOString()
|
|
@@ -198,7 +237,7 @@ router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
|
|
|
198
237
|
username,
|
|
199
238
|
fullname: fullname || 'N/A',
|
|
200
239
|
role,
|
|
201
|
-
|
|
240
|
+
userId: userId || 'N/A',
|
|
202
241
|
sessionIdShort: sessionId.slice(0, 8),
|
|
203
242
|
profilePicUrl: encodeURIComponent(username),
|
|
204
243
|
displayName: fullname || username,
|
|
@@ -221,7 +260,7 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
|
221
260
|
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
222
261
|
}
|
|
223
262
|
|
|
224
|
-
const {
|
|
263
|
+
const { sessionId } = req.session.user;
|
|
225
264
|
if (!sessionId) {
|
|
226
265
|
req.session.destroy(() => { });
|
|
227
266
|
clearSessionCookies(res);
|
|
@@ -345,7 +384,7 @@ router.post('/api/verifySession', LoginLimit, async (req, res) => {
|
|
|
345
384
|
}
|
|
346
385
|
|
|
347
386
|
const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
|
|
348
|
-
return res.status(200).json({ valid: true, expiry
|
|
387
|
+
return res.status(200).json({ valid: true, expiry });
|
|
349
388
|
} catch (err) {
|
|
350
389
|
console.error(`[mbkauthe] verifySession error:`, err);
|
|
351
390
|
return res.status(200).json({ valid: false, expiry: null });
|
|
@@ -450,20 +489,44 @@ router.get("/ErrorCode", (req, res) => {
|
|
|
450
489
|
}
|
|
451
490
|
});
|
|
452
491
|
|
|
453
|
-
// Fetch latest version from GitHub
|
|
454
|
-
export async function getLatestVersion() {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
492
|
+
// Fetch latest version from GitHub with a short in-memory cache.
|
|
493
|
+
export async function getLatestVersion({ forceRefresh = false } = {}) {
|
|
494
|
+
const now = Date.now();
|
|
495
|
+
|
|
496
|
+
if (!forceRefresh && latestVersionCache.expiresAt > now) {
|
|
497
|
+
return latestVersionCache.value;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (!forceRefresh && latestVersionCache.pending) {
|
|
501
|
+
return latestVersionCache.pending;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
latestVersionCache.pending = (async () => {
|
|
505
|
+
try {
|
|
506
|
+
const response = await fetch('https://raw.githubusercontent.com/MIbnEKhalid/mbkauthe/main/package.json');
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
console.error(`[mbkauthe] GitHub API responded with status ${response.status}`);
|
|
509
|
+
latestVersionCache.value = null;
|
|
510
|
+
latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_FAILURE_CACHE_TTL_MS;
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const latestPackageJson = await response.json();
|
|
515
|
+
const latestVersion = typeof latestPackageJson.version === 'string' ? latestPackageJson.version : null;
|
|
516
|
+
latestVersionCache.value = latestVersion;
|
|
517
|
+
latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_CACHE_TTL_MS;
|
|
518
|
+
return latestVersion;
|
|
519
|
+
} catch (error) {
|
|
520
|
+
console.error(`[mbkauthe] Error fetching latest version from GitHub`, error);
|
|
521
|
+
latestVersionCache.value = null;
|
|
522
|
+
latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_FAILURE_CACHE_TTL_MS;
|
|
459
523
|
return null;
|
|
524
|
+
} finally {
|
|
525
|
+
latestVersionCache.pending = null;
|
|
460
526
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
console.error(`[mbkauthe] Error fetching latest version from GitHub`, error);
|
|
465
|
-
return null;
|
|
466
|
-
}
|
|
527
|
+
})();
|
|
528
|
+
|
|
529
|
+
return latestVersionCache.pending;
|
|
467
530
|
}
|
|
468
531
|
|
|
469
532
|
// Version check with error handling
|
package/lib/routes/oauth.js
CHANGED
|
@@ -66,7 +66,7 @@ const createOAuthStrategy = async (provider, profile, done) => {
|
|
|
66
66
|
// Check if user is authorized for this app
|
|
67
67
|
if (user.Role !== "SuperAdmin") {
|
|
68
68
|
const allowedApps = user.AllowedApps;
|
|
69
|
-
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME
|
|
69
|
+
if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
|
|
70
70
|
const error = new Error(`Not authorized to use ${mbkautheVar.APP_NAME}`);
|
|
71
71
|
error.code = 'NOT_AUTHORIZED';
|
|
72
72
|
return done(error);
|
|
@@ -75,7 +75,7 @@ const createOAuthStrategy = async (provider, profile, done) => {
|
|
|
75
75
|
|
|
76
76
|
// Return user data for login
|
|
77
77
|
const userData = {
|
|
78
|
-
|
|
78
|
+
userId: user.UserId || undefined,
|
|
79
79
|
username: user.UserName,
|
|
80
80
|
role: user.Role,
|
|
81
81
|
allowedApps: user.AllowedApps,
|
|
@@ -309,7 +309,7 @@ const finishProviderLogin = async (req, res, provider, user, detailValue = '') =
|
|
|
309
309
|
const oauthRedirect = req.session.oauthRedirect;
|
|
310
310
|
if (oauthRedirect) delete req.session.oauthRedirect;
|
|
311
311
|
req.session.preAuthUser = {
|
|
312
|
-
|
|
312
|
+
userId: user.UserId || user.userId || undefined,
|
|
313
313
|
username: user.UserName,
|
|
314
314
|
role: user.Role,
|
|
315
315
|
allowedApps: user.AllowedApps,
|
|
@@ -364,7 +364,7 @@ const createOAuthCallback = (provider, strategy) => {
|
|
|
364
364
|
res,
|
|
365
365
|
provider,
|
|
366
366
|
{
|
|
367
|
-
|
|
367
|
+
UserId: oauthUser.userId,
|
|
368
368
|
UserName: oauthUser.username,
|
|
369
369
|
Role: oauthUser.role,
|
|
370
370
|
AllowedApps: oauthUser.allowedApps,
|
|
@@ -389,7 +389,7 @@ const createOAuthCallback = (provider, strategy) => {
|
|
|
389
389
|
// Helper function to handle OAuth redirect flow
|
|
390
390
|
const handleOAuthRedirect = async (req, res, user, type, method = null) => {
|
|
391
391
|
const userForSession = {
|
|
392
|
-
|
|
392
|
+
userId: user.UserId || user.userId || undefined,
|
|
393
393
|
username: user.UserName,
|
|
394
394
|
role: user.Role,
|
|
395
395
|
allowedApps: user.AllowedApps,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { mbkautheVar } from "#config.js";
|
|
2
|
+
|
|
3
|
+
export function isUserAuthorizedForApp(role, allowedApps) {
|
|
4
|
+
if (role === "SuperAdmin") return true;
|
|
5
|
+
return Array.isArray(allowedApps)
|
|
6
|
+
&& allowedApps.length > 0
|
|
7
|
+
&& allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME);
|
|
8
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that a redirect target is a safe same-origin relative path.
|
|
3
|
+
*/
|
|
4
|
+
export function isSafeRelativeRedirect(value) {
|
|
5
|
+
if (typeof value !== 'string') return false;
|
|
6
|
+
const trimmed = value.trim();
|
|
7
|
+
if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return false;
|
|
8
|
+
if (trimmed.includes('://') || trimmed.includes('\\')) return false;
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sanitizeRelativeRedirect(value) {
|
|
13
|
+
return isSafeRelativeRedirect(value) ? value.trim() : null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mbkautheVar } from "#config.js";
|
|
2
|
+
|
|
3
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
4
|
+
'localhost',
|
|
5
|
+
'0.0.0.0',
|
|
6
|
+
'metadata.google.internal',
|
|
7
|
+
'metadata',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function isPrivateIpv4(hostname) {
|
|
11
|
+
const parts = hostname.split('.').map((part) => Number(part));
|
|
12
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const [a, b] = parts;
|
|
17
|
+
if (a === 10) return true;
|
|
18
|
+
if (a === 127) return true;
|
|
19
|
+
if (a === 0) return true;
|
|
20
|
+
if (a === 169 && b === 254) return true;
|
|
21
|
+
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
22
|
+
if (a === 192 && b === 168) return true;
|
|
23
|
+
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isPrivateIpv6(hostname) {
|
|
28
|
+
const normalized = hostname.toLowerCase();
|
|
29
|
+
if (normalized === '::1' || normalized === '::') return true;
|
|
30
|
+
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
|
|
31
|
+
if (normalized.startsWith('fe80:')) return true;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isBlockedHostname(hostname) {
|
|
36
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, '');
|
|
37
|
+
if (BLOCKED_HOSTNAMES.has(normalized)) return true;
|
|
38
|
+
if (normalized.endsWith('.localhost') || normalized.endsWith('.local')) return true;
|
|
39
|
+
if (normalized.includes(':') && isPrivateIpv6(normalized)) return true;
|
|
40
|
+
if (isPrivateIpv4(normalized)) return true;
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns true when a URL is safe for server-side fetch (profile images, etc.).
|
|
46
|
+
*/
|
|
47
|
+
export function isSafeFetchUrl(urlString) {
|
|
48
|
+
if (!urlString || typeof urlString !== 'string' || urlString === 'default') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = new URL(urlString);
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const allowHttpInDev = mbkautheVar.IS_DEPLOYED !== 'true';
|
|
60
|
+
const allowedProtocols = allowHttpInDev ? ['https:', 'http:'] : ['https:'];
|
|
61
|
+
if (!allowedProtocols.includes(parsed.protocol)) return false;
|
|
62
|
+
if (parsed.username || parsed.password) return false;
|
|
63
|
+
if (!parsed.hostname) return false;
|
|
64
|
+
if (isBlockedHostname(parsed.hostname)) return false;
|
|
65
|
+
|
|
66
|
+
return true;
|
|
67
|
+
}
|