mbkauthe 4.8.4 → 5.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.
@@ -4,6 +4,7 @@ const PgSession = pgSession(session);
4
4
  import { dblogin, runWithRequestContext } from "#pool.js";
5
5
  import { mbkautheVar } from "#config.js";
6
6
  import { cachedCookieOptions, decryptSessionId, encryptSessionId } from "#cookies.js";
7
+ import { AuthRepository } from "../db/AuthRepository.js";
7
8
 
8
9
  // Session configuration
9
10
  export const sessionConfig = {
@@ -33,6 +34,9 @@ export const sessionConfig = {
33
34
  name: 'mbkauthe.sid'
34
35
  };
35
36
 
37
+ const authRepo = new AuthRepository({ db: dblogin });
38
+ const hasAuthorizationHeader = (req) => typeof req.headers?.authorization === 'string' && req.headers.authorization.trim().length > 0;
39
+
36
40
  // CORS middleware
37
41
  export function corsMiddleware(req, res, next) {
38
42
  const origin = req.headers.origin;
@@ -57,6 +61,10 @@ export function corsMiddleware(req, res, next) {
57
61
 
58
62
  // Session restoration middleware
59
63
  export async function sessionRestorationMiddleware(req, res, next) {
64
+ if (hasAuthorizationHeader(req)) {
65
+ return next();
66
+ }
67
+
60
68
  // Only restore session if not already present and sessionId cookie exists
61
69
  if (!req.session.user && req.cookies.sessionId) {
62
70
  // Decrypt the sessionId from cookie
@@ -77,45 +85,26 @@ export async function sessionRestorationMiddleware(req, res, next) {
77
85
 
78
86
  try {
79
87
  // Validate session by DB primary key id and join to user
80
- const query = `SELECT u.id, u."UserName", u."Active", u."Role", u."AllowedApps", s.expires_at
81
- FROM "Sessions" s
82
- JOIN "Users" u ON s."UserName" = u."UserName"
83
- WHERE s.id = $1 LIMIT 1`;
84
- const result = await dblogin.query({ name: 'restore-user-session', text: query, values: [sessionId] });
85
-
86
- if (result.rows.length > 0) {
87
- const row = result.rows[0];
88
+ const row = await authRepo.getSessionWithUserById(sessionId, 'restore-user-session');
88
89
 
90
+ if (row) {
89
91
  // Reject expired sessions or inactive users
90
92
  if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
91
93
  // leave cookies cleared and don't restore session
92
94
  } else {
93
95
  const normalizedSessionId = String(sessionId);
94
96
  req.session.user = {
95
- id: row.id,
97
+ id: row.uid,
96
98
  username: row.UserName,
97
99
  role: row.Role,
98
100
  sessionId: normalizedSessionId,
99
101
  allowedApps: row.AllowedApps,
100
102
  };
101
103
 
102
- // Use cached FullName from client cookie when available to avoid extra DB queries
103
- if (req.cookies && req.cookies.fullName && typeof req.cookies.fullName === 'string') {
104
+ if (typeof row.FullName === 'string' && row.FullName.trim() !== '') {
105
+ req.session.user.fullname = row.FullName;
106
+ } else if (req.cookies && typeof req.cookies.fullName === 'string') {
104
107
  req.session.user.fullname = req.cookies.fullName;
105
- } else {
106
- // Fallback: attempt to fetch FullName from Users to populate session
107
- try {
108
- const profileRes = await dblogin.query({
109
- name: 'restore-get-fullname',
110
- text: 'SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
111
- values: [row.UserName]
112
- });
113
- if (profileRes.rows.length > 0 && profileRes.rows[0].FullName) {
114
- req.session.user.fullname = profileRes.rows[0].FullName;
115
- }
116
- } catch (profileErr) {
117
- console.error(`[mbkauthe] Error fetching FullName during session restore:`, profileErr);
118
- }
119
108
  }
120
109
  }
121
110
  }
@@ -128,6 +117,10 @@ export async function sessionRestorationMiddleware(req, res, next) {
128
117
 
129
118
  // Session cookie sync middleware
130
119
  export function sessionCookieSyncMiddleware(req, res, next) {
120
+ if (hasAuthorizationHeader(req) || req.auth?.type === 'api-token') {
121
+ return next();
122
+ }
123
+
131
124
  if (req.session && req.session.user) {
132
125
  // Decrypt existing cookie to compare with session
133
126
  const currentDecryptedId = decryptSessionId(req.cookies.sessionId);
@@ -13,8 +13,13 @@ import { ErrorCodes, createErrorResponse } from '../utils/errors.js';
13
13
  export function validateTokenScope(req, res, next) {
14
14
  // Only validate for API token requests (not cookie-based sessions)
15
15
  // Check if this request was authenticated via API token
16
- if (req.session?.user?.sessionId === 'api-token-session' && req.session?.user?.tokenScope) {
17
- const tokenScope = req.session.user.tokenScope;
16
+ const tokenScope = req.auth?.type === 'api-token'
17
+ ? req.auth.tokenScope
18
+ : req.session?.user?.sessionId === 'api-token-session'
19
+ ? req.session.user.tokenScope
20
+ : null;
21
+
22
+ if (tokenScope) {
18
23
  const requestMethod = req.method;
19
24
 
20
25
  // Check if scope allows this HTTP method
@@ -29,4 +34,4 @@ export function validateTokenScope(req, res, next) {
29
34
  }
30
35
 
31
36
  next();
32
- }
37
+ }
package/lib/pool.js CHANGED
@@ -9,12 +9,11 @@ export { runWithRequestContext, getRequestContext };
9
9
 
10
10
  const poolConfig = {
11
11
  connectionString: mbkautheVar.LOGIN_DB,
12
- ssl: {
13
- rejectUnauthorized: true,
14
- },
15
- max: 3,
16
- idleTimeoutMillis: 10000,
17
- connectionTimeoutMillis: 10000,
12
+ ssl: { rejectUnauthorized: true },
13
+ max: 10,
14
+ idleTimeoutMillis: 30000,
15
+ connectionTimeoutMillis: 5000,
16
+ application_name: `${mbkautheVar.APP_NAME}-mbkauthe-app`,
18
17
  };
19
18
 
20
19
  export const dblogin = new Pool(poolConfig);
@@ -12,10 +12,14 @@ import {
12
12
  encryptSessionId
13
13
  } from "#cookies.js";
14
14
  import { packageJson } from "#config.js";
15
- import { hashPassword, hashApiToken } from "#config.js";
15
+ import { hashPassword } from "#config.js";
16
16
  import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
17
+ import { AuthRepository } from "../db/AuthRepository.js";
18
+ import { createLogger } from "../utils/logger.js";
17
19
 
18
20
  const router = express.Router();
21
+ const authRepo = new AuthRepository({ db: dblogin });
22
+ const logAuth = createLogger("auth");
19
23
 
20
24
  // Helper function to clear profile picture cache
21
25
  function clearProfilePicCache(req, username) {
@@ -68,13 +72,8 @@ const csrfProtection = csurf({ cookie: true });
68
72
  // Helper: load a session by DB id and validate basics
69
73
  async function fetchActiveSession(sessionId) {
70
74
  if (!sessionId || typeof sessionId !== 'string') return null;
71
- const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
72
- FROM "Sessions" s
73
- JOIN "Users" u ON s."UserName" = u."UserName"
74
- WHERE s.id = $1 LIMIT 1`;
75
- const result = await dblogin.query({ name: 'multi-session-fetch', text: query, values: [sessionId] });
76
- if (result.rows.length === 0) return null;
77
- const row = result.rows[0];
75
+ const row = await authRepo.fetchActiveSession(sessionId);
76
+ if (!row) return null;
78
77
  if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) return null;
79
78
  if (row.Role !== 'SuperAdmin') {
80
79
  const allowedApps = row.AllowedApps;
@@ -91,7 +90,7 @@ const isUuid = (val) => typeof val === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-
91
90
  async function invalidateDbSession(sessionId) {
92
91
  if (!isUuid(sessionId)) return;
93
92
  try {
94
- await dblogin.query({ name: 'invalidate-app-session', text: 'DELETE FROM "Sessions" WHERE id = $1', values: [sessionId] });
93
+ await authRepo.deleteAppSessionById(sessionId);
95
94
  } catch (err) {
96
95
  console.error(`[mbkauthe] Error invalidating session:`, err);
97
96
  }
@@ -111,28 +110,12 @@ export async function checkTrustedDevice(req, username) {
111
110
  // Hash the provided device token before querying DB (we store token hashes in DB)
112
111
  const deviceTokenHash = hashDeviceToken(deviceToken);
113
112
  // Single round-trip: validate trusted device AND refresh LastUsed.
114
- const deviceQuery = `
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"
124
- `;
125
- const deviceResult = await dblogin.query({
126
- name: 'check-trusted-device',
127
- text: deviceQuery,
128
- values: [deviceTokenHash, username]
129
- });
113
+ const deviceUser = await authRepo.touchTrustedDevice(deviceTokenHash, username);
130
114
 
131
- if (deviceResult.rows.length > 0) {
132
- const deviceUser = deviceResult.rows[0];
115
+ if (deviceUser) {
133
116
 
134
117
  if (!deviceUser.Active) {
135
- console.log(`[mbkauthe] Trusted device check: inactive account for username: ${username}`);
118
+ logAuth(`Trusted device check: inactive account for username: ${username}`);
136
119
  return null;
137
120
  }
138
121
 
@@ -144,7 +127,7 @@ export async function checkTrustedDevice(req, username) {
144
127
  }
145
128
  }
146
129
 
147
- console.log(`[mbkauthe] Trusted device validated for user: ${username}`);
130
+ logAuth(`Trusted device validated for user: ${username}`);
148
131
  return {
149
132
  id: deviceUser.id,
150
133
  username: username,
@@ -174,11 +157,7 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
174
157
  const oldSessionId = req.sessionID;
175
158
 
176
159
  // Delete old session first to prevent session fixation attacks
177
- await dblogin.query({
178
- name: 'login-delete-old-session-before-regen',
179
- text: 'DELETE FROM "session" WHERE sid = $1',
180
- values: [oldSessionId]
181
- });
160
+ await authRepo.deleteSessionBySid(oldSessionId);
182
161
 
183
162
  // Now regenerate with new session ID (timing window closed)
184
163
  await new Promise((resolve, reject) => {
@@ -193,67 +172,40 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
193
172
  const configuredMax = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
194
173
  const MAX_SESSIONS = Number.isInteger(configuredMax) && configuredMax > 0 ? configuredMax : 5;
195
174
 
196
- const dbClient = await dblogin.connect();
197
175
  let dbSessionId;
198
176
  try {
199
- await dbClient.query('BEGIN');
200
- await dbClient.query('LOCK TABLE "Sessions" IN SHARE ROW EXCLUSIVE MODE');
201
-
202
- // Clean up expired sessions + count active sessions in a single round-trip.
203
- const countRes = await dbClient.query({
204
- name: 'cleanup-and-count-user-sessions',
205
- text: `
206
- WITH deleted AS (
207
- DELETE FROM "Sessions"
208
- WHERE "UserName" = $1
209
- AND expires_at IS NOT NULL
210
- AND expires_at <= NOW()
211
- )
212
- SELECT COUNT(*)::int AS count
213
- FROM "Sessions"
214
- WHERE "UserName" = $1
215
- `,
216
- values: [username]
217
- });
177
+ await authRepo.withTransaction(async (txRepo) => {
178
+ await txRepo.advisoryTransactionLock(`sessions:${username}`, "lock-user-sessions");
179
+ const currentSessions = await txRepo.cleanupAndCountUserSessions(username);
180
+ if (currentSessions >= MAX_SESSIONS) {
181
+ const sessionsToDelete = currentSessions - MAX_SESSIONS + 1; // +1 to make room for new session
182
+ logAuth(`User "${username}" has ${currentSessions} active sessions, exceeding max of ${MAX_SESSIONS}. Deleting ${sessionsToDelete} oldest sessions.`);
183
+
184
+ await txRepo.deleteOldestSessionsForUser(username, sessionsToDelete, "prune-oldest-user-session");
185
+ }
218
186
 
219
- const currentSessions = Number(countRes.rows?.[0]?.count ?? 0);
220
- if (currentSessions >= MAX_SESSIONS) {
221
- const sessionsToDelete = currentSessions - MAX_SESSIONS + 1; // +1 to make room for new session
222
- console.log(`[mbkauthe] User "${username}" has ${currentSessions} active sessions, exceeding max of ${MAX_SESSIONS}. Deleting ${sessionsToDelete} oldest sessions.`);
187
+ const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0));
188
+ const insertedSession = await txRepo.insertAppSession(
189
+ username,
190
+ expiresAt,
191
+ JSON.stringify({ ip: req.ip, ua: req.headers['user-agent'] || null })
192
+ );
223
193
 
224
- await dbClient.query({
225
- name: 'prune-oldest-user-session',
226
- text: `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at ASC LIMIT $2)`,
227
- values: [username, sessionsToDelete]
228
- });
229
- }
194
+ if (!insertedSession?.id) {
195
+ throw new Error('Failed to insert app session');
196
+ }
230
197
 
231
- const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0));
232
- const insertRes = await dbClient.query({
233
- name: 'insert-app-session',
234
- text: `INSERT INTO "Sessions" ("UserName", expires_at, meta) VALUES ($1, $2, $3) RETURNING id`,
235
- values: [username, expiresAt, JSON.stringify({ ip: req.ip, ua: req.headers['user-agent'] || null })]
198
+ dbSessionId = insertedSession.id;
236
199
  });
237
- dbSessionId = insertRes.rows[0].id;
238
-
239
- await dbClient.query('COMMIT');
240
200
  } catch (err) {
241
- await dbClient.query('ROLLBACK').catch(() => {});
242
201
  console.error(`[mbkauthe] Error enforcing session limit or inserting app session:`, err);
243
202
  throw err;
244
- } finally {
245
- dbClient.release();
246
203
  }
247
204
 
248
205
  // Update last_login and fetch FullName/Image in a single query.
249
206
  let profileRow = null;
250
207
  try {
251
- const profUpdateRes = await dblogin.query({
252
- name: 'login-update-last-login-return-profile',
253
- text: `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`,
254
- values: [user.id]
255
- });
256
- profileRow = profUpdateRes.rows?.[0] || null;
208
+ profileRow = await authRepo.updateLastLoginReturnProfile(user.id);
257
209
  } catch (profileUpdateErr) {
258
210
  console.error(`[mbkauthe] Error updating last_login/returning profile:`, profileUpdateErr);
259
211
  }
@@ -277,14 +229,10 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
277
229
  } else {
278
230
  // Fallback: try a read query if UPDATE...RETURNING failed unexpectedly.
279
231
  try {
280
- const profileResult = await dblogin.query({
281
- name: 'login-get-fullname-and-image',
282
- text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
283
- values: [username]
284
- });
285
- if (profileResult.rows.length > 0) {
286
- if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName;
287
- if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image;
232
+ const profileResult = await authRepo.getUserProfileByUsername(username);
233
+ if (profileResult) {
234
+ if (profileResult.FullName) req.session.user.fullname = profileResult.FullName;
235
+ if (profileResult.Image && profileResult.Image.trim() !== '') loginProfileImage = profileResult.Image;
288
236
  }
289
237
  } catch (profileErr) {
290
238
  console.error(`[mbkauthe] Error fetching FullName/Image for user:`, profileErr);
@@ -340,23 +288,25 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
340
288
 
341
289
  // Store only the HASH of the device token in DB; send the raw token to the client (httpOnly cookie)
342
290
  const deviceTokenHash = hashDeviceToken(deviceToken);
343
- await dblogin.query({
344
- name: 'insert-trusted-device',
345
- text: `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
346
- VALUES ($1, $2, $3, $4, $5, $6)`,
347
- values: [username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt]
291
+ await authRepo.insertTrustedDevice({
292
+ username,
293
+ deviceTokenHash,
294
+ deviceName,
295
+ userAgent,
296
+ ipAddress,
297
+ expiresAt
348
298
  });
349
299
 
350
300
  // Send raw token to client as httpOnly cookie only
351
301
  res.cookie("device_token", deviceToken, getDeviceTokenCookieOptions());
352
- console.log(`[mbkauthe] Trusted device token created for user: ${username}`);
302
+ logAuth(`Trusted device token created for user: ${username}`);
353
303
  } catch (deviceErr) {
354
304
  console.error(`[mbkauthe] Error creating trusted device:`, deviceErr);
355
305
  // Continue with login even if device trust fails
356
306
  }
357
307
  }
358
308
 
359
- console.log(`[mbkauthe] User "${username}" logged in successfully (last_login updated)`);
309
+ logAuth(`User "${username}" logged in successfully (last_login updated)`);
360
310
 
361
311
  const responsePayload = {
362
312
  success: true,
@@ -378,7 +328,7 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
378
328
 
379
329
  // POST /mbkauthe/api/login
380
330
  router.post("/api/login", LoginLimit, async (req, res) => {
381
- console.log(`[mbkauthe] Login request received`);
331
+ logAuth(`Login request received`);
382
332
 
383
333
  const { username, password, redirect } = req.body;
384
334
 
@@ -408,30 +358,21 @@ router.post("/api/login", LoginLimit, async (req, res) => {
408
358
  );
409
359
  }
410
360
 
411
- console.log(`[mbkauthe] Login attempt for username: ${username.trim()}`);
361
+ logAuth(`Login attempt for username: ${username.trim()}`);
412
362
 
413
363
  const trimmedUsername = username.trim();
414
364
 
415
365
  try {
416
366
  // Combined query: fetch user data and 2FA status in one query
417
- const userQuery = `
418
- SELECT u.id, u."UserName", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
419
- tfa."TwoFAStatus"
420
- FROM "Users" u
421
- LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
422
- WHERE u."UserName" = $1
423
- `;
424
- const userResult = await dblogin.query({ name: 'login-get-user', text: userQuery, values: [trimmedUsername] });
425
-
426
- if (userResult.rows.length === 0) {
367
+ const user = await authRepo.getUserWithTwoFA(trimmedUsername);
368
+
369
+ if (!user) {
427
370
  logError('Login attempt', ErrorCodes.USER_NOT_FOUND, { username: trimmedUsername });
428
371
  return res.status(401).json(
429
372
  createErrorResponse(401, ErrorCodes.INVALID_CREDENTIALS)
430
373
  );
431
374
  }
432
375
 
433
- const user = userResult.rows[0];
434
-
435
376
  // Password verification (hash-only). We never read/compare plaintext passwords.
436
377
  let passwordMatches = false;
437
378
  if (user.PasswordEnc) {
@@ -474,7 +415,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
474
415
  // Check for trusted device AFTER password validation
475
416
  const trustedDeviceUser = await checkTrustedDevice(req, trimmedUsername);
476
417
  if (trustedDeviceUser && (mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLocaleLowerCase() === "true" && user.TwoFAStatus) {
477
- console.log(`[mbkauthe] Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
418
+ logAuth(`Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
478
419
 
479
420
  const userForSession = {
480
421
  id: user.id,
@@ -496,7 +437,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
496
437
  allowedApps: user.AllowedApps,
497
438
  redirectUrl: requestedRedirect
498
439
  };
499
- console.log(`[mbkauthe] 2FA required for user: ${trimmedUsername}`);
440
+ logAuth(`2FA required for user: ${trimmedUsername}`);
500
441
  return res.json({ success: true, twoFactorRequired: true, redirectUrl: requestedRedirect });
501
442
  }
502
443
 
@@ -575,16 +516,15 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
575
516
  const shouldTrustDevice = trustDevice === true || trustDevice === 'true';
576
517
 
577
518
  try {
578
- const query = `SELECT tfa."TwoFASecret" FROM "TwoFA" tfa WHERE tfa."UserName" = $1`;
579
- const twoFAResult = await dblogin.query({ name: 'verify-2fa-secret', text: query, values: [username] });
519
+ const twoFARecord = await authRepo.getTwoFASecret(username);
580
520
 
581
- if (twoFAResult.rows.length === 0 || !twoFAResult.rows[0].TwoFASecret) {
521
+ if (!twoFARecord || !twoFARecord.TwoFASecret) {
582
522
  return res.status(500).json(
583
523
  createErrorResponse(500, ErrorCodes.TWO_FA_NOT_CONFIGURED)
584
524
  );
585
525
  }
586
526
 
587
- const sharedSecret = twoFAResult.rows[0].TwoFASecret;
527
+ const sharedSecret = twoFARecord.TwoFASecret;
588
528
  const allowedApps = req.session.preAuthUser?.allowedApps;
589
529
  const tokenValidates = speakeasy.totp.verify({
590
530
  secret: sharedSecret,
@@ -632,11 +572,11 @@ router.post("/api/logout", LogoutLimit, async (req, res) => {
632
572
  // Remove the application session record for this token (if present)
633
573
  const operations = [];
634
574
  if (req.session && req.session.user && req.session.user.sessionId) {
635
- operations.push(dblogin.query({ name: 'logout-delete-app-session', text: 'DELETE FROM "Sessions" WHERE id = $1', values: [req.session.user.sessionId] }));
575
+ operations.push(authRepo.deleteAppSessionById(req.session.user.sessionId, "logout-delete-app-session"));
636
576
  }
637
577
 
638
578
  if (req.sessionID) {
639
- operations.push(dblogin.query({ name: 'logout-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] }));
579
+ operations.push(authRepo.deleteSessionBySid(req.sessionID, "logout-delete-session"));
640
580
  }
641
581
 
642
582
  await Promise.all(operations);
@@ -654,7 +594,7 @@ router.post("/api/logout", LogoutLimit, async (req, res) => {
654
594
 
655
595
  clearSessionCookies(res);
656
596
 
657
- console.log(`[mbkauthe] User "${username}" logged out successfully`);
597
+ logAuth(`User "${username}" logged out successfully`);
658
598
  res.status(200).json({ success: true, message: "Logout successful" });
659
599
  });
660
600
  } catch (err) {
@@ -673,54 +613,52 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
673
613
  return res.json({ accounts: [], currentSessionId: req.session?.user?.sessionId || null });
674
614
  }
675
615
 
676
- const validated = [];
677
616
  const currentSessionId = req.session?.user?.sessionId || null;
678
617
 
618
+ const validAccountEntries = [];
679
619
  for (const acct of storedAccounts) {
680
620
  if (!isUuid(acct.sessionId)) {
681
621
  removeAccountFromCookie(req, res, acct.sessionId);
682
622
  continue;
683
623
  }
624
+ validAccountEntries.push(acct);
625
+ }
684
626
 
685
- try {
686
- const row = await fetchActiveSession(acct.sessionId);
687
- if (!row) {
627
+ const sessionIds = validAccountEntries.map((acct) => acct.sessionId);
628
+
629
+ try {
630
+ const sessionRows = await authRepo.getSessionsWithUsersByIds(sessionIds, "multi-session-fetch-many");
631
+ const sessionMap = new Map(sessionRows.map((row) => [row.sid, row]));
632
+ const validated = [];
633
+
634
+ for (const acct of validAccountEntries) {
635
+ const row = sessionMap.get(acct.sessionId);
636
+ const expired = row?.expires_at && new Date(row.expires_at) <= new Date();
637
+ const authorized = row && row.Active && (
638
+ row.Role === "SuperAdmin" ||
639
+ (Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase()))
640
+ );
641
+
642
+ if (!row || expired || !authorized) {
688
643
  await invalidateDbSession(acct.sessionId);
689
644
  removeAccountFromCookie(req, res, acct.sessionId);
690
645
  continue;
691
646
  }
692
647
 
693
- let fullName = acct.fullName || acct.username;
694
- let image = acct.image || null;
695
- if (!acct.fullName || !acct.image) {
696
- try {
697
- const prof = await dblogin.query({
698
- name: 'multi-session-fullname-image',
699
- text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
700
- values: [row.UserName]
701
- });
702
- if (prof.rows.length > 0) {
703
- if (!acct.fullName && prof.rows[0].FullName) fullName = prof.rows[0].FullName;
704
- if (!acct.image && prof.rows[0].Image && prof.rows[0].Image.trim() !== '') image = prof.rows[0].Image;
705
- }
706
- } catch (profileErr) {
707
- console.error(`[mbkauthe] Error fetching fullname/image for account list:`, profileErr);
708
- }
709
- }
710
-
711
648
  validated.push({
712
649
  sessionId: row.sid,
713
650
  username: row.UserName,
714
- fullName,
715
- image,
651
+ fullName: acct.fullName || row.FullName || acct.username || row.UserName,
652
+ image: acct.image || (typeof row.Image === 'string' && row.Image.trim() !== '' ? row.Image : null),
716
653
  isCurrent: currentSessionId && row.sid === currentSessionId
717
654
  });
718
- } catch (err) {
719
- console.error(`[mbkauthe] Error validating remembered account:`, err);
720
655
  }
721
- }
722
656
 
723
- return res.json({ accounts: validated, currentSessionId });
657
+ return res.json({ accounts: validated, currentSessionId });
658
+ } catch (err) {
659
+ console.error(`[mbkauthe] Error validating remembered accounts:`, err);
660
+ return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR));
661
+ }
724
662
  });
725
663
 
726
664
  // Switch active session to another remembered account
@@ -745,21 +683,8 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
745
683
  return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
746
684
  }
747
685
 
748
- let fullName = row.UserName;
749
- let switchProfileImage = null;
750
- try {
751
- const prof = await dblogin.query({
752
- name: 'multi-session-switch-fullname-image',
753
- text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
754
- values: [row.UserName]
755
- });
756
- if (prof.rows.length > 0) {
757
- if (prof.rows[0].FullName) fullName = prof.rows[0].FullName;
758
- if (prof.rows[0].Image && prof.rows[0].Image.trim() !== '') switchProfileImage = prof.rows[0].Image;
759
- }
760
- } catch (profileErr) {
761
- console.error(`[mbkauthe] Error fetching fullname/image during switch:`, profileErr);
762
- }
686
+ const fullName = row.FullName || row.UserName;
687
+ const switchProfileImage = typeof row.Image === 'string' && row.Image.trim() !== '' ? row.Image : null;
763
688
 
764
689
  // Regenerate session to avoid fixation
765
690
  await new Promise((resolve, reject) => {
@@ -816,15 +741,11 @@ router.post("/api/logout-all", LoginLimit, async (req, res) => {
816
741
  if (currentSessionId) sessionIds.push(currentSessionId);
817
742
 
818
743
  if (sessionIds.length) {
819
- await dblogin.query({
820
- name: 'logout-all-app-sessions',
821
- text: 'DELETE FROM "Sessions" WHERE id = ANY($1)',
822
- values: [sessionIds]
823
- });
744
+ await authRepo.deleteSessionsByIds(sessionIds, "logout-all-app-sessions");
824
745
  }
825
746
 
826
747
  if (req.sessionID) {
827
- await dblogin.query({ name: 'logout-all-delete-session', text: 'DELETE FROM "session" WHERE sid = $1', values: [req.sessionID] });
748
+ await authRepo.deleteSessionBySid(req.sessionID, "logout-all-delete-session");
828
749
  }
829
750
 
830
751
  clearAccountListCookie(res);
@@ -842,6 +763,9 @@ router.post("/api/logout-all", LoginLimit, async (req, res) => {
842
763
  // GET /mbkauthe/login
843
764
  router.get("/login", LoginLimit, csrfProtection, (req, res) => {
844
765
  const lastLogin = req.cookies && typeof req.cookies.lastLoginMethod === 'string' ? req.cookies.lastLoginMethod : null;
766
+ const reason = req.query.reason;
767
+ const redirectTarget = req.query.redirect || null;
768
+
845
769
  return res.render("pages/loginmbkauthe.handlebars", {
846
770
  layout: false,
847
771
  githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
@@ -856,7 +780,9 @@ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
856
780
  lastLoginMethod: lastLogin,
857
781
  lastLoginPassword: lastLogin === 'password',
858
782
  lastLoginGithub: lastLogin === 'github',
859
- lastLoginGoogle: lastLogin === 'google'
783
+ lastLoginGoogle: lastLogin === 'google',
784
+ showLoggedOutMessage: reason === 'logged_out',
785
+ redirectTarget: redirectTarget
860
786
  });
861
787
  });
862
788