mbkauthe 4.8.4 → 4.9.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/index.js +1 -0
- package/lib/db/AuthRepository.js +295 -0
- package/lib/db/BaseRepository.js +185 -0
- package/lib/db/dialects/postgres.js +18 -0
- package/lib/middleware/auth.js +20 -49
- package/lib/middleware/index.js +8 -14
- package/lib/routes/auth.js +63 -132
- package/lib/routes/misc.js +11 -46
- package/lib/routes/oauth.js +7 -28
- package/package.json +1 -1
- package/public/main.css +35 -2
- package/views/header.handlebars +1 -1
- package/views/pages/2fa.handlebars +9 -5
- 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,8 @@ export const sessionConfig = {
|
|
|
33
34
|
name: 'mbkauthe.sid'
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
const authRepo = new AuthRepository({ db: dblogin });
|
|
38
|
+
|
|
36
39
|
// CORS middleware
|
|
37
40
|
export function corsMiddleware(req, res, next) {
|
|
38
41
|
const origin = req.headers.origin;
|
|
@@ -77,14 +80,9 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
77
80
|
|
|
78
81
|
try {
|
|
79
82
|
// 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] });
|
|
83
|
+
const row = await authRepo.getSessionWithUserById(sessionId, 'restore-user-session');
|
|
85
84
|
|
|
86
|
-
if (
|
|
87
|
-
const row = result.rows[0];
|
|
85
|
+
if (row) {
|
|
88
86
|
|
|
89
87
|
// Reject expired sessions or inactive users
|
|
90
88
|
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
|
|
@@ -105,13 +103,9 @@ export async function sessionRestorationMiddleware(req, res, next) {
|
|
|
105
103
|
} else {
|
|
106
104
|
// Fallback: attempt to fetch FullName from Users to populate session
|
|
107
105
|
try {
|
|
108
|
-
const profileRes = await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
values: [row.UserName]
|
|
112
|
-
});
|
|
113
|
-
if (profileRes.rows.length > 0 && profileRes.rows[0].FullName) {
|
|
114
|
-
req.session.user.fullname = profileRes.rows[0].FullName;
|
|
106
|
+
const profileRes = await authRepo.getUserFullNameByUsername(row.UserName, 'restore-get-fullname');
|
|
107
|
+
if (profileRes && profileRes.FullName) {
|
|
108
|
+
req.session.user.fullname = profileRes.FullName;
|
|
115
109
|
}
|
|
116
110
|
} catch (profileErr) {
|
|
117
111
|
console.error(`[mbkauthe] Error fetching FullName during session restore:`, profileErr);
|
package/lib/routes/auth.js
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
import { packageJson } from "#config.js";
|
|
15
15
|
import { hashPassword, hashApiToken } from "#config.js";
|
|
16
16
|
import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
|
|
17
|
+
import { AuthRepository } from "../db/AuthRepository.js";
|
|
17
18
|
|
|
18
19
|
const router = express.Router();
|
|
20
|
+
const authRepo = new AuthRepository({ db: dblogin });
|
|
19
21
|
|
|
20
22
|
// Helper function to clear profile picture cache
|
|
21
23
|
function clearProfilePicCache(req, username) {
|
|
@@ -68,13 +70,8 @@ const csrfProtection = csurf({ cookie: true });
|
|
|
68
70
|
// Helper: load a session by DB id and validate basics
|
|
69
71
|
async function fetchActiveSession(sessionId) {
|
|
70
72
|
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];
|
|
73
|
+
const row = await authRepo.fetchActiveSession(sessionId);
|
|
74
|
+
if (!row) return null;
|
|
78
75
|
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) return null;
|
|
79
76
|
if (row.Role !== 'SuperAdmin') {
|
|
80
77
|
const allowedApps = row.AllowedApps;
|
|
@@ -91,7 +88,7 @@ const isUuid = (val) => typeof val === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-
|
|
|
91
88
|
async function invalidateDbSession(sessionId) {
|
|
92
89
|
if (!isUuid(sessionId)) return;
|
|
93
90
|
try {
|
|
94
|
-
await
|
|
91
|
+
await authRepo.deleteAppSessionById(sessionId);
|
|
95
92
|
} catch (err) {
|
|
96
93
|
console.error(`[mbkauthe] Error invalidating session:`, err);
|
|
97
94
|
}
|
|
@@ -111,25 +108,9 @@ export async function checkTrustedDevice(req, username) {
|
|
|
111
108
|
// Hash the provided device token before querying DB (we store token hashes in DB)
|
|
112
109
|
const deviceTokenHash = hashDeviceToken(deviceToken);
|
|
113
110
|
// 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
|
-
});
|
|
111
|
+
const deviceUser = await authRepo.touchTrustedDevice(deviceTokenHash, username);
|
|
130
112
|
|
|
131
|
-
if (
|
|
132
|
-
const deviceUser = deviceResult.rows[0];
|
|
113
|
+
if (deviceUser) {
|
|
133
114
|
|
|
134
115
|
if (!deviceUser.Active) {
|
|
135
116
|
console.log(`[mbkauthe] Trusted device check: inactive account for username: ${username}`);
|
|
@@ -174,11 +155,7 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
174
155
|
const oldSessionId = req.sessionID;
|
|
175
156
|
|
|
176
157
|
// 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
|
-
});
|
|
158
|
+
await authRepo.deleteSessionBySid(oldSessionId);
|
|
182
159
|
|
|
183
160
|
// Now regenerate with new session ID (timing window closed)
|
|
184
161
|
await new Promise((resolve, reject) => {
|
|
@@ -193,67 +170,40 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
193
170
|
const configuredMax = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
|
|
194
171
|
const MAX_SESSIONS = Number.isInteger(configuredMax) && configuredMax > 0 ? configuredMax : 5;
|
|
195
172
|
|
|
196
|
-
const dbClient = await dblogin.connect();
|
|
197
173
|
let dbSessionId;
|
|
198
174
|
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
|
-
});
|
|
175
|
+
await authRepo.withTransaction(async (txRepo) => {
|
|
176
|
+
await txRepo.lockTable("Sessions", "SHARE ROW EXCLUSIVE");
|
|
177
|
+
const currentSessions = await txRepo.cleanupAndCountUserSessions(username);
|
|
178
|
+
if (currentSessions >= MAX_SESSIONS) {
|
|
179
|
+
const sessionsToDelete = currentSessions - MAX_SESSIONS + 1; // +1 to make room for new session
|
|
180
|
+
console.log(`[mbkauthe] User "${username}" has ${currentSessions} active sessions, exceeding max of ${MAX_SESSIONS}. Deleting ${sessionsToDelete} oldest sessions.`);
|
|
181
|
+
|
|
182
|
+
await txRepo.deleteOldestSessionsForUser(username, sessionsToDelete, "prune-oldest-user-session");
|
|
183
|
+
}
|
|
218
184
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
185
|
+
const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0));
|
|
186
|
+
const insertedSession = await txRepo.insertAppSession(
|
|
187
|
+
username,
|
|
188
|
+
expiresAt,
|
|
189
|
+
JSON.stringify({ ip: req.ip, ua: req.headers['user-agent'] || null })
|
|
190
|
+
);
|
|
223
191
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
values: [username, sessionsToDelete]
|
|
228
|
-
});
|
|
229
|
-
}
|
|
192
|
+
if (!insertedSession?.id) {
|
|
193
|
+
throw new Error('Failed to insert app session');
|
|
194
|
+
}
|
|
230
195
|
|
|
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 })]
|
|
196
|
+
dbSessionId = insertedSession.id;
|
|
236
197
|
});
|
|
237
|
-
dbSessionId = insertRes.rows[0].id;
|
|
238
|
-
|
|
239
|
-
await dbClient.query('COMMIT');
|
|
240
198
|
} catch (err) {
|
|
241
|
-
await dbClient.query('ROLLBACK').catch(() => {});
|
|
242
199
|
console.error(`[mbkauthe] Error enforcing session limit or inserting app session:`, err);
|
|
243
200
|
throw err;
|
|
244
|
-
} finally {
|
|
245
|
-
dbClient.release();
|
|
246
201
|
}
|
|
247
202
|
|
|
248
203
|
// Update last_login and fetch FullName/Image in a single query.
|
|
249
204
|
let profileRow = null;
|
|
250
205
|
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;
|
|
206
|
+
profileRow = await authRepo.updateLastLoginReturnProfile(user.id);
|
|
257
207
|
} catch (profileUpdateErr) {
|
|
258
208
|
console.error(`[mbkauthe] Error updating last_login/returning profile:`, profileUpdateErr);
|
|
259
209
|
}
|
|
@@ -277,14 +227,10 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
277
227
|
} else {
|
|
278
228
|
// Fallback: try a read query if UPDATE...RETURNING failed unexpectedly.
|
|
279
229
|
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;
|
|
230
|
+
const profileResult = await authRepo.getUserProfileByUsername(username);
|
|
231
|
+
if (profileResult) {
|
|
232
|
+
if (profileResult.FullName) req.session.user.fullname = profileResult.FullName;
|
|
233
|
+
if (profileResult.Image && profileResult.Image.trim() !== '') loginProfileImage = profileResult.Image;
|
|
288
234
|
}
|
|
289
235
|
} catch (profileErr) {
|
|
290
236
|
console.error(`[mbkauthe] Error fetching FullName/Image for user:`, profileErr);
|
|
@@ -340,11 +286,13 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
|
|
|
340
286
|
|
|
341
287
|
// Store only the HASH of the device token in DB; send the raw token to the client (httpOnly cookie)
|
|
342
288
|
const deviceTokenHash = hashDeviceToken(deviceToken);
|
|
343
|
-
await
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
289
|
+
await authRepo.insertTrustedDevice({
|
|
290
|
+
username,
|
|
291
|
+
deviceTokenHash,
|
|
292
|
+
deviceName,
|
|
293
|
+
userAgent,
|
|
294
|
+
ipAddress,
|
|
295
|
+
expiresAt
|
|
348
296
|
});
|
|
349
297
|
|
|
350
298
|
// Send raw token to client as httpOnly cookie only
|
|
@@ -414,24 +362,15 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
414
362
|
|
|
415
363
|
try {
|
|
416
364
|
// 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) {
|
|
365
|
+
const user = await authRepo.getUserWithTwoFA(trimmedUsername);
|
|
366
|
+
|
|
367
|
+
if (!user) {
|
|
427
368
|
logError('Login attempt', ErrorCodes.USER_NOT_FOUND, { username: trimmedUsername });
|
|
428
369
|
return res.status(401).json(
|
|
429
370
|
createErrorResponse(401, ErrorCodes.INVALID_CREDENTIALS)
|
|
430
371
|
);
|
|
431
372
|
}
|
|
432
373
|
|
|
433
|
-
const user = userResult.rows[0];
|
|
434
|
-
|
|
435
374
|
// Password verification (hash-only). We never read/compare plaintext passwords.
|
|
436
375
|
let passwordMatches = false;
|
|
437
376
|
if (user.PasswordEnc) {
|
|
@@ -575,16 +514,15 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
|
|
|
575
514
|
const shouldTrustDevice = trustDevice === true || trustDevice === 'true';
|
|
576
515
|
|
|
577
516
|
try {
|
|
578
|
-
const
|
|
579
|
-
const twoFAResult = await dblogin.query({ name: 'verify-2fa-secret', text: query, values: [username] });
|
|
517
|
+
const twoFARecord = await authRepo.getTwoFASecret(username);
|
|
580
518
|
|
|
581
|
-
if (
|
|
519
|
+
if (!twoFARecord || !twoFARecord.TwoFASecret) {
|
|
582
520
|
return res.status(500).json(
|
|
583
521
|
createErrorResponse(500, ErrorCodes.TWO_FA_NOT_CONFIGURED)
|
|
584
522
|
);
|
|
585
523
|
}
|
|
586
524
|
|
|
587
|
-
const sharedSecret =
|
|
525
|
+
const sharedSecret = twoFARecord.TwoFASecret;
|
|
588
526
|
const allowedApps = req.session.preAuthUser?.allowedApps;
|
|
589
527
|
const tokenValidates = speakeasy.totp.verify({
|
|
590
528
|
secret: sharedSecret,
|
|
@@ -632,11 +570,11 @@ router.post("/api/logout", LogoutLimit, async (req, res) => {
|
|
|
632
570
|
// Remove the application session record for this token (if present)
|
|
633
571
|
const operations = [];
|
|
634
572
|
if (req.session && req.session.user && req.session.user.sessionId) {
|
|
635
|
-
operations.push(
|
|
573
|
+
operations.push(authRepo.deleteAppSessionById(req.session.user.sessionId, "logout-delete-app-session"));
|
|
636
574
|
}
|
|
637
575
|
|
|
638
576
|
if (req.sessionID) {
|
|
639
|
-
operations.push(
|
|
577
|
+
operations.push(authRepo.deleteSessionBySid(req.sessionID, "logout-delete-session"));
|
|
640
578
|
}
|
|
641
579
|
|
|
642
580
|
await Promise.all(operations);
|
|
@@ -694,14 +632,10 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
|
|
|
694
632
|
let image = acct.image || null;
|
|
695
633
|
if (!acct.fullName || !acct.image) {
|
|
696
634
|
try {
|
|
697
|
-
const prof = await
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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;
|
|
635
|
+
const prof = await authRepo.getUserProfileByUsername(row.UserName, "multi-session-fullname-image");
|
|
636
|
+
if (prof) {
|
|
637
|
+
if (!acct.fullName && prof.FullName) fullName = prof.FullName;
|
|
638
|
+
if (!acct.image && prof.Image && prof.Image.trim() !== '') image = prof.Image;
|
|
705
639
|
}
|
|
706
640
|
} catch (profileErr) {
|
|
707
641
|
console.error(`[mbkauthe] Error fetching fullname/image for account list:`, profileErr);
|
|
@@ -748,14 +682,10 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
|
|
|
748
682
|
let fullName = row.UserName;
|
|
749
683
|
let switchProfileImage = null;
|
|
750
684
|
try {
|
|
751
|
-
const prof = await
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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;
|
|
685
|
+
const prof = await authRepo.getUserProfileByUsername(row.UserName, "multi-session-switch-fullname-image");
|
|
686
|
+
if (prof) {
|
|
687
|
+
if (prof.FullName) fullName = prof.FullName;
|
|
688
|
+
if (prof.Image && prof.Image.trim() !== '') switchProfileImage = prof.Image;
|
|
759
689
|
}
|
|
760
690
|
} catch (profileErr) {
|
|
761
691
|
console.error(`[mbkauthe] Error fetching fullname/image during switch:`, profileErr);
|
|
@@ -816,15 +746,11 @@ router.post("/api/logout-all", LoginLimit, async (req, res) => {
|
|
|
816
746
|
if (currentSessionId) sessionIds.push(currentSessionId);
|
|
817
747
|
|
|
818
748
|
if (sessionIds.length) {
|
|
819
|
-
await
|
|
820
|
-
name: 'logout-all-app-sessions',
|
|
821
|
-
text: 'DELETE FROM "Sessions" WHERE id = ANY($1)',
|
|
822
|
-
values: [sessionIds]
|
|
823
|
-
});
|
|
749
|
+
await authRepo.deleteSessionsByIds(sessionIds, "logout-all-app-sessions");
|
|
824
750
|
}
|
|
825
751
|
|
|
826
752
|
if (req.sessionID) {
|
|
827
|
-
await
|
|
753
|
+
await authRepo.deleteSessionBySid(req.sessionID, "logout-all-delete-session");
|
|
828
754
|
}
|
|
829
755
|
|
|
830
756
|
clearAccountListCookie(res);
|
|
@@ -842,6 +768,9 @@ router.post("/api/logout-all", LoginLimit, async (req, res) => {
|
|
|
842
768
|
// GET /mbkauthe/login
|
|
843
769
|
router.get("/login", LoginLimit, csrfProtection, (req, res) => {
|
|
844
770
|
const lastLogin = req.cookies && typeof req.cookies.lastLoginMethod === 'string' ? req.cookies.lastLoginMethod : null;
|
|
771
|
+
const reason = req.query.reason;
|
|
772
|
+
const redirectTarget = req.query.redirect || null;
|
|
773
|
+
|
|
845
774
|
return res.render("pages/loginmbkauthe.handlebars", {
|
|
846
775
|
layout: false,
|
|
847
776
|
githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
|
|
@@ -856,7 +785,9 @@ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
|
|
|
856
785
|
lastLoginMethod: lastLogin,
|
|
857
786
|
lastLoginPassword: lastLogin === 'password',
|
|
858
787
|
lastLoginGithub: lastLogin === 'github',
|
|
859
|
-
lastLoginGoogle: lastLogin === 'google'
|
|
788
|
+
lastLoginGoogle: lastLogin === 'google',
|
|
789
|
+
showLoggedOutMessage: reason === 'logged_out',
|
|
790
|
+
redirectTarget: redirectTarget
|
|
860
791
|
});
|
|
861
792
|
});
|
|
862
793
|
|
package/lib/routes/misc.js
CHANGED
|
@@ -7,6 +7,7 @@ import { authenticate, sessVal, sessRole } from "../middleware/auth.js";
|
|
|
7
7
|
import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js";
|
|
8
8
|
import { dblogin } from "#pool.js";
|
|
9
9
|
import { clearSessionCookies, decryptSessionId, cachedCookieOptions } from "#cookies.js";
|
|
10
|
+
import { AuthRepository } from "../db/AuthRepository.js";
|
|
10
11
|
import { fileURLToPath } from "url";
|
|
11
12
|
import path from "path";
|
|
12
13
|
import fs from "fs";
|
|
@@ -18,6 +19,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
const router = express.Router();
|
|
22
|
+
const authRepo = new AuthRepository({ db: dblogin });
|
|
21
23
|
// Rate limiter for info/test routes
|
|
22
24
|
const LoginLimit = rateLimit({
|
|
23
25
|
windowMs: 1 * 60 * 1000,
|
|
@@ -100,14 +102,10 @@ router.get('/user/profilepic', async (req, res) => {
|
|
|
100
102
|
|
|
101
103
|
// If not in cache, fetch from DB
|
|
102
104
|
if (!imageUrl) {
|
|
103
|
-
const
|
|
104
|
-
name: 'get-user-profile-pic',
|
|
105
|
-
text: 'SELECT "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
|
|
106
|
-
values: [username]
|
|
107
|
-
});
|
|
105
|
+
const profile = await authRepo.getUserImageByUsername(username, 'get-user-profile-pic');
|
|
108
106
|
|
|
109
|
-
if (
|
|
110
|
-
imageUrl =
|
|
107
|
+
if (profile && profile.Image && profile.Image.trim() !== '') {
|
|
108
|
+
imageUrl = profile.Image;
|
|
111
109
|
} else {
|
|
112
110
|
imageUrl = 'default';
|
|
113
111
|
}
|
|
@@ -229,31 +227,13 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
|
|
|
229
227
|
}
|
|
230
228
|
|
|
231
229
|
// Single round-trip: fetch app-session expiry and (if needed) connect-pg-simple expiry.
|
|
232
|
-
const
|
|
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
|
-
});
|
|
230
|
+
const row = await authRepo.getSessionValidity(sessionId, req.sessionID, 'check-session-validity');
|
|
249
231
|
|
|
250
|
-
if (
|
|
232
|
+
if (!row) {
|
|
251
233
|
req.session.destroy(() => { });
|
|
252
234
|
clearSessionCookies(res);
|
|
253
235
|
return res.status(200).json({ sessionValid: false, expiry: null });
|
|
254
236
|
}
|
|
255
|
-
|
|
256
|
-
const row = result.rows[0];
|
|
257
237
|
if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
|
|
258
238
|
req.session.destroy(() => { });
|
|
259
239
|
clearSessionCookies(res);
|
|
@@ -298,17 +278,8 @@ function normalizeSessionIdFromBody(body = {}) {
|
|
|
298
278
|
}
|
|
299
279
|
|
|
300
280
|
async function getSessionValidationRow(sessionId, queryName = 'check-session-validity-by-id') {
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
text: `SELECT s.expires_at, u."Active", u."UserName", u."Role" FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1`,
|
|
304
|
-
values: [sessionId]
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
if (result.rows.length === 0) {
|
|
308
|
-
return null;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return result.rows[0];
|
|
281
|
+
const row = await authRepo.getSessionValidationRow(sessionId, queryName);
|
|
282
|
+
return row || null;
|
|
312
283
|
}
|
|
313
284
|
|
|
314
285
|
function isSessionRowValid(row) {
|
|
@@ -567,14 +538,8 @@ router.post("/api/terminateAllSessions", AdminOperationLimit, authenticate(mbkau
|
|
|
567
538
|
try {
|
|
568
539
|
// Run both operations in parallel for better performance
|
|
569
540
|
await Promise.all([
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
text: 'DELETE FROM "Sessions"'
|
|
573
|
-
}),
|
|
574
|
-
dblogin.query({
|
|
575
|
-
name: 'terminate-all-db-sessions',
|
|
576
|
-
text: 'DELETE FROM "session" WHERE expire > NOW()'
|
|
577
|
-
})
|
|
541
|
+
authRepo.deleteAllAppSessions('terminate-all-app-sessions'),
|
|
542
|
+
authRepo.deleteActiveSessionStoreRows('terminate-all-db-sessions')
|
|
578
543
|
]);
|
|
579
544
|
|
|
580
545
|
req.session.destroy((err) => {
|
package/lib/routes/oauth.js
CHANGED
|
@@ -8,8 +8,10 @@ import { dblogin } from "#pool.js";
|
|
|
8
8
|
import { mbkautheVar } from "#config.js";
|
|
9
9
|
import { renderError } from "../utils/response.js";
|
|
10
10
|
import { checkTrustedDevice, completeLoginProcess } from "./auth.js";
|
|
11
|
+
import { AuthRepository } from "../db/AuthRepository.js";
|
|
11
12
|
|
|
12
13
|
const router = express.Router();
|
|
14
|
+
const authRepo = new AuthRepository({ db: dblogin });
|
|
13
15
|
|
|
14
16
|
// CSRF protection middleware
|
|
15
17
|
const csrfProtection = csurf({ cookie: true });
|
|
@@ -42,25 +44,16 @@ const createOAuthStrategy = async (provider, profile, done) => {
|
|
|
42
44
|
console.log(`[mbkauthe] ${provider} OAuth callback for user: ${profile.emails?.[0]?.value || profile.id}`);
|
|
43
45
|
|
|
44
46
|
const isGitHub = provider === 'GitHub';
|
|
45
|
-
const tableName = isGitHub ? 'user_github' : 'user_google';
|
|
46
|
-
const idField = isGitHub ? 'github_id' : 'google_id';
|
|
47
|
-
const queryName = isGitHub ? 'github-login-get-user' : 'google-login-get-user';
|
|
48
47
|
|
|
49
48
|
// Check if this OAuth account is linked to any user
|
|
50
|
-
const
|
|
51
|
-
name: queryName,
|
|
52
|
-
text: `SELECT ug.*, u."UserName", u."Role", u."Active", u."AllowedApps", u."id" FROM ${tableName} ug JOIN "Users" u ON ug.user_name = u."UserName" WHERE ug.${idField} = $1`,
|
|
53
|
-
values: [profile.id]
|
|
54
|
-
});
|
|
49
|
+
const user = await authRepo.getOAuthUserByProviderId(provider, profile.id);
|
|
55
50
|
|
|
56
|
-
if (
|
|
51
|
+
if (!user) {
|
|
57
52
|
const error = new Error(`${provider} account not linked to any user`);
|
|
58
53
|
error.code = `${provider.toUpperCase()}_NOT_LINKED`;
|
|
59
54
|
return done(error);
|
|
60
55
|
}
|
|
61
56
|
|
|
62
|
-
const user = oauthUser.rows[0];
|
|
63
|
-
|
|
64
57
|
// Check if the user account is active
|
|
65
58
|
if (!user.Active) {
|
|
66
59
|
const error = new Error('Account is inactive');
|
|
@@ -300,21 +293,9 @@ const validateOAuthCallback = (req, res) => {
|
|
|
300
293
|
};
|
|
301
294
|
|
|
302
295
|
const finishProviderLogin = async (req, res, provider, username, detailValue = '') => {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
FROM "Users" u
|
|
307
|
-
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
308
|
-
WHERE u."UserName" = $1
|
|
309
|
-
`;
|
|
310
|
-
|
|
311
|
-
const userResult = await dblogin.query({
|
|
312
|
-
name: `${provider.toLowerCase()}-callback-get-user`,
|
|
313
|
-
text: userQuery,
|
|
314
|
-
values: [username]
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
if (userResult.rows.length === 0) {
|
|
296
|
+
const user = await authRepo.getUserWithTwoFA(username, `${provider.toLowerCase()}-callback-get-user`);
|
|
297
|
+
|
|
298
|
+
if (!user) {
|
|
318
299
|
console.error(`[mbkauthe] ${provider} login: User not found: ${username}`);
|
|
319
300
|
return renderError(res, req, {
|
|
320
301
|
code: 404,
|
|
@@ -326,8 +307,6 @@ const finishProviderLogin = async (req, res, provider, username, detailValue = '
|
|
|
326
307
|
});
|
|
327
308
|
}
|
|
328
309
|
|
|
329
|
-
const user = userResult.rows[0];
|
|
330
|
-
|
|
331
310
|
// Check for trusted device
|
|
332
311
|
const trustedDeviceUser = await checkTrustedDevice(req, user.UserName);
|
|
333
312
|
if (trustedDeviceUser && (mbkautheVar.MBKAUTH_TWO_FA_ENABLE || "").toLowerCase() === "true" && user.TwoFAStatus) {
|
package/package.json
CHANGED
package/public/main.css
CHANGED
|
@@ -286,6 +286,14 @@ header {
|
|
|
286
286
|
transition: var(--transition);
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
.icon-button {
|
|
290
|
+
appearance: none;
|
|
291
|
+
border: 0;
|
|
292
|
+
background: transparent;
|
|
293
|
+
padding: 0;
|
|
294
|
+
font: inherit;
|
|
295
|
+
}
|
|
296
|
+
|
|
289
297
|
.input-icon:hover {
|
|
290
298
|
color: var(--accent);
|
|
291
299
|
}
|
|
@@ -304,6 +312,14 @@ header {
|
|
|
304
312
|
box-shadow: var(--box-shadow);
|
|
305
313
|
}
|
|
306
314
|
|
|
315
|
+
.btn-social,
|
|
316
|
+
.swi {
|
|
317
|
+
appearance: none;
|
|
318
|
+
border: 0.13rem solid;
|
|
319
|
+
font: inherit;
|
|
320
|
+
cursor: pointer;
|
|
321
|
+
}
|
|
322
|
+
|
|
307
323
|
.swi {
|
|
308
324
|
display: flex;
|
|
309
325
|
align-items: center;
|
|
@@ -315,7 +331,6 @@ header {
|
|
|
315
331
|
font-size: var(--text-size-sm);
|
|
316
332
|
text-decoration: none;
|
|
317
333
|
transition: var(--transition);
|
|
318
|
-
border: 0.13rem solid;
|
|
319
334
|
position: relative;
|
|
320
335
|
z-index: 1;
|
|
321
336
|
overflow: hidden;
|
|
@@ -423,6 +438,25 @@ header {
|
|
|
423
438
|
text-decoration: none;
|
|
424
439
|
}
|
|
425
440
|
|
|
441
|
+
.btn-message-action {
|
|
442
|
+
appearance: none;
|
|
443
|
+
border: 0;
|
|
444
|
+
border-radius: var(--border-radius);
|
|
445
|
+
background: var(--accent);
|
|
446
|
+
color: var(--dark);
|
|
447
|
+
font: inherit;
|
|
448
|
+
font-weight: 700;
|
|
449
|
+
cursor: pointer;
|
|
450
|
+
padding: 0.7rem 1rem;
|
|
451
|
+
transition: var(--transition);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.btn-message-action:hover,
|
|
455
|
+
.btn-message-action:focus-visible {
|
|
456
|
+
background: var(--surface-1);
|
|
457
|
+
color: var(--accent);
|
|
458
|
+
}
|
|
459
|
+
|
|
426
460
|
.token-container {
|
|
427
461
|
animation: fadeInUp 0.4s ease-out;
|
|
428
462
|
}
|
|
@@ -776,7 +810,6 @@ header {
|
|
|
776
810
|
font-size: 0.9rem;
|
|
777
811
|
text-decoration: none;
|
|
778
812
|
transition: var(--transition);
|
|
779
|
-
border: 0.13rem solid;
|
|
780
813
|
position: relative;
|
|
781
814
|
z-index: 1;
|
|
782
815
|
overflow: hidden;
|
package/views/header.handlebars
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<a class="logo">
|
|
4
4
|
<img src="/icon.svg" alt="Logo" class="logo-image"/>
|
|
5
5
|
<span class="logo-text">BK <span>Tech</span></span>
|
|
6
|
-
<span class="logo-comp">{{#if appName}}{{appName}}{{else}}{{#if app}}{{app}}{{else}}mbkauthe{{/if}}{{/if}}
|
|
6
|
+
<span class="logo-comp">{{#if appName}}{{appName}}{{else}}{{#if app}}{{app}}{{else}}mbkauthe{{/if}}{{/if}}</span>
|
|
7
7
|
</a>
|
|
8
8
|
|
|
9
9
|
{{> profilemenu}}
|