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.
- package/README.md +1 -0
- package/docs/api.md +29 -178
- package/docs/db.md +1 -1
- package/docs/db.sql +305 -253
- package/index.js +6 -3
- package/lib/config/cookies.js +84 -18
- package/lib/config/index.js +3 -1
- package/lib/config/tokenScopes.js +1 -1
- package/lib/createTable.js +95 -8
- package/lib/db/AuthRepository.js +336 -0
- package/lib/db/BaseRepository.js +193 -0
- package/lib/db/dialects/postgres.js +18 -0
- package/lib/main.js +5 -5
- package/lib/middleware/auth.js +213 -259
- package/lib/middleware/index.js +18 -25
- package/lib/middleware/scopeValidator.js +8 -3
- package/lib/pool.js +5 -6
- package/lib/routes/auth.js +95 -169
- package/lib/routes/dbLogs.js +247 -29
- package/lib/routes/misc.js +17 -50
- package/lib/routes/oauth.js +23 -48
- package/lib/utils/dbQueryLogger.js +485 -80
- package/lib/utils/errors.js +1 -1
- package/lib/utils/logger.js +12 -0
- package/lib/utils/timingSafeToken.js +1 -1
- package/package.json +1 -1
- package/public/main.css +36 -3
- package/test.spec.js +515 -48
- package/views/header.handlebars +1 -1
- package/views/pages/2fa.handlebars +9 -5
- package/views/pages/dbLogs.handlebars +618 -420
- package/views/pages/loginmbkauthe.handlebars +42 -25
- package/views/showmessage.handlebars +2 -2
package/lib/middleware/index.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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);
|
package/lib/routes/auth.js
CHANGED
|
@@ -12,10 +12,14 @@ import {
|
|
|
12
12
|
encryptSessionId
|
|
13
13
|
} from "#cookies.js";
|
|
14
14
|
import { packageJson } from "#config.js";
|
|
15
|
-
import { hashPassword
|
|
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
|
|
72
|
-
|
|
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
|
|
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
|
|
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 (
|
|
132
|
-
const deviceUser = deviceResult.rows[0];
|
|
115
|
+
if (deviceUser) {
|
|
133
116
|
|
|
134
117
|
if (!deviceUser.Active) {
|
|
135
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
values: [username, sessionsToDelete]
|
|
228
|
-
});
|
|
229
|
-
}
|
|
194
|
+
if (!insertedSession?.id) {
|
|
195
|
+
throw new Error('Failed to insert app session');
|
|
196
|
+
}
|
|
230
197
|
|
|
231
|
-
|
|
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
|
-
|
|
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
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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 =
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
749
|
-
|
|
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
|
|
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
|
|
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
|
|