mbkauthe 2.5.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,7 @@
1
- import { dblogin } from "./pool.js";
2
- import { mbkautheVar, renderError, clearSessionCookies } from "./config.js";
1
+ import { dblogin } from "../database/pool.js";
2
+ import { mbkautheVar } from "../config/index.js";
3
+ import { renderError } from "../utils/response.js";
4
+ import { clearSessionCookies } from "../config/cookies.js";
3
5
 
4
6
  async function validateSession(req, res, next) {
5
7
  if (!req.session.user) {
@@ -172,4 +174,4 @@ const authenticate = (authentication) => {
172
174
  };
173
175
 
174
176
 
175
- export { validateSession, checkRolePermission, validateSessionAndRole, authenticate };
177
+ export { validateSession, checkRolePermission, validateSessionAndRole, authenticate };
@@ -0,0 +1,106 @@
1
+ import session from "express-session";
2
+ import pgSession from "connect-pg-simple";
3
+ const PgSession = pgSession(session);
4
+ import { dblogin } from "../database/pool.js";
5
+ import { mbkautheVar } from "../config/index.js";
6
+ import { cachedCookieOptions } from "../config/cookies.js";
7
+
8
+ // Session configuration
9
+ export const sessionConfig = {
10
+ store: new PgSession({
11
+ pool: dblogin,
12
+ tableName: "session",
13
+ createTableIfMissing: true
14
+ }),
15
+ secret: mbkautheVar.SESSION_SECRET_KEY,
16
+ resave: false,
17
+ saveUninitialized: false,
18
+ proxy: true,
19
+ cookie: {
20
+ maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
21
+ domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
22
+ httpOnly: true,
23
+ secure: mbkautheVar.IS_DEPLOYED === 'true',
24
+ sameSite: 'lax',
25
+ path: '/'
26
+ },
27
+ name: 'mbkauthe.sid'
28
+ };
29
+
30
+ // CORS middleware
31
+ export function corsMiddleware(req, res, next) {
32
+ const origin = req.headers.origin;
33
+ if (origin) {
34
+ try {
35
+ const originUrl = new URL(origin);
36
+ const allowedDomain = `.${mbkautheVar.DOMAIN}`;
37
+ // Exact match or subdomain match
38
+ if (originUrl.hostname === mbkautheVar.DOMAIN ||
39
+ (originUrl.hostname.endsWith(allowedDomain) && originUrl.hostname.charAt(originUrl.hostname.length - allowedDomain.length - 1) !== '.')) {
40
+ res.header('Access-Control-Allow-Origin', origin);
41
+ res.header('Access-Control-Allow-Credentials', 'true');
42
+ res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
43
+ res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
44
+ }
45
+ } catch (err) {
46
+ // Invalid origin URL, skip CORS headers
47
+ }
48
+ }
49
+ next();
50
+ }
51
+
52
+ // Session restoration middleware
53
+ export async function sessionRestorationMiddleware(req, res, next) {
54
+ // Only restore session if not already present and sessionId cookie exists
55
+ if (!req.session.user && req.cookies.sessionId) {
56
+ const sessionId = req.cookies.sessionId;
57
+
58
+ // Early validation to avoid unnecessary processing
59
+ if (typeof sessionId !== 'string' || !/^[a-f0-9]{64}$/i.test(sessionId)) {
60
+ // Clear invalid cookie to prevent repeated attempts
61
+ res.clearCookie('sessionId', {
62
+ domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
63
+ path: '/',
64
+ httpOnly: true,
65
+ secure: mbkautheVar.IS_DEPLOYED === 'true',
66
+ sameSite: 'lax'
67
+ });
68
+ return next();
69
+ }
70
+
71
+ try {
72
+ const normalizedSessionId = sessionId.toLowerCase();
73
+
74
+ const query = `SELECT id, "UserName", "Active", "Role", "SessionId", "AllowedApps" FROM "Users" WHERE LOWER("SessionId") = $1 AND "Active" = true`;
75
+ const result = await dblogin.query({ name: 'restore-user-session', text: query, values: [normalizedSessionId] });
76
+
77
+ if (result.rows.length > 0) {
78
+ const user = result.rows[0];
79
+ req.session.user = {
80
+ id: user.id,
81
+ username: user.UserName,
82
+ UserName: user.UserName,
83
+ role: user.Role,
84
+ Role: user.Role,
85
+ sessionId: normalizedSessionId,
86
+ allowedApps: user.AllowedApps,
87
+ };
88
+ }
89
+ } catch (err) {
90
+ console.error("[mbkauthe] Session restoration error:", err);
91
+ }
92
+ }
93
+ next();
94
+ }
95
+
96
+ // Session cookie sync middleware
97
+ export function sessionCookieSyncMiddleware(req, res, next) {
98
+ if (req.session && req.session.user) {
99
+ // Only set cookies if they're missing or different
100
+ if (req.cookies.sessionId !== req.session.user.sessionId) {
101
+ res.cookie("username", req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
102
+ res.cookie("sessionId", req.session.user.sessionId, cachedCookieOptions);
103
+ }
104
+ }
105
+ next();
106
+ }
@@ -0,0 +1,521 @@
1
+ import express from "express";
2
+ import crypto from "crypto";
3
+ import csurf from "csurf";
4
+ import speakeasy from "speakeasy";
5
+ import rateLimit from 'express-rate-limit';
6
+ import { dblogin } from "../database/pool.js";
7
+ import { mbkautheVar } from "../config/index.js";
8
+ import {
9
+ cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
10
+ generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS
11
+ } from "../config/cookies.js";
12
+ import { packageJson } from "../config/index.js";
13
+ import { hashPassword } from "../config/security.js";
14
+ import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
15
+
16
+ const router = express.Router();
17
+
18
+ // Rate limiters for auth routes
19
+ const LoginLimit = rateLimit({
20
+ windowMs: 1 * 60 * 1000,
21
+ max: 8,
22
+ message: { success: false, message: "Too many attempts, please try again later" },
23
+ skip: (req) => {
24
+ return !!req.session.user;
25
+ }
26
+ });
27
+
28
+ const LogoutLimit = rateLimit({
29
+ windowMs: 1 * 60 * 1000,
30
+ max: 10,
31
+ message: { success: false, message: "Too many logout attempts, please try again later" }
32
+ });
33
+
34
+ const TwoFALimit = rateLimit({
35
+ windowMs: 1 * 60 * 1000,
36
+ max: 5,
37
+ message: { success: false, message: "Too many 2FA attempts, please try again later" }
38
+ });
39
+
40
+ // CSRF protection middleware
41
+ const csrfProtection = csurf({ cookie: true });
42
+
43
+ /**
44
+ * Check if the device is trusted for the given username
45
+ */
46
+ export async function checkTrustedDevice(req, username) {
47
+ const deviceToken = req.cookies.device_token;
48
+
49
+ if (!deviceToken || typeof deviceToken !== 'string') {
50
+ return null;
51
+ }
52
+
53
+ try {
54
+ const deviceQuery = `
55
+ SELECT td."UserName", td."LastUsed", td."ExpiresAt", u."id", u."Active", u."Role", u."AllowedApps"
56
+ FROM "TrustedDevices" td
57
+ JOIN "Users" u ON td."UserName" = u."UserName"
58
+ WHERE td."DeviceToken" = $1 AND td."UserName" = $2 AND td."ExpiresAt" > NOW()
59
+ `;
60
+ const deviceResult = await dblogin.query({
61
+ name: 'check-trusted-device',
62
+ text: deviceQuery,
63
+ values: [deviceToken, username]
64
+ });
65
+
66
+ if (deviceResult.rows.length > 0) {
67
+ const deviceUser = deviceResult.rows[0];
68
+
69
+ if (!deviceUser.Active) {
70
+ console.log(`[mbkauthe] Trusted device check: inactive account for username: ${username}`);
71
+ return null;
72
+ }
73
+
74
+ if (deviceUser.Role !== "SuperAdmin") {
75
+ const allowedApps = deviceUser.AllowedApps;
76
+ if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
77
+ console.warn(`[mbkauthe] Trusted device check: User "${username}" is not authorized to use the application "${mbkautheVar.APP_NAME}"`);
78
+ return null;
79
+ }
80
+ }
81
+
82
+ // Update last used timestamp
83
+ await dblogin.query({
84
+ name: 'update-device-last-used',
85
+ text: 'UPDATE "TrustedDevices" SET "LastUsed" = NOW() WHERE "DeviceToken" = $1',
86
+ values: [deviceToken]
87
+ });
88
+
89
+ console.log(`[mbkauthe] Trusted device validated for user: ${username}`);
90
+ return {
91
+ id: deviceUser.id,
92
+ username: username,
93
+ role: deviceUser.Role,
94
+ Role: deviceUser.Role,
95
+ allowedApps: deviceUser.AllowedApps,
96
+ };
97
+ }
98
+ } catch (deviceErr) {
99
+ console.error("[mbkauthe] Error checking trusted device:", deviceErr);
100
+ }
101
+
102
+ return null;
103
+ }
104
+
105
+ /**
106
+ * Complete the login process by creating session and cookies
107
+ */
108
+ export async function completeLoginProcess(req, res, user, redirectUrl = null, trustDevice = false) {
109
+ try {
110
+ // Ensure both username formats are available for compatibility
111
+ const username = user.username || user.UserName;
112
+ if (!username) {
113
+ throw new Error('Username is required in user object');
114
+ }
115
+
116
+ // smaller session id is sufficient and faster to generate/serialize
117
+ const sessionId = crypto.randomBytes(32).toString("hex");
118
+ console.log(`[mbkauthe] Generated session ID for username: ${username}`);
119
+
120
+ // Regenerate session to prevent session fixation attacks
121
+ const oldSessionId = req.sessionID;
122
+ await new Promise((resolve, reject) => {
123
+ req.session.regenerate((err) => {
124
+ if (err) reject(err);
125
+ else resolve();
126
+ });
127
+ });
128
+
129
+ // Run both queries in parallel for better performance
130
+ await Promise.all([
131
+ // Delete old sessions using indexed lookup on sess->'user'->>'id'
132
+ dblogin.query({
133
+ name: 'login-delete-old-user-sessions',
134
+ text: 'DELETE FROM "session" WHERE (sess->\'user\'->>\'id\')::int = $1',
135
+ values: [user.id]
136
+ }),
137
+ // Update session ID and last login time in Users table
138
+ dblogin.query({
139
+ name: 'login-update-session-and-last-login',
140
+ text: `UPDATE "Users" SET "SessionId" = $1, "last_login" = NOW() WHERE "id" = $2`,
141
+ values: [sessionId, user.id]
142
+ })
143
+ ]);
144
+
145
+ req.session.user = {
146
+ id: user.id,
147
+ username: username,
148
+ UserName: username,
149
+ role: user.role || user.Role,
150
+ Role: user.role || user.Role,
151
+ sessionId,
152
+ allowedApps: user.allowedApps || user.AllowedApps,
153
+ };
154
+
155
+ if (req.session.preAuthUser) {
156
+ delete req.session.preAuthUser;
157
+ }
158
+
159
+ req.session.save(async (err) => {
160
+ if (err) {
161
+ console.error("[mbkauthe] Session save error:", err);
162
+ return res.status(500).json({ success: false, message: "Internal Server Error" });
163
+ }
164
+
165
+ res.cookie("sessionId", sessionId, cachedCookieOptions);
166
+
167
+ // Handle trusted device if requested
168
+ if (trustDevice) {
169
+ try {
170
+ const deviceToken = generateDeviceToken();
171
+ const deviceName = req.headers['user-agent'] ?
172
+ req.headers['user-agent'].substring(0, 255) : 'Unknown Device';
173
+ const userAgent = req.headers['user-agent'] || 'Unknown';
174
+ const ipAddress = req.ip || req.connection.remoteAddress || 'Unknown';
175
+ const expiresAt = new Date(Date.now() + DEVICE_TRUST_DURATION_MS);
176
+
177
+ await dblogin.query({
178
+ name: 'insert-trusted-device',
179
+ text: `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
180
+ VALUES ($1, $2, $3, $4, $5, $6)`,
181
+ values: [username, deviceToken, deviceName, userAgent, ipAddress, expiresAt]
182
+ });
183
+
184
+ res.cookie("device_token", deviceToken, getDeviceTokenCookieOptions());
185
+ console.log(`[mbkauthe] Trusted device token created for user: ${username}`);
186
+ } catch (deviceErr) {
187
+ console.error("[mbkauthe] Error creating trusted device:", deviceErr);
188
+ // Continue with login even if device trust fails
189
+ }
190
+ }
191
+
192
+ console.log(`[mbkauthe] User "${username}" logged in successfully (last_login updated)`);
193
+
194
+ const responsePayload = {
195
+ success: true,
196
+ message: "Login successful",
197
+ sessionId,
198
+ };
199
+
200
+ if (redirectUrl) {
201
+ responsePayload.redirectUrl = redirectUrl;
202
+ }
203
+
204
+ res.status(200).json(responsePayload);
205
+ });
206
+ } catch (err) {
207
+ console.error("[mbkauthe] Error during login completion:", err);
208
+ res.status(500).json({ success: false, message: "Internal Server Error" });
209
+ }
210
+ }
211
+
212
+ // POST /mbkauthe/api/login
213
+ router.post("/api/login", LoginLimit, async (req, res) => {
214
+ console.log("[mbkauthe] Login request received");
215
+
216
+ const { username, password, redirect } = req.body;
217
+
218
+ // Input validation
219
+ if (!username || !password) {
220
+ logError('Login attempt', ErrorCodes.MISSING_REQUIRED_FIELD, { username: username || 'missing' });
221
+ return res.status(400).json(
222
+ createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD, {
223
+ message: "Username and password are required"
224
+ })
225
+ );
226
+ }
227
+
228
+ // Validate username format and length
229
+ if (typeof username !== 'string' || username.trim().length === 0 || username.length > 255) {
230
+ logError('Login attempt', ErrorCodes.INVALID_USERNAME_FORMAT, { username });
231
+ return res.status(400).json(
232
+ createErrorResponse(400, ErrorCodes.INVALID_USERNAME_FORMAT)
233
+ );
234
+ }
235
+
236
+ // Validate password length
237
+ if (typeof password !== 'string' || password.length < 8 || password.length > 255) {
238
+ logError('Login attempt', ErrorCodes.INVALID_PASSWORD_LENGTH, { username: username.trim() });
239
+ return res.status(400).json(
240
+ createErrorResponse(400, ErrorCodes.INVALID_PASSWORD_LENGTH)
241
+ );
242
+ }
243
+
244
+ console.log(`[mbkauthe] Login attempt for username: ${username.trim()}`);
245
+
246
+ const trimmedUsername = username.trim();
247
+
248
+ try {
249
+ // Combined query: fetch user data and 2FA status in one query
250
+ const userQuery = `
251
+ SELECT u.id, u."UserName", u."Password", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
252
+ tfa."TwoFAStatus"
253
+ FROM "Users" u
254
+ LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
255
+ WHERE u."UserName" = $1
256
+ `;
257
+ const userResult = await dblogin.query({ name: 'login-get-user', text: userQuery, values: [trimmedUsername] });
258
+
259
+ if (userResult.rows.length === 0) {
260
+ logError('Login attempt', ErrorCodes.USER_NOT_FOUND, { username: trimmedUsername });
261
+ return res.status(401).json(
262
+ createErrorResponse(401, ErrorCodes.INVALID_CREDENTIALS)
263
+ );
264
+ }
265
+
266
+ const user = userResult.rows[0];
267
+
268
+ // Validate user has password field
269
+ if (!user.Password && !user.PasswordEnc) {
270
+ console.error("[mbkauthe] User account has no password set");
271
+ return res.status(500).json({ success: false, message: "Internal Server Error" });
272
+ }
273
+
274
+ // Check password based on EncPass configuration - ALWAYS validate password first
275
+ let passwordMatches = false;
276
+ if (mbkautheVar.EncPass === "true" || mbkautheVar.EncPass === true) {
277
+ // Use encrypted password comparison
278
+ if (user.PasswordEnc) {
279
+ const hashedInputPassword = hashPassword(password, user.UserName);
280
+ passwordMatches = user.PasswordEnc === hashedInputPassword;
281
+ }
282
+ } else {
283
+ // Use raw password comparison
284
+ if (user.Password) {
285
+ passwordMatches = user.Password === password;
286
+ }
287
+ }
288
+
289
+ if (!passwordMatches) {
290
+ logError('Login attempt', ErrorCodes.INCORRECT_PASSWORD, { username: trimmedUsername });
291
+ return res.status(401).json(
292
+ createErrorResponse(401, ErrorCodes.INCORRECT_PASSWORD)
293
+ );
294
+ }
295
+
296
+ if (!user.Active) {
297
+ logError('Login attempt', ErrorCodes.ACCOUNT_INACTIVE, { username: trimmedUsername });
298
+ return res.status(403).json(
299
+ createErrorResponse(403, ErrorCodes.ACCOUNT_INACTIVE)
300
+ );
301
+ }
302
+
303
+ if (user.Role !== "SuperAdmin") {
304
+ const allowedApps = user.AllowedApps;
305
+ if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
306
+ logError('Login attempt', ErrorCodes.APP_NOT_AUTHORIZED, {
307
+ username: user.UserName,
308
+ app: mbkautheVar.APP_NAME
309
+ });
310
+ return res.status(403).json(
311
+ createErrorResponse(403, ErrorCodes.APP_NOT_AUTHORIZED, {
312
+ message: `You are not authorized to access ${mbkautheVar.APP_NAME}`,
313
+ app: mbkautheVar.APP_NAME
314
+ })
315
+ );
316
+ }
317
+ }
318
+
319
+ // Check for trusted device AFTER password validation
320
+ const trustedDeviceUser = await checkTrustedDevice(req, trimmedUsername);
321
+ if (trustedDeviceUser && (mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) {
322
+ console.log(`[mbkauthe] Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
323
+
324
+ const userForSession = {
325
+ id: user.id,
326
+ username: user.UserName,
327
+ role: user.Role,
328
+ Role: user.Role,
329
+ allowedApps: user.AllowedApps,
330
+ };
331
+ const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
332
+ return await completeLoginProcess(req, res, userForSession, requestedRedirect);
333
+ }
334
+
335
+ if ((mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) {
336
+ // 2FA is enabled, prompt for token on a separate page
337
+ const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
338
+ req.session.preAuthUser = {
339
+ id: user.id,
340
+ username: user.UserName,
341
+ role: user.Role,
342
+ Role: user.Role,
343
+ redirectUrl: requestedRedirect
344
+ };
345
+ console.log(`[mbkauthe] 2FA required for user: ${trimmedUsername}`);
346
+ return res.json({ success: true, twoFactorRequired: true, redirectUrl: requestedRedirect });
347
+ }
348
+
349
+ // If 2FA is not enabled, proceed with login
350
+ const userForSession = {
351
+ id: user.id,
352
+ username: user.UserName,
353
+ role: user.Role,
354
+ Role: user.Role,
355
+ allowedApps: user.AllowedApps,
356
+ };
357
+ const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
358
+ await completeLoginProcess(req, res, userForSession, requestedRedirect);
359
+
360
+ } catch (err) {
361
+ console.error("[mbkauthe] Error during login process:", err);
362
+ res.status(500).json({ success: false, message: "Internal Server Error" });
363
+ }
364
+ });
365
+
366
+ // GET /mbkauthe/2fa
367
+ router.get("/2fa", csrfProtection, (req, res) => {
368
+ if (!req.session.preAuthUser) {
369
+ return res.redirect("/mbkauthe/login");
370
+ }
371
+
372
+ // Prefer explicit redirect from query string, else from session preAuthUser redirectUrl, else fallback value
373
+ let redirectFromQuery = req.query && typeof req.query.redirect === 'string' ? req.query.redirect : null;
374
+ let redirectToUse = redirectFromQuery || req.session.preAuthUser.redirectUrl || (mbkautheVar.loginRedirectURL || '/dashboard');
375
+
376
+ // Validate redirectToUse to prevent open redirect attacks
377
+ if (redirectToUse && !(typeof redirectToUse === 'string' && redirectToUse.startsWith('/') && !redirectToUse.startsWith('//'))) {
378
+ redirectToUse = mbkautheVar.loginRedirectURL || '/dashboard';
379
+ }
380
+
381
+ res.render("2fa.handlebars", {
382
+ layout: false,
383
+ customURL: redirectToUse,
384
+ csrfToken: req.csrfToken(),
385
+ appName: mbkautheVar.APP_NAME.toLowerCase(),
386
+ version: packageJson.version,
387
+ DEVICE_TRUST_DURATION_DAYS: mbkautheVar.DEVICE_TRUST_DURATION_DAYS
388
+ });
389
+ });
390
+
391
+ // POST /mbkauthe/api/verify-2fa
392
+ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
393
+ if (!req.session.preAuthUser) {
394
+ return res.status(401).json(
395
+ createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND, {
396
+ message: "Please log in first"
397
+ })
398
+ );
399
+ }
400
+
401
+ const { token, trustDevice } = req.body;
402
+ const { username, id, role } = req.session.preAuthUser;
403
+
404
+ // Validate 2FA token
405
+ if (!token || typeof token !== 'string') {
406
+ return res.status(400).json(
407
+ createErrorResponse(400, ErrorCodes.MISSING_REQUIRED_FIELD, {
408
+ message: "2FA token is required"
409
+ })
410
+ );
411
+ }
412
+
413
+ // Validate token format (should be 6 digits)
414
+ const sanitizedToken = token.trim();
415
+ if (!/^\d{6}$/.test(sanitizedToken)) {
416
+ return res.status(400).json(
417
+ createErrorResponse(400, ErrorCodes.INVALID_TOKEN_FORMAT)
418
+ );
419
+ }
420
+
421
+ // Validate trustDevice parameter if provided
422
+ const shouldTrustDevice = trustDevice === true || trustDevice === 'true';
423
+
424
+ try {
425
+ const query = `SELECT tfa."TwoFASecret", u."AllowedApps" FROM "TwoFA" tfa JOIN "Users" u ON tfa."UserName" = u."UserName" WHERE tfa."UserName" = $1`;
426
+ const twoFAResult = await dblogin.query({ name: 'verify-2fa-secret', text: query, values: [username] });
427
+
428
+ if (twoFAResult.rows.length === 0 || !twoFAResult.rows[0].TwoFASecret) {
429
+ return res.status(500).json(
430
+ createErrorResponse(500, ErrorCodes.TWO_FA_NOT_CONFIGURED)
431
+ );
432
+ }
433
+
434
+ const sharedSecret = twoFAResult.rows[0].TwoFASecret;
435
+ const allowedApps = twoFAResult.rows[0].AllowedApps;
436
+ const tokenValidates = speakeasy.totp.verify({
437
+ secret: sharedSecret,
438
+ encoding: "base32",
439
+ token: sanitizedToken,
440
+ window: 1,
441
+ });
442
+
443
+ if (!tokenValidates) {
444
+ logError('2FA verification', ErrorCodes.TWO_FA_INVALID_TOKEN, { username });
445
+ return res.status(401).json(
446
+ createErrorResponse(401, ErrorCodes.TWO_FA_INVALID_TOKEN)
447
+ );
448
+ }
449
+
450
+ // 2FA successful, complete login with optional device trust
451
+ const userForSession = { id, username, role, allowedApps };
452
+ // Prefer redirect stored in preAuthUser or in query/body, fallback to configured default
453
+ let redirectFromSession = req.session.preAuthUser && req.session.preAuthUser.redirectUrl ? req.session.preAuthUser.redirectUrl : null;
454
+ if (redirectFromSession && (!(typeof redirectFromSession === 'string') || !redirectFromSession.startsWith('/') || redirectFromSession.startsWith('//'))) {
455
+ redirectFromSession = null;
456
+ }
457
+ const redirectUrl = redirectFromSession || mbkautheVar.loginRedirectURL || '/dashboard';
458
+ // Clear preAuthUser after successful login
459
+ if (req.session.preAuthUser) delete req.session.preAuthUser;
460
+ await completeLoginProcess(req, res, userForSession, redirectUrl, shouldTrustDevice);
461
+
462
+ } catch (err) {
463
+ console.error("[mbkauthe] Error during 2FA verification:", err);
464
+ res.status(500).json({ success: false, message: "Internal Server Error" });
465
+ }
466
+ });
467
+
468
+ // POST /mbkauthe/api/logout
469
+ router.post("/api/logout", LogoutLimit, async (req, res) => {
470
+ if (req.session.user) {
471
+ try {
472
+ const { id, username } = req.session.user;
473
+
474
+ // Run both database operations in parallel
475
+ const operations = [
476
+ dblogin.query({ name: 'logout-clear-session', text: `UPDATE "Users" SET "SessionId" = NULL WHERE "id" = $1`, values: [id] })
477
+ ];
478
+
479
+ if (req.sessionID) {
480
+ operations.push(
481
+ dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] })
482
+ );
483
+ }
484
+
485
+ await Promise.all(operations);
486
+
487
+ req.session.destroy((err) => {
488
+ if (err) {
489
+ console.error("[mbkauthe] Error destroying session:", err);
490
+ return res.status(500).json({ success: false, message: "Logout failed" });
491
+ }
492
+
493
+ clearSessionCookies(res);
494
+
495
+ console.log(`[mbkauthe] User "${username}" logged out successfully`);
496
+ res.status(200).json({ success: true, message: "Logout successful" });
497
+ });
498
+ } catch (err) {
499
+ console.error("[mbkauthe] Database query error during logout:", err);
500
+ res.status(500).json({ success: false, message: "Internal Server Error" });
501
+ }
502
+ } else {
503
+ res.status(400).json({ success: false, message: "Not logged in" });
504
+ }
505
+ });
506
+
507
+ // GET /mbkauthe/login
508
+ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
509
+ return res.render("loginmbkauthe.handlebars", {
510
+ layout: false,
511
+ githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
512
+ customURL: mbkautheVar.loginRedirectURL || '/dashboard',
513
+ userLoggedIn: !!req.session?.user,
514
+ username: req.session?.user?.username || '',
515
+ version: packageJson.version,
516
+ appName: mbkautheVar.APP_NAME.toLowerCase(),
517
+ csrfToken: req.csrfToken(),
518
+ });
519
+ });
520
+
521
+ export default router;