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/config/cookies.js
CHANGED
|
@@ -14,6 +14,60 @@ const getEncryptionKey = () => {
|
|
|
14
14
|
return crypto.createHash('sha256').update(COOKIE_ENCRYPTION_KEY).digest();
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
const getSigningKey = () => {
|
|
18
|
+
return crypto.createHash('sha256').update(`${COOKIE_ENCRYPTION_KEY}:cookie-signing`).digest();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const encodePayload = (data) => {
|
|
22
|
+
return Buffer.from(JSON.stringify(data), 'utf8').toString('base64url');
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const decodePayload = (encoded) => {
|
|
26
|
+
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const signCookiePayload = (encodedPayload) => {
|
|
30
|
+
return crypto.createHmac('sha256', getSigningKey()).update(encodedPayload).digest('hex');
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const verifyCookieSignature = (encodedPayload, signature) => {
|
|
34
|
+
if (!encodedPayload || !signature || typeof encodedPayload !== 'string' || typeof signature !== 'string') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const expected = signCookiePayload(encodedPayload);
|
|
39
|
+
const expectedBuffer = Buffer.from(expected, 'hex');
|
|
40
|
+
const actualBuffer = Buffer.from(signature, 'hex');
|
|
41
|
+
|
|
42
|
+
return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const createSignedCookiePayload = (data) => {
|
|
46
|
+
try {
|
|
47
|
+
const payload = encodePayload(data);
|
|
48
|
+
return {
|
|
49
|
+
payload,
|
|
50
|
+
signature: signCookiePayload(payload)
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`[mbkauthe] Cookie signing error:`, error);
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const parseSignedCookiePayload = (signedPayload) => {
|
|
59
|
+
try {
|
|
60
|
+
if (!signedPayload || !verifyCookieSignature(signedPayload.payload, signedPayload.signature)) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return decodePayload(signedPayload.payload);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(`[mbkauthe] Cookie signature verification error:`, error);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
17
71
|
// Encrypt and sign cookie payload
|
|
18
72
|
const encryptCookiePayload = (data) => {
|
|
19
73
|
try {
|
|
@@ -160,33 +214,46 @@ export { getCookieOptions, getClearCookieOptions };
|
|
|
160
214
|
const parseAccountList = (raw, req) => {
|
|
161
215
|
if (!raw) return [];
|
|
162
216
|
try {
|
|
163
|
-
// First, decrypt the cookie payload
|
|
164
217
|
const parsed = JSON.parse(raw);
|
|
165
|
-
|
|
218
|
+
let data = parseSignedCookiePayload(parsed);
|
|
219
|
+
let isLegacyEncryptedCookie = false;
|
|
220
|
+
|
|
221
|
+
// Backward compatibility for previously encrypted account-list cookies.
|
|
222
|
+
if (!data && parsed.iv && parsed.authTag && parsed.data) {
|
|
223
|
+
data = decryptCookiePayload(parsed);
|
|
224
|
+
isLegacyEncryptedCookie = true;
|
|
225
|
+
}
|
|
166
226
|
|
|
167
|
-
if (!
|
|
227
|
+
if (!data || !data.accounts || !data.fingerprint) {
|
|
168
228
|
return [];
|
|
169
229
|
}
|
|
170
230
|
|
|
171
231
|
// Verify fingerprint matches current request
|
|
172
232
|
const currentFingerprint = generateFingerprint(req);
|
|
173
|
-
if (
|
|
233
|
+
if (data.fingerprint !== currentFingerprint) {
|
|
174
234
|
console.warn(`[mbkauthe] Cookie fingerprint mismatch - possible cookie theft attempt`);
|
|
175
235
|
return [];
|
|
176
236
|
}
|
|
177
237
|
|
|
178
|
-
const accounts =
|
|
238
|
+
const accounts = data.accounts;
|
|
179
239
|
if (!Array.isArray(accounts)) return [];
|
|
180
240
|
|
|
181
241
|
// Accept only minimal safe fields
|
|
182
242
|
return accounts
|
|
183
243
|
.filter(item => item && typeof item === 'object')
|
|
184
|
-
.map(item =>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
244
|
+
.map(item => {
|
|
245
|
+
const rawSessionId = typeof item.sessionId === 'string' ? item.sessionId : null;
|
|
246
|
+
const sessionId = isLegacyEncryptedCookie
|
|
247
|
+
? rawSessionId
|
|
248
|
+
: decryptSessionId(rawSessionId);
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
sessionId,
|
|
252
|
+
username: typeof item.username === 'string' ? item.username : null,
|
|
253
|
+
fullName: typeof item.fullName === 'string' ? item.fullName : null,
|
|
254
|
+
image: typeof item.image === 'string' ? item.image : null
|
|
255
|
+
};
|
|
256
|
+
})
|
|
190
257
|
.filter(item => item.sessionId && item.username)
|
|
191
258
|
.slice(0, MAX_REMEMBERED_ACCOUNTS);
|
|
192
259
|
} catch (error) {
|
|
@@ -200,7 +267,7 @@ const writeAccountList = (res, list, req) => {
|
|
|
200
267
|
|
|
201
268
|
// Clean and limit fields to safe values (limit image URL length)
|
|
202
269
|
const cleaned = sanitized.map(item => ({
|
|
203
|
-
sessionId: item && item.sessionId ? item.sessionId : null,
|
|
270
|
+
sessionId: item && item.sessionId ? encryptSessionId(item.sessionId) : null,
|
|
204
271
|
username: item && item.username ? item.username : null,
|
|
205
272
|
fullName: item && item.fullName ? item.fullName : null,
|
|
206
273
|
image: (item && typeof item.image === 'string' && item.image.length <= 2048) ? item.image : null
|
|
@@ -212,14 +279,13 @@ const writeAccountList = (res, list, req) => {
|
|
|
212
279
|
fingerprint: generateFingerprint(req)
|
|
213
280
|
};
|
|
214
281
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
console.error(`[mbkauthe] Failed to encrypt account list cookie`);
|
|
282
|
+
const signed = createSignedCookiePayload(payload);
|
|
283
|
+
if (!signed) {
|
|
284
|
+
console.error(`[mbkauthe] Failed to sign account list cookie`);
|
|
219
285
|
return;
|
|
220
286
|
}
|
|
221
287
|
|
|
222
|
-
res.cookie(ACCOUNT_LIST_COOKIE, JSON.stringify(
|
|
288
|
+
res.cookie(ACCOUNT_LIST_COOKIE, JSON.stringify(signed), cachedCookieOptions);
|
|
223
289
|
};
|
|
224
290
|
|
|
225
291
|
export const readAccountListFromCookie = (req) => {
|
|
@@ -243,4 +309,4 @@ export const removeAccountFromCookie = (req, res, sessionId) => {
|
|
|
243
309
|
|
|
244
310
|
export const clearAccountListCookie = (res) => {
|
|
245
311
|
res.clearCookie(ACCOUNT_LIST_COOKIE, cachedClearCookieOptions);
|
|
246
|
-
};
|
|
312
|
+
};
|
package/lib/config/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import dotenv from "dotenv";
|
|
2
2
|
import { createRequire } from "module";
|
|
3
|
+
import { createLogger } from "../utils/logger.js";
|
|
3
4
|
|
|
4
5
|
dotenv.config();
|
|
6
|
+
const logConfig = createLogger("config");
|
|
5
7
|
|
|
6
8
|
// Comprehensive validation function
|
|
7
9
|
function validateConfiguration() {
|
|
@@ -215,7 +217,7 @@ function validateConfiguration() {
|
|
|
215
217
|
configParts.push(`defaults: ${usedDefaults.length} keys`);
|
|
216
218
|
}
|
|
217
219
|
const configSummary = configParts.length > 0 ? ` (${configParts.join(', ')})` : '';
|
|
218
|
-
|
|
220
|
+
logConfig(`Configuration loaded${configSummary}`);
|
|
219
221
|
return mbkautheVar;
|
|
220
222
|
}
|
|
221
223
|
|
package/lib/createTable.js
CHANGED
|
@@ -2,33 +2,120 @@
|
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
import { performance } from "perf_hooks";
|
|
5
6
|
|
|
6
7
|
const __filename = fileURLToPath(import.meta.url);
|
|
7
8
|
const __dirname = path.dirname(__filename);
|
|
8
9
|
|
|
9
10
|
async function main() {
|
|
11
|
+
const startTime = performance.now();
|
|
12
|
+
|
|
13
|
+
console.log("[mbkauthe] Starting schema creation...");
|
|
14
|
+
|
|
10
15
|
const schemaPath = path.resolve(__dirname, "../docs/db.sql");
|
|
16
|
+
|
|
17
|
+
console.log(`[mbkauthe] Schema file: ${schemaPath}`);
|
|
18
|
+
|
|
11
19
|
const schemaSql = await readFile(schemaPath, "utf8");
|
|
12
20
|
|
|
21
|
+
console.log(
|
|
22
|
+
`[mbkauthe] Schema loaded (${Buffer.byteLength(schemaSql, "utf8")} bytes)`
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
const statementCount = schemaSql
|
|
26
|
+
.split(";")
|
|
27
|
+
.map(s => s.trim())
|
|
28
|
+
.filter(Boolean).length;
|
|
29
|
+
|
|
30
|
+
console.log(
|
|
31
|
+
`[mbkauthe] Detected approximately ${statementCount} SQL statements`
|
|
32
|
+
);
|
|
33
|
+
|
|
13
34
|
try {
|
|
35
|
+
console.log("[mbkauthe] Testing database connection...");
|
|
36
|
+
|
|
37
|
+
const ping = await dblogin.query("SELECT version()");
|
|
38
|
+
|
|
39
|
+
console.log(
|
|
40
|
+
`[mbkauthe] Connected to PostgreSQL`
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
console.log(
|
|
44
|
+
`[mbkauthe] PostgreSQL version: ${ping.rows[0].version}`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
console.log("[mbkauthe] Applying schema...");
|
|
48
|
+
|
|
49
|
+
const queryStart = performance.now();
|
|
50
|
+
|
|
14
51
|
const res = await dblogin.query(schemaSql);
|
|
15
|
-
|
|
52
|
+
|
|
53
|
+
const queryDuration = (
|
|
54
|
+
performance.now() - queryStart
|
|
55
|
+
).toFixed(2);
|
|
56
|
+
|
|
57
|
+
console.log(
|
|
58
|
+
`[mbkauthe] Schema applied successfully in ${queryDuration} ms`
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
console.log(
|
|
62
|
+
`[mbkauthe] Command: ${res.command ?? "MULTI"}`
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
console.log(
|
|
66
|
+
`[mbkauthe] Row count: ${res.rowCount ?? 0}`
|
|
67
|
+
);
|
|
68
|
+
|
|
16
69
|
if (res?.rows?.length) {
|
|
17
|
-
console.log(
|
|
18
|
-
|
|
19
|
-
|
|
70
|
+
console.log(
|
|
71
|
+
`[mbkauthe] Returned rows: ${res.rows.length}`
|
|
72
|
+
);
|
|
20
73
|
}
|
|
74
|
+
|
|
21
75
|
} catch (err) {
|
|
22
76
|
const IGNORE_CODES = ["42710", "42P07"];
|
|
23
|
-
|
|
24
|
-
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
err &&
|
|
80
|
+
typeof err.code === "string" &&
|
|
81
|
+
IGNORE_CODES.includes(err.code)
|
|
82
|
+
) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`[mbkauthe] Schema object already exists (ignored)`
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
console.warn(`Code: ${err.code}`);
|
|
88
|
+
console.warn(`Message: ${err.message}`);
|
|
25
89
|
} else {
|
|
26
|
-
console.error(
|
|
90
|
+
console.error(
|
|
91
|
+
`[mbkauthe] Failed to apply schema`
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
console.error("Code:", err.code);
|
|
95
|
+
console.error("Severity:", err.severity);
|
|
96
|
+
console.error("Position:", err.position);
|
|
97
|
+
console.error("Table:", err.table);
|
|
98
|
+
console.error("Column:", err.column);
|
|
99
|
+
console.error("Constraint:", err.constraint);
|
|
100
|
+
console.error("Detail:", err.detail);
|
|
101
|
+
console.error("Hint:", err.hint);
|
|
102
|
+
console.error("Message:", err.message);
|
|
103
|
+
|
|
27
104
|
process.exitCode = 1;
|
|
28
105
|
}
|
|
29
106
|
} finally {
|
|
107
|
+
console.log("[mbkauthe] Closing database connection...");
|
|
108
|
+
|
|
30
109
|
await dblogin.end();
|
|
110
|
+
|
|
111
|
+
const totalDuration = (
|
|
112
|
+
performance.now() - startTime
|
|
113
|
+
).toFixed(2);
|
|
114
|
+
|
|
115
|
+
console.log(
|
|
116
|
+
`[mbkauthe] Finished in ${totalDuration} ms`
|
|
117
|
+
);
|
|
31
118
|
}
|
|
32
119
|
}
|
|
33
120
|
|
|
34
|
-
main();
|
|
121
|
+
main();
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { BaseRepository } from "./BaseRepository.js";
|
|
2
|
+
|
|
3
|
+
const OAUTH_PROVIDERS = {
|
|
4
|
+
github: {
|
|
5
|
+
table: "user_github",
|
|
6
|
+
idColumn: "github_id",
|
|
7
|
+
queryName: "github-login-get-user"
|
|
8
|
+
},
|
|
9
|
+
google: {
|
|
10
|
+
table: "user_google",
|
|
11
|
+
idColumn: "google_id",
|
|
12
|
+
queryName: "google-login-get-user"
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export class AuthRepository extends BaseRepository {
|
|
17
|
+
buildSessionUserSelect({ includeProfile = false, includeTwoFA = false } = {}) {
|
|
18
|
+
const fields = [
|
|
19
|
+
`s.id as sid`,
|
|
20
|
+
`s.expires_at`,
|
|
21
|
+
`u.id as uid`,
|
|
22
|
+
`u."UserName"`,
|
|
23
|
+
`u."Active"`,
|
|
24
|
+
`u."Role"`,
|
|
25
|
+
`u."AllowedApps"`
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
if (includeProfile) {
|
|
29
|
+
fields.push(`u."FullName"`, `u."Image"`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (includeTwoFA) {
|
|
33
|
+
fields.push(`tfa."TwoFAStatus"`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return fields.join(", ");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
resolveOAuthProvider(provider) {
|
|
40
|
+
const key = String(provider || "").toLowerCase();
|
|
41
|
+
const config = OAUTH_PROVIDERS[key];
|
|
42
|
+
if (!config) {
|
|
43
|
+
throw new Error(`Unsupported OAuth provider: ${provider}`);
|
|
44
|
+
}
|
|
45
|
+
return config;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async fetchActiveSession(sessionId) {
|
|
49
|
+
const query = `SELECT ${this.buildSessionUserSelect({ includeProfile: true })}
|
|
50
|
+
FROM "Sessions" s
|
|
51
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
52
|
+
WHERE s.id = $1 LIMIT 1`;
|
|
53
|
+
const result = await this.executeRaw({ name: "multi-session-fetch", text: query, values: [sessionId] });
|
|
54
|
+
return result.rows?.[0] || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async deleteAppSessionById(sessionId, queryName = "invalidate-app-session") {
|
|
58
|
+
const query = `DELETE FROM "Sessions" WHERE id = $1`;
|
|
59
|
+
return this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async deleteSessionBySid(sessionId, queryName = "login-delete-old-session-before-regen") {
|
|
63
|
+
const query = `DELETE FROM "session" WHERE sid = $1`;
|
|
64
|
+
return this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async getSessionsWithUsersByIds(sessionIds, queryName = "multi-session-fetch-many") {
|
|
68
|
+
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return [];
|
|
69
|
+
|
|
70
|
+
const query = `SELECT ${this.buildSessionUserSelect({ includeProfile: true })}
|
|
71
|
+
FROM "Sessions" s
|
|
72
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
73
|
+
WHERE s.id = ANY($1)`;
|
|
74
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionIds] });
|
|
75
|
+
return result.rows || [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async touchTrustedDevice(deviceTokenHash, username) {
|
|
79
|
+
const query = `
|
|
80
|
+
UPDATE "TrustedDevices" td
|
|
81
|
+
SET "LastUsed" = NOW()
|
|
82
|
+
FROM "Users" u
|
|
83
|
+
WHERE td."DeviceToken" = $1
|
|
84
|
+
AND td."UserName" = $2
|
|
85
|
+
AND td."ExpiresAt" > NOW()
|
|
86
|
+
AND u."UserName" = td."UserName"
|
|
87
|
+
AND u."Active" = TRUE
|
|
88
|
+
RETURNING td."UserName", td."ExpiresAt", u."id" as id, u."Active", u."Role", u."AllowedApps"
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
const result = await this.executeRaw({
|
|
92
|
+
name: "check-trusted-device",
|
|
93
|
+
text: query,
|
|
94
|
+
values: [deviceTokenHash, username]
|
|
95
|
+
});
|
|
96
|
+
return result.rows?.[0] || null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async cleanupAndCountUserSessions(username, queryName = "cleanup-and-count-user-sessions") {
|
|
100
|
+
const query = `
|
|
101
|
+
WITH deleted AS (
|
|
102
|
+
DELETE FROM "Sessions"
|
|
103
|
+
WHERE "UserName" = $1
|
|
104
|
+
AND expires_at IS NOT NULL
|
|
105
|
+
AND expires_at <= NOW()
|
|
106
|
+
)
|
|
107
|
+
SELECT COUNT(*)::int AS count
|
|
108
|
+
FROM "Sessions"
|
|
109
|
+
WHERE "UserName" = $1
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
113
|
+
return Number(result.rows?.[0]?.count ?? 0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async deleteOldestSessionsForUser(username, limit, queryName = "prune-oldest-user-session") {
|
|
117
|
+
if (!Number.isFinite(limit) || limit <= 0) return 0;
|
|
118
|
+
const query = `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at ASC LIMIT $2)`;
|
|
119
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username, limit] });
|
|
120
|
+
return result.rowCount || 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async deleteExpiredSessionsForUser(username) {
|
|
124
|
+
const query = this.sql`
|
|
125
|
+
DELETE FROM ${this.table("Sessions")}
|
|
126
|
+
WHERE ${this.ident("UserName")} = ${this.value(username)}
|
|
127
|
+
AND ${this.ident("expires_at")} IS NOT NULL
|
|
128
|
+
AND ${this.ident("expires_at")} <= ${this.now()}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const result = await this.execute("cleanup-expired-user-sessions", query);
|
|
132
|
+
return result.rowCount || 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async countActiveSessionsForUser(username) {
|
|
136
|
+
const columns = this.columns([`COUNT(*) AS ${this.quoteIdentifier("count")}`]);
|
|
137
|
+
const query = this.sql`
|
|
138
|
+
SELECT ${columns}
|
|
139
|
+
FROM ${this.table("Sessions")}
|
|
140
|
+
WHERE ${this.ident("UserName")} = ${this.value(username)}
|
|
141
|
+
`;
|
|
142
|
+
const result = await this.execute("count-user-sessions", query);
|
|
143
|
+
return Number(result.rows?.[0]?.count ?? 0);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getOldestSessionIds(username, limit) {
|
|
147
|
+
if (!Number.isFinite(limit) || limit <= 0) return [];
|
|
148
|
+
|
|
149
|
+
const query = this.sql`
|
|
150
|
+
SELECT ${this.column("id")}
|
|
151
|
+
FROM ${this.table("Sessions")}
|
|
152
|
+
WHERE ${this.ident("UserName")} = ${this.value(username)}
|
|
153
|
+
ORDER BY ${this.ident("created_at")} ASC
|
|
154
|
+
${this.limit(limit)}
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
const result = await this.execute("oldest-user-sessions", query);
|
|
158
|
+
return (result.rows || []).map((row) => row.id).filter(Boolean);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async insertAppSession(username, expiresAt, meta) {
|
|
162
|
+
const query = `INSERT INTO "Sessions" ("UserName", expires_at, meta) VALUES ($1, $2, $3) RETURNING id`;
|
|
163
|
+
const result = await this.executeRaw({
|
|
164
|
+
name: "insert-app-session",
|
|
165
|
+
text: query,
|
|
166
|
+
values: [username, expiresAt, meta]
|
|
167
|
+
});
|
|
168
|
+
return result.rows?.[0] || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async updateLastLoginReturnProfile(userId) {
|
|
172
|
+
const query = `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`;
|
|
173
|
+
const result = await this.executeRaw({
|
|
174
|
+
name: "login-update-last-login-return-profile",
|
|
175
|
+
text: query,
|
|
176
|
+
values: [userId]
|
|
177
|
+
});
|
|
178
|
+
return result.rows?.[0] || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async getUserProfileByUsername(username, queryName = "login-get-fullname-and-image") {
|
|
182
|
+
const query = `SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1`;
|
|
183
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
184
|
+
return result.rows?.[0] || null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async insertTrustedDevice({ username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt }) {
|
|
188
|
+
const query = `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
|
|
189
|
+
VALUES ($1, $2, $3, $4, $5, $6)`;
|
|
190
|
+
return this.executeRaw({
|
|
191
|
+
name: "insert-trusted-device",
|
|
192
|
+
text: query,
|
|
193
|
+
values: [username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt]
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async getUserWithTwoFA(username, queryName = "login-get-user") {
|
|
198
|
+
const query = `
|
|
199
|
+
SELECT u.id, u."UserName", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
|
|
200
|
+
tfa."TwoFAStatus", u."FullName", u."Image"
|
|
201
|
+
FROM "Users" u
|
|
202
|
+
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
203
|
+
WHERE u."UserName" = $1
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
207
|
+
return result.rows?.[0] || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async getTwoFASecret(username) {
|
|
211
|
+
const query = `SELECT tfa."TwoFASecret" FROM "TwoFA" tfa WHERE tfa."UserName" = $1`;
|
|
212
|
+
const result = await this.executeRaw({ name: "verify-2fa-secret", text: query, values: [username] });
|
|
213
|
+
return result.rows?.[0] || null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async deleteSessionsByIds(ids, queryName = "delete-sessions-by-ids") {
|
|
217
|
+
if (!Array.isArray(ids) || ids.length === 0) return 0;
|
|
218
|
+
const query = `DELETE FROM "Sessions" WHERE id = ANY($1)`;
|
|
219
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [ids] });
|
|
220
|
+
return result.rowCount || 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async getOAuthUserByProviderId(provider, providerId) {
|
|
224
|
+
const { table, idColumn, queryName } = this.resolveOAuthProvider(provider);
|
|
225
|
+
const query = `SELECT ug.*, u."UserName", u."Role", u."Active", u."AllowedApps", u."id", tfa."TwoFAStatus"
|
|
226
|
+
FROM ${table} ug
|
|
227
|
+
JOIN "Users" u ON ug.user_name = u."UserName"
|
|
228
|
+
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
229
|
+
WHERE ug.${idColumn} = $1`;
|
|
230
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [providerId] });
|
|
231
|
+
return result.rows?.[0] || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async getApiTokenByHash(tokenHash, queryName = "validate-api-token") {
|
|
235
|
+
const query = `
|
|
236
|
+
SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
|
|
237
|
+
u.id as uid, u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
|
|
238
|
+
FROM "ApiTokens" t
|
|
239
|
+
JOIN "Users" u ON t."UserName" = u."UserName"
|
|
240
|
+
WHERE t."TokenHash" = $1 LIMIT 1
|
|
241
|
+
`;
|
|
242
|
+
|
|
243
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [tokenHash] });
|
|
244
|
+
return result.rows?.[0] || null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async updateApiTokenLastUsed(tokenId, queryName = null, minIntervalMinutes = 15) {
|
|
248
|
+
const query = `
|
|
249
|
+
UPDATE "ApiTokens"
|
|
250
|
+
SET "LastUsed" = NOW()
|
|
251
|
+
WHERE id = $1
|
|
252
|
+
AND (
|
|
253
|
+
"LastUsed" IS NULL
|
|
254
|
+
OR "LastUsed" < NOW() - ($2::int * INTERVAL '1 minute')
|
|
255
|
+
)
|
|
256
|
+
`;
|
|
257
|
+
const values = [tokenId, minIntervalMinutes];
|
|
258
|
+
if (queryName) {
|
|
259
|
+
return this.executeRaw({ name: queryName, text: query, values });
|
|
260
|
+
}
|
|
261
|
+
return this.executeRaw({ text: query, values });
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async getSessionAuthData(sessionId, queryName = "validate-app-session") {
|
|
265
|
+
const query = `
|
|
266
|
+
SELECT s.expires_at, u."Active", u."Role", u."AllowedApps", u."UserName"
|
|
267
|
+
FROM "Sessions" s
|
|
268
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
269
|
+
WHERE s.id = $1
|
|
270
|
+
LIMIT 1
|
|
271
|
+
`;
|
|
272
|
+
|
|
273
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
274
|
+
return result.rows?.[0] || null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async getSessionWithUserById(sessionId, queryName = "restore-user-session") {
|
|
278
|
+
const query = `SELECT ${this.buildSessionUserSelect({ includeProfile: true })}
|
|
279
|
+
FROM "Sessions" s
|
|
280
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
281
|
+
WHERE s.id = $1 LIMIT 1`;
|
|
282
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
283
|
+
return result.rows?.[0] || null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async getSessionWithUserForReload(sessionId, queryName = "reload-session-user") {
|
|
287
|
+
return this.getSessionWithUserById(sessionId, queryName);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async getSessionValidationRow(sessionId, queryName = "check-session-validity-by-id") {
|
|
291
|
+
const query = `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`;
|
|
292
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
293
|
+
return result.rows?.[0] || null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async getUserFullNameByUsername(username, queryName = "get-fullname-by-username") {
|
|
297
|
+
const query = `SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1`;
|
|
298
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
299
|
+
return result.rows?.[0] || null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async getUserImageByUsername(username, queryName = "get-user-profile-pic") {
|
|
303
|
+
const query = `SELECT "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1`;
|
|
304
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
305
|
+
return result.rows?.[0] || null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async getSessionValidity(sessionId, sessionStoreSid, queryName = "check-session-validity") {
|
|
309
|
+
const query = `
|
|
310
|
+
SELECT
|
|
311
|
+
s.expires_at,
|
|
312
|
+
u."Active",
|
|
313
|
+
CASE
|
|
314
|
+
WHEN s.expires_at IS NULL THEN (SELECT expire FROM "session" WHERE sid = $2)
|
|
315
|
+
ELSE NULL
|
|
316
|
+
END AS connect_expire
|
|
317
|
+
FROM "Sessions" s
|
|
318
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
319
|
+
WHERE s.id = $1
|
|
320
|
+
LIMIT 1
|
|
321
|
+
`;
|
|
322
|
+
|
|
323
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId, sessionStoreSid] });
|
|
324
|
+
return result.rows?.[0] || null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async deleteAllAppSessions(queryName = "delete-all-app-sessions") {
|
|
328
|
+
const query = `DELETE FROM "Sessions"`;
|
|
329
|
+
return this.executeRaw({ name: queryName, text: query, values: [] });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async deleteActiveSessionStoreRows(queryName = "delete-active-session-store-rows") {
|
|
333
|
+
const query = `DELETE FROM "session" WHERE expire > NOW()`;
|
|
334
|
+
return this.executeRaw({ name: queryName, text: query, values: [] });
|
|
335
|
+
}
|
|
336
|
+
}
|