mbkauthe 4.8.0 → 4.8.1
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 +1 -1
- package/docs/api.md +1 -1
- package/docs/db.md +2 -2
- package/docs/db.sql +19 -1
- package/index.d.ts +3 -0
- package/index.js +59 -94
- package/lib/middleware/auth.js +48 -29
- package/lib/pool.js +1 -0
- package/lib/routes/auth.js +57 -43
- package/lib/routes/misc.js +21 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
- Multi-session support (configurable concurrent sessions per user)
|
|
24
24
|
- Optional TOTP-based 2FA with trusted devices
|
|
25
25
|
- Social login (GitHub App & Google OAuth)
|
|
26
|
-
- Role-based access: SuperAdmin, NormalUser, Guest
|
|
26
|
+
- Role-based access: SuperAdmin, NormalUser, Guest, member
|
|
27
27
|
- CSRF protection & rate limiting
|
|
28
28
|
- Easy Express.js integration
|
|
29
29
|
- Customizable Handlebars templates
|
package/docs/api.md
CHANGED
|
@@ -1474,7 +1474,7 @@ These cookies allow front-end UI to display a friendly name without making extra
|
|
|
1474
1474
|
Checks if the authenticated user has the required role.
|
|
1475
1475
|
|
|
1476
1476
|
**Parameters:**
|
|
1477
|
-
- `requiredRole` (string) - Required role: `"SuperAdmin"`, `"NormalUser"`, `"Guest"`, or `"Any"`/`"any"`
|
|
1477
|
+
- `requiredRole` (string) - Required role: `"SuperAdmin"`, `"NormalUser"`, `"Guest"`, `"member"`, or `"Any"`/`"any"`
|
|
1478
1478
|
- `notAllowed` (string, optional) - Role that is explicitly not allowed
|
|
1479
1479
|
|
|
1480
1480
|
**Usage:**
|
package/docs/db.md
CHANGED
|
@@ -12,7 +12,7 @@ The project uses a Postgres `ENUM` type for user roles:
|
|
|
12
12
|
DO $$
|
|
13
13
|
BEGIN
|
|
14
14
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN
|
|
15
|
-
CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest');
|
|
15
|
+
CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest', 'member');
|
|
16
16
|
END IF;
|
|
17
17
|
END
|
|
18
18
|
$$;
|
|
@@ -319,7 +319,7 @@ To add new users to the `Users` table, use the following SQL queries:
|
|
|
319
319
|
- Replace `support` and `test` with the desired usernames.
|
|
320
320
|
- For raw passwords: Replace `12345678` with the actual plain text passwords.
|
|
321
321
|
- For encrypted passwords: Use the hashPassword function to generate the hash before inserting.
|
|
322
|
-
- Adjust the `Role` values as needed (`SuperAdmin`, `NormalUser`, or `
|
|
322
|
+
- Adjust the `Role` values as needed (`SuperAdmin`, `NormalUser`, `Guest`, or `member`).
|
|
323
323
|
- Modify the `Active` and `HaveMailAccount` values as required.
|
|
324
324
|
|
|
325
325
|
**Generating Encrypted Passwords:**
|
package/docs/db.sql
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
DO $$
|
|
3
3
|
BEGIN
|
|
4
4
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN
|
|
5
|
-
CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest');
|
|
5
|
+
CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest', 'member');
|
|
6
6
|
END IF;
|
|
7
7
|
END
|
|
8
8
|
$$;
|
|
@@ -94,6 +94,24 @@ CREATE TABLE IF NOT EXISTS "Sessions" (
|
|
|
94
94
|
CREATE INDEX IF NOT EXISTS idx_sessions_username ON "Sessions" ("UserName");
|
|
95
95
|
CREATE INDEX IF NOT EXISTS idx_sessions_user_created ON "Sessions" ("UserName", created_at);
|
|
96
96
|
|
|
97
|
+
-- Support expiry-based cleanup and validity checks
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_username_expires
|
|
99
|
+
ON "Sessions" ("UserName", expires_at);
|
|
100
|
+
|
|
101
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires
|
|
102
|
+
ON "Sessions" (expires_at)
|
|
103
|
+
WHERE expires_at IS NOT NULL;
|
|
104
|
+
|
|
105
|
+
-- Optional (Postgres 11+): covering indexes for hot-path lookups (validateSession)
|
|
106
|
+
-- These can enable index-only scans for the exact columns used in auth middleware.
|
|
107
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_id_cover
|
|
108
|
+
ON "Sessions" (id)
|
|
109
|
+
INCLUDE ("UserName", expires_at);
|
|
110
|
+
|
|
111
|
+
CREATE INDEX IF NOT EXISTS idx_users_username_cover
|
|
112
|
+
ON "Users" ("UserName")
|
|
113
|
+
INCLUDE ("Active", "Role");
|
|
114
|
+
|
|
97
115
|
|
|
98
116
|
CREATE TABLE IF NOT EXISTS "session" (
|
|
99
117
|
sid VARCHAR(33) PRIMARY KEY NOT NULL,
|
package/index.d.ts
CHANGED
|
@@ -242,6 +242,9 @@ declare module 'mbkauthe' {
|
|
|
242
242
|
notAllowed?: UserRole
|
|
243
243
|
): AuthMiddleware;
|
|
244
244
|
|
|
245
|
+
export const sessVal: AuthMiddleware;
|
|
246
|
+
export const sessRole: AuthMiddleware;
|
|
247
|
+
|
|
245
248
|
export const strictValidateSession: AuthMiddleware;
|
|
246
249
|
|
|
247
250
|
export function strictValidateSessionAndRole(
|
package/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import router from "./lib/main.js";
|
|
3
|
-
import { checkVersion } from "./lib/main.js";
|
|
2
|
+
import router, { checkVersion } from "./lib/main.js";
|
|
4
3
|
import { engine } from "express-handlebars";
|
|
5
4
|
import path from "path";
|
|
6
5
|
import { fileURLToPath } from "url";
|
|
@@ -9,129 +8,95 @@ import { packageJson } from "#config.js";
|
|
|
9
8
|
|
|
10
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
11
10
|
const __dirname = path.dirname(__filename);
|
|
11
|
+
const isDevMode = process.env.test === "dev";
|
|
12
|
+
const DEV_PORT = 5555;
|
|
13
|
+
const viewsPath = path.join(__dirname, "views");
|
|
14
|
+
const packageVersion = packageJson.version;
|
|
12
15
|
|
|
13
16
|
const app = express();
|
|
14
17
|
|
|
15
18
|
app.set("views", [
|
|
16
|
-
|
|
19
|
+
viewsPath,
|
|
17
20
|
path.join(__dirname, "node_modules/mbkauthe/views")
|
|
18
21
|
]);
|
|
19
22
|
|
|
23
|
+
const handlebarsHelpers = {
|
|
24
|
+
eq: (a, b) => a === b,
|
|
25
|
+
encodeURIComponent: (str) => encodeURIComponent(str),
|
|
26
|
+
formatTimestamp: (timestamp) => new Date(timestamp).toLocaleString(),
|
|
27
|
+
jsonStringify: (context) => JSON.stringify(context),
|
|
28
|
+
json: (obj) => JSON.stringify(obj, null, 2),
|
|
29
|
+
objectEntries: (obj) => {
|
|
30
|
+
if (!obj || typeof obj !== 'object') return [];
|
|
31
|
+
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
|
32
|
+
},
|
|
33
|
+
cacheBuster: () => `?v=${packageVersion}`
|
|
34
|
+
};
|
|
35
|
+
|
|
20
36
|
app.engine("handlebars", engine({
|
|
21
37
|
defaultLayout: false,
|
|
22
38
|
cache: true,
|
|
23
39
|
partialsDir: [
|
|
24
|
-
|
|
40
|
+
viewsPath,
|
|
25
41
|
path.join(__dirname, "node_modules/mbkauthe/views"),
|
|
26
42
|
path.join(__dirname, "node_modules/mbkauthe/views/Error"),
|
|
27
43
|
],
|
|
28
|
-
helpers:
|
|
29
|
-
eq: function (a, b) {
|
|
30
|
-
return a === b;
|
|
31
|
-
},
|
|
32
|
-
encodeURIComponent: function (str) {
|
|
33
|
-
return encodeURIComponent(str);
|
|
34
|
-
},
|
|
35
|
-
formatTimestamp: function (timestamp) {
|
|
36
|
-
return new Date(timestamp).toLocaleString();
|
|
37
|
-
},
|
|
38
|
-
jsonStringify: function (context) {
|
|
39
|
-
return JSON.stringify(context);
|
|
40
|
-
},
|
|
41
|
-
json: (obj) => JSON.stringify(obj, null, 2),
|
|
42
|
-
objectEntries: function (obj) {
|
|
43
|
-
if (!obj || typeof obj !== 'object') {
|
|
44
|
-
return []; // Return an empty array if obj is undefined, null, or not an object
|
|
45
|
-
}
|
|
46
|
-
return Object.entries(obj).map(([key, value]) => ({ key, value }));
|
|
47
|
-
},
|
|
48
|
-
cacheBuster: function () {
|
|
49
|
-
return "?v=" + packageJson.version;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
44
|
+
helpers: handlebarsHelpers
|
|
53
45
|
}));
|
|
54
46
|
|
|
55
47
|
app.set("view engine", "handlebars");
|
|
56
|
-
|
|
57
48
|
app.use(router);
|
|
58
49
|
|
|
59
|
-
|
|
50
|
+
const renderDevError = (res, req, code, error, message, page, details) => renderError(res, req, {
|
|
51
|
+
layout: false,
|
|
52
|
+
code,
|
|
53
|
+
error,
|
|
54
|
+
message,
|
|
55
|
+
details,
|
|
56
|
+
pagename: "Home",
|
|
57
|
+
page,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (isDevMode) {
|
|
60
61
|
console.log("[mbkauthe] Dev mode is enabled. Starting server in dev mode.");
|
|
61
|
-
|
|
62
|
-
app.get(["/dashboard", "/home", "/"], (req, res) =>
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
app.get("/500", (req, res) => {
|
|
77
|
-
return renderError(res, req, {
|
|
78
|
-
layout: false,
|
|
79
|
-
code: 500,
|
|
80
|
-
error: "Internal Server Error",
|
|
81
|
-
message: "Simulated 500 Error",
|
|
82
|
-
details: "This is a simulated 500 error page for testing purposes.",
|
|
83
|
-
pagename: "Home",
|
|
84
|
-
page: "/mbkauthe/login",
|
|
85
|
-
});
|
|
86
|
-
});
|
|
62
|
+
|
|
63
|
+
app.get(["/dashboard", "/home", "/"], (req, res) => res.redirect("/mbkauthe/"));
|
|
64
|
+
|
|
65
|
+
app.get("/dev/2fa", (req, res) => renderPage(req, res, "pages/2fa.handlebars", false, {
|
|
66
|
+
pagename: "Two-Factor Authentication",
|
|
67
|
+
page: "/home"
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
app.get("/showmessage", (req, res) => renderPage(req, res, "showmessage", false));
|
|
71
|
+
|
|
72
|
+
app.get("/500", (req, res) => renderDevError(res, req, 500,
|
|
73
|
+
"Internal Server Error", "Simulated 500 Error",
|
|
74
|
+
"/mbkauthe/login", "This is a simulated 500 error page for testing purposes."
|
|
75
|
+
));
|
|
76
|
+
|
|
87
77
|
app.use((req, res) => {
|
|
88
78
|
console.log(`[mbkauthe] Path not found: ${req.method} ${req.url}`);
|
|
89
|
-
|
|
90
|
-
layout: false,
|
|
91
|
-
code: 404,
|
|
92
|
-
error: "Not Found",
|
|
93
|
-
message: "The requested page was not found.",
|
|
94
|
-
pagename: "Home",
|
|
95
|
-
page: "/mbkauthe/login",
|
|
96
|
-
});
|
|
79
|
+
renderDevError(res, req, 404, "Not Found", "The requested page was not found.", "/mbkauthe/login");
|
|
97
80
|
});
|
|
98
|
-
|
|
99
|
-
|
|
81
|
+
|
|
82
|
+
app.listen(DEV_PORT, () => {
|
|
83
|
+
console.log(`[mbkauthe] Server running on http://localhost:${DEV_PORT}`);
|
|
100
84
|
});
|
|
101
85
|
}
|
|
102
86
|
|
|
103
|
-
if (
|
|
87
|
+
if (!isDevMode) {
|
|
104
88
|
await checkVersion();
|
|
105
89
|
}
|
|
106
90
|
|
|
107
|
-
export
|
|
108
|
-
|
|
109
|
-
validateSessionAndRole, authenticate, reloadSessionUser,
|
|
110
|
-
strictValidateSession, strictValidateSessionAndRole
|
|
111
|
-
} from "./lib/middleware/auth.js";
|
|
112
|
-
export {
|
|
113
|
-
sessionConfig,
|
|
114
|
-
corsMiddleware,
|
|
115
|
-
sessionRestorationMiddleware,
|
|
116
|
-
sessionCookieSyncMiddleware,
|
|
117
|
-
requestContextMiddleware
|
|
118
|
-
} from "./lib/middleware/index.js";
|
|
91
|
+
export * from "./lib/middleware/auth.js";
|
|
92
|
+
export * from "./lib/middleware/index.js";
|
|
119
93
|
export { validateTokenScope } from "./lib/middleware/scopeValidator.js";
|
|
120
|
-
export
|
|
94
|
+
export * from "#response.js";
|
|
121
95
|
export { dblogin } from "#pool.js";
|
|
122
96
|
export { getLatestVersion } from "./lib/routes/misc.js";
|
|
123
|
-
export
|
|
124
|
-
export
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
} from "./lib/utils/errors.js";
|
|
128
|
-
export {
|
|
129
|
-
encryptSessionId, decryptSessionId, cachedCookieOptions, cachedClearCookieOptions,
|
|
130
|
-
DEVICE_TRUST_DURATION_DAYS, DEVICE_TRUST_DURATION_MS,
|
|
131
|
-
generateDeviceToken, hashDeviceToken, getDeviceTokenCookieOptions,
|
|
132
|
-
getCookieOptions, getClearCookieOptions, clearSessionCookies,
|
|
133
|
-
readAccountListFromCookie, upsertAccountListCookie, removeAccountFromCookie, clearAccountListCookie
|
|
134
|
-
} from "./lib/config/cookies.js";
|
|
135
|
-
export { hashPassword, hashApiToken } from "./lib/config/security.js";
|
|
97
|
+
export * from "./lib/routes/auth.js";
|
|
98
|
+
export * from "./lib/utils/errors.js";
|
|
99
|
+
export * from "./lib/config/cookies.js";
|
|
100
|
+
export * from "./lib/config/security.js";
|
|
136
101
|
export { mbkautheVar } from "#config.js";
|
|
137
102
|
export default app;
|
package/lib/middleware/auth.js
CHANGED
|
@@ -6,6 +6,18 @@ import { ErrorCodes, createErrorResponse } from "../utils/errors.js";
|
|
|
6
6
|
import { hashApiToken } from "#config.js";
|
|
7
7
|
import { canAccessMethod } from "#config.js";
|
|
8
8
|
|
|
9
|
+
const IS_DEV = process.env.env === 'dev' || process.env.test === 'dev' || process.env.NODE_ENV === 'development';
|
|
10
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
11
|
+
const isUuid = (val) => typeof val === 'string' && UUID_RE.test(val);
|
|
12
|
+
|
|
13
|
+
const SQL_VALIDATE_APP_SESSION = `
|
|
14
|
+
SELECT s.expires_at, u."Active", u."Role"
|
|
15
|
+
FROM "Sessions" s
|
|
16
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
17
|
+
WHERE s.id = $1
|
|
18
|
+
LIMIT 1
|
|
19
|
+
`;
|
|
20
|
+
|
|
9
21
|
/**
|
|
10
22
|
* Decide if the incoming request should return JSON errors instead of HTML.
|
|
11
23
|
* Non-browser clients (API calls / AJAX) should get JSON.
|
|
@@ -184,8 +196,10 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
184
196
|
|
|
185
197
|
// --- Fallback to Cookie Session ---
|
|
186
198
|
if (!req.session.user) {
|
|
187
|
-
|
|
188
|
-
|
|
199
|
+
if (IS_DEV) {
|
|
200
|
+
console.log("[mbkauthe] User not authenticated");
|
|
201
|
+
console.log("[mbkauthe]: ", req.session.user);
|
|
202
|
+
}
|
|
189
203
|
if (isJsonRequest(req)) {
|
|
190
204
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
|
|
191
205
|
}
|
|
@@ -199,10 +213,10 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
199
213
|
}
|
|
200
214
|
|
|
201
215
|
try {
|
|
202
|
-
const {
|
|
216
|
+
const { sessionId, role, allowedApps } = req.session.user;
|
|
203
217
|
|
|
204
218
|
// Defensive checks for sessionId and allowedApps
|
|
205
|
-
if (!sessionId) {
|
|
219
|
+
if (!sessionId || !isUuid(sessionId)) {
|
|
206
220
|
console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
|
|
207
221
|
req.session.destroy();
|
|
208
222
|
clearSessionCookies(res);
|
|
@@ -218,15 +232,8 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
218
232
|
});
|
|
219
233
|
}
|
|
220
234
|
|
|
221
|
-
// Normalize sessionId (DB id) for consistent comparison
|
|
222
|
-
const normalizedSessionId = sessionId;
|
|
223
|
-
|
|
224
235
|
// Validate session by DB primary key id and join to user
|
|
225
|
-
const
|
|
226
|
-
FROM "Sessions" s
|
|
227
|
-
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
228
|
-
WHERE s.id = $1 LIMIT 1`;
|
|
229
|
-
const result = await dblogin.query({ name: 'validate-app-session', text: query, values: [normalizedSessionId] });
|
|
236
|
+
const result = await dblogin.query({ name: 'validate-app-session', text: SQL_VALIDATE_APP_SESSION, values: [sessionId] });
|
|
230
237
|
|
|
231
238
|
if (result.rows.length === 0) {
|
|
232
239
|
console.log(`[mbkauthe] Session not found for user "${req.session.user.username}"`);
|
|
@@ -247,7 +254,11 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
247
254
|
const sessionRow = result.rows[0];
|
|
248
255
|
|
|
249
256
|
// Check expired
|
|
250
|
-
if (sessionRow.expires_at
|
|
257
|
+
if (sessionRow.expires_at) {
|
|
258
|
+
const expiresMs = sessionRow.expires_at instanceof Date
|
|
259
|
+
? sessionRow.expires_at.getTime()
|
|
260
|
+
: Date.parse(sessionRow.expires_at);
|
|
261
|
+
if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
|
|
251
262
|
console.log(`[mbkauthe] Session invalidated (expired) for user "${req.session.user.username}"`);
|
|
252
263
|
// destroy and clear cookies
|
|
253
264
|
req.session.destroy();
|
|
@@ -263,6 +274,7 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
263
274
|
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
|
|
264
275
|
});
|
|
265
276
|
}
|
|
277
|
+
}
|
|
266
278
|
|
|
267
279
|
|
|
268
280
|
if (!sessionRow.Active) {
|
|
@@ -302,7 +314,7 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
302
314
|
}
|
|
303
315
|
|
|
304
316
|
// Store user role in request for checkRolePermission to use
|
|
305
|
-
req.userRole =
|
|
317
|
+
req.userRole = sessionRow.Role;
|
|
306
318
|
|
|
307
319
|
next();
|
|
308
320
|
} catch (err) {
|
|
@@ -321,25 +333,18 @@ async function validateApiSession(req, res, next) {
|
|
|
321
333
|
}
|
|
322
334
|
|
|
323
335
|
try {
|
|
324
|
-
const {
|
|
336
|
+
const { sessionId, role, allowedApps } = req.session.user;
|
|
325
337
|
|
|
326
338
|
// Defensive checks for sessionId and allowedApps
|
|
327
|
-
if (!sessionId) {
|
|
339
|
+
if (!sessionId || !isUuid(sessionId)) {
|
|
328
340
|
console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
|
|
329
341
|
req.session.destroy();
|
|
330
342
|
clearSessionCookies(res);
|
|
331
343
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
|
|
332
344
|
}
|
|
333
345
|
|
|
334
|
-
// Normalize sessionId (DB id) for consistent comparison
|
|
335
|
-
const normalizedSessionId = sessionId;
|
|
336
|
-
|
|
337
346
|
// Validate session by DB primary key id and join to user
|
|
338
|
-
const
|
|
339
|
-
FROM "Sessions" s
|
|
340
|
-
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
341
|
-
WHERE s.id = $1 LIMIT 1`;
|
|
342
|
-
const result = await dblogin.query({ name: 'validate-app-session-for-api', text: query, values: [normalizedSessionId] });
|
|
347
|
+
const result = await dblogin.query({ name: 'validate-app-session-for-api', text: SQL_VALIDATE_APP_SESSION, values: [sessionId] });
|
|
343
348
|
|
|
344
349
|
if (result.rows.length === 0) {
|
|
345
350
|
req.session.destroy();
|
|
@@ -350,11 +355,16 @@ async function validateApiSession(req, res, next) {
|
|
|
350
355
|
const sessionRow = result.rows[0];
|
|
351
356
|
|
|
352
357
|
// Check expired
|
|
353
|
-
if (sessionRow.expires_at
|
|
358
|
+
if (sessionRow.expires_at) {
|
|
359
|
+
const expiresMs = sessionRow.expires_at instanceof Date
|
|
360
|
+
? sessionRow.expires_at.getTime()
|
|
361
|
+
: Date.parse(sessionRow.expires_at);
|
|
362
|
+
if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
|
|
354
363
|
req.session.destroy();
|
|
355
364
|
clearSessionCookies(res);
|
|
356
365
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
|
|
357
366
|
}
|
|
367
|
+
}
|
|
358
368
|
|
|
359
369
|
|
|
360
370
|
if (!result.rows[0].Active) {
|
|
@@ -376,7 +386,7 @@ async function validateApiSession(req, res, next) {
|
|
|
376
386
|
}
|
|
377
387
|
|
|
378
388
|
// Store user role in request for checkRolePermission to use
|
|
379
|
-
req.userRole =
|
|
389
|
+
req.userRole = sessionRow.Role;
|
|
380
390
|
|
|
381
391
|
next();
|
|
382
392
|
} catch (err) {
|
|
@@ -500,6 +510,11 @@ const checkRolePermission = (requiredRoles, notAllowed) => {
|
|
|
500
510
|
});
|
|
501
511
|
}
|
|
502
512
|
|
|
513
|
+
// SuperAdmin bypasses all role checks
|
|
514
|
+
if(req.session.role === "SuperAdmin") {
|
|
515
|
+
return next();
|
|
516
|
+
}
|
|
517
|
+
|
|
503
518
|
// Use role from validateSession to avoid additional DB query
|
|
504
519
|
const userRole = req.userRole;
|
|
505
520
|
|
|
@@ -521,7 +536,7 @@ const checkRolePermission = (requiredRoles, notAllowed) => {
|
|
|
521
536
|
const rolesArray = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
|
|
522
537
|
|
|
523
538
|
// Check for "Any" or "any" role
|
|
524
|
-
if (rolesArray.includes("Any") || rolesArray.includes("any")) {
|
|
539
|
+
if (rolesArray.includes("Any") || rolesArray.includes("any") || rolesArray.includes("*")) {
|
|
525
540
|
return next();
|
|
526
541
|
}
|
|
527
542
|
|
|
@@ -555,7 +570,6 @@ const validateSessionAndRole = (requiredRole, notAllowed, strictTokenValidation
|
|
|
555
570
|
};
|
|
556
571
|
};
|
|
557
572
|
|
|
558
|
-
|
|
559
573
|
const authenticate = (authentication) => {
|
|
560
574
|
return (req, res, next) => {
|
|
561
575
|
const token = req.headers["authorization"];
|
|
@@ -573,8 +587,13 @@ const authenticate = (authentication) => {
|
|
|
573
587
|
const strictValidateSession = (req, res, next) => validateSession(req, res, next, true);
|
|
574
588
|
const strictValidateSessionAndRole = (requiredRole, notAllowed) => validateSessionAndRole(requiredRole, notAllowed, true);
|
|
575
589
|
|
|
590
|
+
// Short aliases for convenience
|
|
591
|
+
const sessVal = validateSession;
|
|
592
|
+
const sessRole = validateSessionAndRole;
|
|
593
|
+
|
|
576
594
|
export {
|
|
577
595
|
validateSession, validateApiSession, checkRolePermission,
|
|
578
596
|
validateSessionAndRole, authenticate, reloadSessionUser,
|
|
579
|
-
strictValidateSession, strictValidateSessionAndRole
|
|
597
|
+
strictValidateSession, strictValidateSessionAndRole,
|
|
598
|
+
sessVal, sessRole
|
|
580
599
|
}
|
package/lib/pool.js
CHANGED
|
@@ -34,6 +34,7 @@ attachDevQueryLogger(dblogin);
|
|
|
34
34
|
try {
|
|
35
35
|
const client = await dblogin.connect();
|
|
36
36
|
client.release();
|
|
37
|
+
console.log("[mbkauthe] Database connection pool established successfully.");
|
|
37
38
|
} catch (err) {
|
|
38
39
|
console.error("[mbkauthe] Database connection error (pool):", err);
|
|
39
40
|
}
|
package/lib/routes/auth.js
CHANGED
|
@@ -110,11 +110,17 @@ export async function checkTrustedDevice(req, username) {
|
|
|
110
110
|
try {
|
|
111
111
|
// Hash the provided device token before querying DB (we store token hashes in DB)
|
|
112
112
|
const deviceTokenHash = hashDeviceToken(deviceToken);
|
|
113
|
+
// Single round-trip: validate trusted device AND refresh LastUsed.
|
|
113
114
|
const deviceQuery = `
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
WHERE td."DeviceToken" = $1
|
|
115
|
+
UPDATE "TrustedDevices" td
|
|
116
|
+
SET "LastUsed" = NOW()
|
|
117
|
+
FROM "Users" u
|
|
118
|
+
WHERE td."DeviceToken" = $1
|
|
119
|
+
AND td."UserName" = $2
|
|
120
|
+
AND td."ExpiresAt" > NOW()
|
|
121
|
+
AND u."UserName" = td."UserName"
|
|
122
|
+
AND u."Active" = TRUE
|
|
123
|
+
RETURNING td."UserName", td."ExpiresAt", u."id" as id, u."Active", u."Role", u."AllowedApps"
|
|
118
124
|
`;
|
|
119
125
|
const deviceResult = await dblogin.query({
|
|
120
126
|
name: 'check-trusted-device',
|
|
@@ -138,13 +144,6 @@ export async function checkTrustedDevice(req, username) {
|
|
|
138
144
|
}
|
|
139
145
|
}
|
|
140
146
|
|
|
141
|
-
// Update last used timestamp
|
|
142
|
-
await dblogin.query({
|
|
143
|
-
name: 'update-device-last-used',
|
|
144
|
-
text: 'UPDATE "TrustedDevices" SET "LastUsed" = NOW() WHERE "DeviceToken" = $1',
|
|
145
|
-
values: [deviceTokenHash]
|
|
146
|
-
});
|
|
147
|
-
|
|
148
147
|
console.log(`[mbkauthe] Trusted device validated for user: ${username}`);
|
|
149
148
|
return {
|
|
150
149
|
id: deviceUser.id,
|
|
@@ -193,21 +192,24 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
193
192
|
const configuredMax = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
|
|
194
193
|
const MAX_SESSIONS = Number.isInteger(configuredMax) && configuredMax > 0 ? configuredMax : 5;
|
|
195
194
|
|
|
196
|
-
// Clean up expired sessions
|
|
197
|
-
await dblogin.query({
|
|
198
|
-
name: 'cleanup-expired-sessions',
|
|
199
|
-
text: `DELETE FROM "Sessions" WHERE "UserName" = $1 AND expires_at IS NOT NULL AND expires_at <= NOW()`,
|
|
200
|
-
values: [username]
|
|
201
|
-
});
|
|
202
|
-
|
|
203
|
-
// Count active sessions for this user (by username)
|
|
195
|
+
// Clean up expired sessions + count active sessions in a single round-trip.
|
|
204
196
|
const countRes = await dblogin.query({
|
|
205
|
-
name: 'count-user-sessions',
|
|
206
|
-
text: `
|
|
197
|
+
name: 'cleanup-and-count-user-sessions',
|
|
198
|
+
text: `
|
|
199
|
+
WITH deleted AS (
|
|
200
|
+
DELETE FROM "Sessions"
|
|
201
|
+
WHERE "UserName" = $1
|
|
202
|
+
AND expires_at IS NOT NULL
|
|
203
|
+
AND expires_at <= NOW()
|
|
204
|
+
)
|
|
205
|
+
SELECT COUNT(*)::int AS count
|
|
206
|
+
FROM "Sessions"
|
|
207
|
+
WHERE "UserName" = $1
|
|
208
|
+
`,
|
|
207
209
|
values: [username]
|
|
208
210
|
});
|
|
209
211
|
|
|
210
|
-
const currentSessions = countRes.rows
|
|
212
|
+
const currentSessions = Number(countRes.rows?.[0]?.count ?? 0);
|
|
211
213
|
// If we have MAX_SESSIONS or more, delete oldest to make room for exactly 1 new session
|
|
212
214
|
if (currentSessions >= MAX_SESSIONS) {
|
|
213
215
|
const sessionsToDelete = currentSessions - MAX_SESSIONS + 1; // +1 to make room for new session
|
|
@@ -215,15 +217,15 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
215
217
|
|
|
216
218
|
await dblogin.query({
|
|
217
219
|
name: 'prune-oldest-user-session',
|
|
218
|
-
|
|
220
|
+
// Expired sessions were already removed above; remaining rows are active.
|
|
221
|
+
text: `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at ASC LIMIT $2)`,
|
|
219
222
|
values: [username, sessionsToDelete]
|
|
220
223
|
});
|
|
221
224
|
}
|
|
222
225
|
|
|
223
226
|
const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0));
|
|
224
227
|
|
|
225
|
-
// Insert new session record for the user
|
|
226
|
-
const newSessionId = crypto.randomUUID();
|
|
228
|
+
// Insert new session record for the user.
|
|
227
229
|
let dbSessionId;
|
|
228
230
|
try {
|
|
229
231
|
const insertRes = await dblogin.query({
|
|
@@ -237,12 +239,18 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
237
239
|
throw insertErr;
|
|
238
240
|
}
|
|
239
241
|
|
|
240
|
-
// Update last_login
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
242
|
+
// Update last_login and fetch FullName/Image in a single query.
|
|
243
|
+
let profileRow = null;
|
|
244
|
+
try {
|
|
245
|
+
const profUpdateRes = await dblogin.query({
|
|
246
|
+
name: 'login-update-last-login-return-profile',
|
|
247
|
+
text: `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`,
|
|
248
|
+
values: [user.id]
|
|
249
|
+
});
|
|
250
|
+
profileRow = profUpdateRes.rows?.[0] || null;
|
|
251
|
+
} catch (profileUpdateErr) {
|
|
252
|
+
console.error('[mbkauthe] Error updating last_login/returning profile:', profileUpdateErr);
|
|
253
|
+
}
|
|
246
254
|
|
|
247
255
|
req.session.user = {
|
|
248
256
|
id: user.id,
|
|
@@ -255,20 +263,26 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
255
263
|
// Clear profile picture cache to fetch fresh data
|
|
256
264
|
clearProfilePicCache(req, username);
|
|
257
265
|
|
|
258
|
-
//
|
|
266
|
+
// Store FullName/Image in session and cache cookie values.
|
|
259
267
|
let loginProfileImage = null;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
268
|
+
if (profileRow) {
|
|
269
|
+
if (profileRow.FullName) req.session.user.fullname = profileRow.FullName;
|
|
270
|
+
if (typeof profileRow.Image === 'string' && profileRow.Image.trim() !== '') loginProfileImage = profileRow.Image;
|
|
271
|
+
} else {
|
|
272
|
+
// Fallback: try a read query if UPDATE...RETURNING failed unexpectedly.
|
|
273
|
+
try {
|
|
274
|
+
const profileResult = await dblogin.query({
|
|
275
|
+
name: 'login-get-fullname-and-image',
|
|
276
|
+
text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
277
|
+
values: [username]
|
|
278
|
+
});
|
|
279
|
+
if (profileResult.rows.length > 0) {
|
|
280
|
+
if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName;
|
|
281
|
+
if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image;
|
|
282
|
+
}
|
|
283
|
+
} catch (profileErr) {
|
|
284
|
+
console.error("[mbkauthe] Error fetching FullName/Image for user:", profileErr);
|
|
269
285
|
}
|
|
270
|
-
} catch (profileErr) {
|
|
271
|
-
console.error("[mbkauthe] Error fetching FullName/Image for user:", profileErr);
|
|
272
286
|
}
|
|
273
287
|
|
|
274
288
|
if (req.session.preAuthUser) {
|
package/lib/routes/misc.js
CHANGED
|
@@ -228,7 +228,24 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
|
228
228
|
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
229
229
|
}
|
|
230
230
|
|
|
231
|
-
|
|
231
|
+
// Single round-trip: fetch app-session expiry and (if needed) connect-pg-simple expiry.
|
|
232
|
+
const result = await dblogin.query({
|
|
233
|
+
name: 'check-session-validity',
|
|
234
|
+
text: `
|
|
235
|
+
SELECT
|
|
236
|
+
s.expires_at,
|
|
237
|
+
u."Active",
|
|
238
|
+
CASE
|
|
239
|
+
WHEN s.expires_at IS NULL THEN (SELECT expire FROM "session" WHERE sid = $2)
|
|
240
|
+
ELSE NULL
|
|
241
|
+
END AS connect_expire
|
|
242
|
+
FROM "Sessions" s
|
|
243
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
244
|
+
WHERE s.id = $1
|
|
245
|
+
LIMIT 1
|
|
246
|
+
`,
|
|
247
|
+
values: [sessionId, req.sessionID]
|
|
248
|
+
});
|
|
232
249
|
|
|
233
250
|
if (result.rows.length === 0) {
|
|
234
251
|
req.session.destroy(() => { });
|
|
@@ -243,12 +260,9 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
|
243
260
|
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
244
261
|
}
|
|
245
262
|
|
|
246
|
-
// Determine expiry: prefer application session expiry if present else fallback to connect-pg-simple
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
const sessResult = await dblogin.query({ name: 'get-session-expiry', text: 'SELECT expire FROM "session" WHERE sid = $1', values: [req.sessionID] });
|
|
250
|
-
expiry = sessResult.rows.length > 0 && sessResult.rows[0].expire ? new Date(sessResult.rows[0].expire).toISOString() : null;
|
|
251
|
-
}
|
|
263
|
+
// Determine expiry: prefer application session expiry if present else fallback to connect-pg-simple expiry.
|
|
264
|
+
const expirySource = row.expires_at || row.connect_expire || null;
|
|
265
|
+
const expiry = expirySource ? new Date(expirySource).toISOString() : null;
|
|
252
266
|
|
|
253
267
|
return res.status(200).json({ sessionValid: true, expiry });
|
|
254
268
|
} catch (err) {
|