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/README.md
CHANGED
package/index.js
CHANGED
|
@@ -98,5 +98,6 @@ export * from "./lib/routes/auth.js";
|
|
|
98
98
|
export * from "./lib/utils/errors.js";
|
|
99
99
|
export * from "./lib/config/cookies.js";
|
|
100
100
|
export * from "./lib/config/security.js";
|
|
101
|
+
export * from "./lib/db/AuthRepository.js";
|
|
101
102
|
export { mbkautheVar } from "#config.js";
|
|
102
103
|
export default app;
|
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
resolveOAuthProvider(provider) {
|
|
18
|
+
const key = String(provider || "").toLowerCase();
|
|
19
|
+
const config = OAUTH_PROVIDERS[key];
|
|
20
|
+
if (!config) {
|
|
21
|
+
throw new Error(`Unsupported OAuth provider: ${provider}`);
|
|
22
|
+
}
|
|
23
|
+
return config;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async fetchActiveSession(sessionId) {
|
|
27
|
+
const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
|
|
28
|
+
FROM "Sessions" s
|
|
29
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
30
|
+
WHERE s.id = $1 LIMIT 1`;
|
|
31
|
+
const result = await this.executeRaw({ name: "multi-session-fetch", text: query, values: [sessionId] });
|
|
32
|
+
return result.rows?.[0] || null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async deleteAppSessionById(sessionId, queryName = "invalidate-app-session") {
|
|
36
|
+
const query = `DELETE FROM "Sessions" WHERE id = $1`;
|
|
37
|
+
return this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async deleteSessionBySid(sessionId, queryName = "login-delete-old-session-before-regen") {
|
|
41
|
+
const query = `DELETE FROM "session" WHERE sid = $1`;
|
|
42
|
+
return this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async touchTrustedDevice(deviceTokenHash, username) {
|
|
46
|
+
const query = `
|
|
47
|
+
UPDATE "TrustedDevices" td
|
|
48
|
+
SET "LastUsed" = NOW()
|
|
49
|
+
FROM "Users" u
|
|
50
|
+
WHERE td."DeviceToken" = $1
|
|
51
|
+
AND td."UserName" = $2
|
|
52
|
+
AND td."ExpiresAt" > NOW()
|
|
53
|
+
AND u."UserName" = td."UserName"
|
|
54
|
+
AND u."Active" = TRUE
|
|
55
|
+
RETURNING td."UserName", td."ExpiresAt", u."id" as id, u."Active", u."Role", u."AllowedApps"
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
const result = await this.executeRaw({
|
|
59
|
+
name: "check-trusted-device",
|
|
60
|
+
text: query,
|
|
61
|
+
values: [deviceTokenHash, username]
|
|
62
|
+
});
|
|
63
|
+
return result.rows?.[0] || null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async cleanupAndCountUserSessions(username, queryName = "cleanup-and-count-user-sessions") {
|
|
67
|
+
const query = `
|
|
68
|
+
WITH deleted AS (
|
|
69
|
+
DELETE FROM "Sessions"
|
|
70
|
+
WHERE "UserName" = $1
|
|
71
|
+
AND expires_at IS NOT NULL
|
|
72
|
+
AND expires_at <= NOW()
|
|
73
|
+
)
|
|
74
|
+
SELECT COUNT(*)::int AS count
|
|
75
|
+
FROM "Sessions"
|
|
76
|
+
WHERE "UserName" = $1
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
80
|
+
return Number(result.rows?.[0]?.count ?? 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async deleteOldestSessionsForUser(username, limit, queryName = "prune-oldest-user-session") {
|
|
84
|
+
if (!Number.isFinite(limit) || limit <= 0) return 0;
|
|
85
|
+
const query = `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at ASC LIMIT $2)`;
|
|
86
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username, limit] });
|
|
87
|
+
return result.rowCount || 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async deleteExpiredSessionsForUser(username) {
|
|
91
|
+
const query = this.sql`
|
|
92
|
+
DELETE FROM ${this.table("Sessions")}
|
|
93
|
+
WHERE ${this.ident("UserName")} = ${this.value(username)}
|
|
94
|
+
AND ${this.ident("expires_at")} IS NOT NULL
|
|
95
|
+
AND ${this.ident("expires_at")} <= ${this.now()}
|
|
96
|
+
`;
|
|
97
|
+
|
|
98
|
+
const result = await this.execute("cleanup-expired-user-sessions", query);
|
|
99
|
+
return result.rowCount || 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async countActiveSessionsForUser(username) {
|
|
103
|
+
const columns = this.columns([`COUNT(*) AS ${this.quoteIdentifier("count")}`]);
|
|
104
|
+
const query = this.sql`
|
|
105
|
+
SELECT ${columns}
|
|
106
|
+
FROM ${this.table("Sessions")}
|
|
107
|
+
WHERE ${this.ident("UserName")} = ${this.value(username)}
|
|
108
|
+
`;
|
|
109
|
+
const result = await this.execute("count-user-sessions", query);
|
|
110
|
+
return Number(result.rows?.[0]?.count ?? 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async getOldestSessionIds(username, limit) {
|
|
114
|
+
if (!Number.isFinite(limit) || limit <= 0) return [];
|
|
115
|
+
|
|
116
|
+
const query = this.sql`
|
|
117
|
+
SELECT ${this.column("id")}
|
|
118
|
+
FROM ${this.table("Sessions")}
|
|
119
|
+
WHERE ${this.ident("UserName")} = ${this.value(username)}
|
|
120
|
+
ORDER BY ${this.ident("created_at")} ASC
|
|
121
|
+
${this.limit(limit)}
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
const result = await this.execute("oldest-user-sessions", query);
|
|
125
|
+
return (result.rows || []).map((row) => row.id).filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async insertAppSession(username, expiresAt, meta) {
|
|
129
|
+
const query = `INSERT INTO "Sessions" ("UserName", expires_at, meta) VALUES ($1, $2, $3) RETURNING id`;
|
|
130
|
+
const result = await this.executeRaw({
|
|
131
|
+
name: "insert-app-session",
|
|
132
|
+
text: query,
|
|
133
|
+
values: [username, expiresAt, meta]
|
|
134
|
+
});
|
|
135
|
+
return result.rows?.[0] || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async updateLastLoginReturnProfile(userId) {
|
|
139
|
+
const query = `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`;
|
|
140
|
+
const result = await this.executeRaw({
|
|
141
|
+
name: "login-update-last-login-return-profile",
|
|
142
|
+
text: query,
|
|
143
|
+
values: [userId]
|
|
144
|
+
});
|
|
145
|
+
return result.rows?.[0] || null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async getUserProfileByUsername(username, queryName = "login-get-fullname-and-image") {
|
|
149
|
+
const query = `SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1`;
|
|
150
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
151
|
+
return result.rows?.[0] || null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async insertTrustedDevice({ username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt }) {
|
|
155
|
+
const query = `INSERT INTO "TrustedDevices" ("UserName", "DeviceToken", "DeviceName", "UserAgent", "IpAddress", "ExpiresAt")
|
|
156
|
+
VALUES ($1, $2, $3, $4, $5, $6)`;
|
|
157
|
+
return this.executeRaw({
|
|
158
|
+
name: "insert-trusted-device",
|
|
159
|
+
text: query,
|
|
160
|
+
values: [username, deviceTokenHash, deviceName, userAgent, ipAddress, expiresAt]
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async getUserWithTwoFA(username, queryName = "login-get-user") {
|
|
165
|
+
const query = `
|
|
166
|
+
SELECT u.id, u."UserName", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
|
|
167
|
+
tfa."TwoFAStatus"
|
|
168
|
+
FROM "Users" u
|
|
169
|
+
LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
|
|
170
|
+
WHERE u."UserName" = $1
|
|
171
|
+
`;
|
|
172
|
+
|
|
173
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
174
|
+
return result.rows?.[0] || null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async getTwoFASecret(username) {
|
|
178
|
+
const query = `SELECT tfa."TwoFASecret" FROM "TwoFA" tfa WHERE tfa."UserName" = $1`;
|
|
179
|
+
const result = await this.executeRaw({ name: "verify-2fa-secret", text: query, values: [username] });
|
|
180
|
+
return result.rows?.[0] || null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async deleteSessionsByIds(ids, queryName = "delete-sessions-by-ids") {
|
|
184
|
+
if (!Array.isArray(ids) || ids.length === 0) return 0;
|
|
185
|
+
const query = `DELETE FROM "Sessions" WHERE id = ANY($1)`;
|
|
186
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [ids] });
|
|
187
|
+
return result.rowCount || 0;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async getOAuthUserByProviderId(provider, providerId) {
|
|
191
|
+
const { table, idColumn, queryName } = this.resolveOAuthProvider(provider);
|
|
192
|
+
const query = `SELECT ug.*, u."UserName", u."Role", u."Active", u."AllowedApps", u."id" FROM ${table} ug JOIN "Users" u ON ug.user_name = u."UserName" WHERE ug.${idColumn} = $1`;
|
|
193
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [providerId] });
|
|
194
|
+
return result.rows?.[0] || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async getApiTokenByHash(tokenHash, queryName = "validate-api-token") {
|
|
198
|
+
const query = `
|
|
199
|
+
SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
|
|
200
|
+
u.id as uid, u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
|
|
201
|
+
FROM "ApiTokens" t
|
|
202
|
+
JOIN "Users" u ON t."UserName" = u."UserName"
|
|
203
|
+
WHERE t."TokenHash" = $1 LIMIT 1
|
|
204
|
+
`;
|
|
205
|
+
|
|
206
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [tokenHash] });
|
|
207
|
+
return result.rows?.[0] || null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async updateApiTokenLastUsed(tokenId, queryName = null) {
|
|
211
|
+
const query = `UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1`;
|
|
212
|
+
if (queryName) {
|
|
213
|
+
return this.executeRaw({ name: queryName, text: query, values: [tokenId] });
|
|
214
|
+
}
|
|
215
|
+
return this.executeRaw({ text: query, values: [tokenId] });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async getSessionAuthData(sessionId, queryName = "validate-app-session") {
|
|
219
|
+
const query = `
|
|
220
|
+
SELECT s.expires_at, u."Active", u."Role"
|
|
221
|
+
FROM "Sessions" s
|
|
222
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
223
|
+
WHERE s.id = $1
|
|
224
|
+
LIMIT 1
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
228
|
+
return result.rows?.[0] || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async getSessionWithUserById(sessionId, queryName = "restore-user-session") {
|
|
232
|
+
const query = `SELECT u.id, u."UserName", u."Active", u."Role", u."AllowedApps", s.expires_at
|
|
233
|
+
FROM "Sessions" s
|
|
234
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
235
|
+
WHERE s.id = $1 LIMIT 1`;
|
|
236
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
237
|
+
return result.rows?.[0] || null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async getSessionWithUserForReload(sessionId, queryName = "reload-session-user") {
|
|
241
|
+
const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
|
|
242
|
+
FROM "Sessions" s
|
|
243
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
244
|
+
WHERE s.id = $1 LIMIT 1`;
|
|
245
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
246
|
+
return result.rows?.[0] || null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async getSessionValidationRow(sessionId, queryName = "check-session-validity-by-id") {
|
|
250
|
+
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`;
|
|
251
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
|
|
252
|
+
return result.rows?.[0] || null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async getUserFullNameByUsername(username, queryName = "get-fullname-by-username") {
|
|
256
|
+
const query = `SELECT "FullName" FROM "Users" WHERE "UserName" = $1 LIMIT 1`;
|
|
257
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
258
|
+
return result.rows?.[0] || null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async getUserImageByUsername(username, queryName = "get-user-profile-pic") {
|
|
262
|
+
const query = `SELECT "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1`;
|
|
263
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [username] });
|
|
264
|
+
return result.rows?.[0] || null;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async getSessionValidity(sessionId, sessionStoreSid, queryName = "check-session-validity") {
|
|
268
|
+
const query = `
|
|
269
|
+
SELECT
|
|
270
|
+
s.expires_at,
|
|
271
|
+
u."Active",
|
|
272
|
+
CASE
|
|
273
|
+
WHEN s.expires_at IS NULL THEN (SELECT expire FROM "session" WHERE sid = $2)
|
|
274
|
+
ELSE NULL
|
|
275
|
+
END AS connect_expire
|
|
276
|
+
FROM "Sessions" s
|
|
277
|
+
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
278
|
+
WHERE s.id = $1
|
|
279
|
+
LIMIT 1
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId, sessionStoreSid] });
|
|
283
|
+
return result.rows?.[0] || null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async deleteAllAppSessions(queryName = "delete-all-app-sessions") {
|
|
287
|
+
const query = `DELETE FROM "Sessions"`;
|
|
288
|
+
return this.executeRaw({ name: queryName, text: query, values: [] });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async deleteActiveSessionStoreRows(queryName = "delete-active-session-store-rows") {
|
|
292
|
+
const query = `DELETE FROM "session" WHERE expire > NOW()`;
|
|
293
|
+
return this.executeRaw({ name: queryName, text: query, values: [] });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { postgresDialect } from "./dialects/postgres.js";
|
|
2
|
+
|
|
3
|
+
const isPlainObject = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
4
|
+
|
|
5
|
+
export class BaseRepository {
|
|
6
|
+
constructor({ db, dialect = postgresDialect } = {}) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
this.dialect = dialect;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
quoteIdentifier(name) {
|
|
12
|
+
if (name === "*") return "*";
|
|
13
|
+
return String(name)
|
|
14
|
+
.split(".")
|
|
15
|
+
.map((part) => (part === "*" ? "*" : this.dialect.quoteIdentifier(part)))
|
|
16
|
+
.join(".");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ident(name) {
|
|
20
|
+
return { kind: "ident", name };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
value(val) {
|
|
24
|
+
return { kind: "param", value: val };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
raw(text) {
|
|
28
|
+
return { kind: "raw", text: text ?? "" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
list(values) {
|
|
32
|
+
return { kind: "list", values };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
table(name, alias = null) {
|
|
36
|
+
const tableSql = this.quoteIdentifier(name);
|
|
37
|
+
if (alias) {
|
|
38
|
+
return this.raw(`${tableSql} AS ${this.quoteIdentifier(alias)}`);
|
|
39
|
+
}
|
|
40
|
+
return this.raw(tableSql);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
column(name, alias = null) {
|
|
44
|
+
const columnSql = this.quoteIdentifier(name);
|
|
45
|
+
if (alias) {
|
|
46
|
+
return `${columnSql} AS ${this.quoteIdentifier(alias)}`;
|
|
47
|
+
}
|
|
48
|
+
return columnSql;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
columns(list) {
|
|
52
|
+
const items = (list || []).filter(Boolean).map((item) => {
|
|
53
|
+
if (isPlainObject(item) && item.kind) {
|
|
54
|
+
if (item.kind === "ident") return this.quoteIdentifier(item.name);
|
|
55
|
+
if (item.kind === "raw") return item.text;
|
|
56
|
+
}
|
|
57
|
+
return String(item);
|
|
58
|
+
});
|
|
59
|
+
return this.raw(items.join(", "));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
star(alias = null) {
|
|
63
|
+
if (!alias) return this.raw("*");
|
|
64
|
+
return this.raw(`${this.quoteIdentifier(alias)}.*`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
now() {
|
|
68
|
+
return this.raw(this.dialect.now());
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
boolean(value) {
|
|
72
|
+
return this.raw(this.dialect.boolean(value));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
returning(columns) {
|
|
76
|
+
if (!this.dialect.supportsReturning) return this.raw("");
|
|
77
|
+
if (!columns) return this.raw("");
|
|
78
|
+
|
|
79
|
+
let columnSql = "";
|
|
80
|
+
if (isPlainObject(columns) && columns.kind === "raw") {
|
|
81
|
+
columnSql = columns.text;
|
|
82
|
+
} else if (Array.isArray(columns)) {
|
|
83
|
+
columnSql = columns.join(", ");
|
|
84
|
+
} else {
|
|
85
|
+
columnSql = String(columns);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!columnSql) return this.raw("");
|
|
89
|
+
return this.raw(this.dialect.returningClause(columnSql));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
limit(limit, offset) {
|
|
93
|
+
return this.raw(this.dialect.limitOffset({ limit, offset }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
sql(strings, ...exprs) {
|
|
97
|
+
const values = [];
|
|
98
|
+
let text = strings?.[0] ?? "";
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < exprs.length; i += 1) {
|
|
101
|
+
text += this.renderToken(exprs[i], values);
|
|
102
|
+
text += strings?.[i + 1] ?? "";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { text, values };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
renderToken(token, values) {
|
|
109
|
+
if (token == null) return "";
|
|
110
|
+
|
|
111
|
+
if (!isPlainObject(token) || !token.kind) {
|
|
112
|
+
return String(token);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
switch (token.kind) {
|
|
116
|
+
case "raw":
|
|
117
|
+
return token.text;
|
|
118
|
+
case "ident":
|
|
119
|
+
return this.quoteIdentifier(token.name);
|
|
120
|
+
case "param":
|
|
121
|
+
values.push(token.value);
|
|
122
|
+
return this.dialect.param(values.length);
|
|
123
|
+
case "list":
|
|
124
|
+
if (!Array.isArray(token.values) || token.values.length === 0) {
|
|
125
|
+
return "(NULL)";
|
|
126
|
+
}
|
|
127
|
+
return `(${token.values.map((item) => {
|
|
128
|
+
values.push(item);
|
|
129
|
+
return this.dialect.param(values.length);
|
|
130
|
+
}).join(", ")})`;
|
|
131
|
+
default:
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async execute(name, query) {
|
|
137
|
+
const { text, values } = query;
|
|
138
|
+
if (name) {
|
|
139
|
+
return this.db.query({ name, text, values });
|
|
140
|
+
}
|
|
141
|
+
return this.db.query({ text, values });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async executeRaw({ name, text, values }) {
|
|
145
|
+
if (name) {
|
|
146
|
+
return this.db.query({ name, text, values });
|
|
147
|
+
}
|
|
148
|
+
return this.db.query({ text, values });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
cloneWithDb(db) {
|
|
152
|
+
return new this.constructor({ db, dialect: this.dialect });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async withTransaction(fn) {
|
|
156
|
+
if (!this.db || typeof this.db.connect !== "function") {
|
|
157
|
+
return fn(this);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const client = await this.db.connect();
|
|
161
|
+
const txRepo = this.cloneWithDb(client);
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
await client.query("BEGIN");
|
|
165
|
+
const result = await fn(txRepo);
|
|
166
|
+
await client.query("COMMIT");
|
|
167
|
+
return result;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
await client.query("ROLLBACK").catch(() => {});
|
|
170
|
+
throw err;
|
|
171
|
+
} finally {
|
|
172
|
+
client.release();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async lockTable(tableName, mode) {
|
|
177
|
+
if (!this.dialect.lockTable) return null;
|
|
178
|
+
const text = this.dialect.lockTable(this.quoteIdentifier(tableName), mode);
|
|
179
|
+
return this.executeRaw({
|
|
180
|
+
name: `lock-${tableName}`,
|
|
181
|
+
text,
|
|
182
|
+
values: []
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const quoteIdentifier = (name) => `"${String(name).replace(/"/g, '""')}"`;
|
|
2
|
+
|
|
3
|
+
export const postgresDialect = {
|
|
4
|
+
name: "postgres",
|
|
5
|
+
quoteIdentifier,
|
|
6
|
+
param: (index) => `$${index}`,
|
|
7
|
+
now: () => "NOW()",
|
|
8
|
+
boolean: (value) => (value ? "TRUE" : "FALSE"),
|
|
9
|
+
supportsReturning: true,
|
|
10
|
+
returningClause: (columns) => ` RETURNING ${columns}`,
|
|
11
|
+
limitOffset: ({ limit, offset } = {}) => {
|
|
12
|
+
const parts = [];
|
|
13
|
+
if (typeof limit === "number") parts.push(`LIMIT ${limit}`);
|
|
14
|
+
if (typeof offset === "number") parts.push(`OFFSET ${offset}`);
|
|
15
|
+
return parts.length ? ` ${parts.join(" ")}` : "";
|
|
16
|
+
},
|
|
17
|
+
lockTable: (tableSql, mode = "ROW EXCLUSIVE") => `LOCK TABLE ${tableSql} IN ${mode} MODE`
|
|
18
|
+
};
|
package/lib/middleware/auth.js
CHANGED
|
@@ -6,18 +6,12 @@ import { ErrorCodes, createErrorResponse } from "../utils/errors.js";
|
|
|
6
6
|
import { hashApiToken } from "#config.js";
|
|
7
7
|
import { canAccessMethod } from "#config.js";
|
|
8
8
|
import { extractAuthorizationToken, timingSafeTokenMatch } from "../utils/timingSafeToken.js";
|
|
9
|
+
import { AuthRepository } from "../db/AuthRepository.js";
|
|
9
10
|
|
|
10
11
|
const IS_DEV = process.env.env === 'dev' || process.env.test === 'dev' || process.env.NODE_ENV === 'development';
|
|
11
12
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
12
13
|
const isUuid = (val) => typeof val === 'string' && UUID_RE.test(val);
|
|
13
|
-
|
|
14
|
-
const SQL_VALIDATE_APP_SESSION = `
|
|
15
|
-
SELECT s.expires_at, u."Active", u."Role"
|
|
16
|
-
FROM "Sessions" s
|
|
17
|
-
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
18
|
-
WHERE s.id = $1
|
|
19
|
-
LIMIT 1
|
|
20
|
-
`;
|
|
14
|
+
const authRepo = new AuthRepository({ db: dblogin });
|
|
21
15
|
|
|
22
16
|
/**
|
|
23
17
|
* Decide if the incoming request should return JSON errors instead of HTML.
|
|
@@ -64,18 +58,9 @@ async function validateTokenAuthentication(req) {
|
|
|
64
58
|
// 1. Check for API Token (mbk_)
|
|
65
59
|
if (token.startsWith('mbk_')) {
|
|
66
60
|
const tokenHash = hashApiToken(token);
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
FROM "ApiTokens" t
|
|
71
|
-
JOIN "Users" u ON t."UserName" = u."UserName"
|
|
72
|
-
WHERE t."TokenHash" = $1 LIMIT 1
|
|
73
|
-
`;
|
|
74
|
-
const tokenResult = await dblogin.query({ name: 'validate-api-token', text: tokenQuery, values: [tokenHash] });
|
|
75
|
-
|
|
76
|
-
if (tokenResult.rows.length === 0) return { error: 'INVALID_TOKEN' };
|
|
77
|
-
|
|
78
|
-
const row = tokenResult.rows[0];
|
|
61
|
+
const row = await authRepo.getApiTokenByHash(tokenHash);
|
|
62
|
+
|
|
63
|
+
if (!row) return { error: 'INVALID_TOKEN' };
|
|
79
64
|
if (row.ExpiresAt && new Date(row.ExpiresAt) <= new Date()) return { error: 'TOKEN_EXPIRED' };
|
|
80
65
|
|
|
81
66
|
// Parse permissions from JSONB
|
|
@@ -90,10 +75,7 @@ async function validateTokenAuthentication(req) {
|
|
|
90
75
|
}
|
|
91
76
|
|
|
92
77
|
// Update usage
|
|
93
|
-
|
|
94
|
-
text: 'UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1',
|
|
95
|
-
values: [row.id]
|
|
96
|
-
}).catch(e => console.error(`[mbkauthe] Failed to update token usage:`, e));
|
|
78
|
+
authRepo.updateApiTokenLastUsed(row.id).catch(e => console.error(`[mbkauthe] Failed to update token usage:`, e));
|
|
97
79
|
|
|
98
80
|
return {
|
|
99
81
|
id: row.uid,
|
|
@@ -208,13 +190,12 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
208
190
|
if (isJsonRequest(req)) {
|
|
209
191
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
|
|
210
192
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
pagename: "Login",
|
|
216
|
-
page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
|
|
193
|
+
// Use OAuth 2.0 style redirect to the login page for browser navigation
|
|
194
|
+
const redirectParams = new URLSearchParams({
|
|
195
|
+
redirect: req.originalUrl,
|
|
196
|
+
reason: 'logged_out'
|
|
217
197
|
});
|
|
198
|
+
return res.redirect(302, `/mbkauthe/login?${redirectParams.toString()}`);
|
|
218
199
|
}
|
|
219
200
|
|
|
220
201
|
try {
|
|
@@ -238,9 +219,9 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
238
219
|
}
|
|
239
220
|
|
|
240
221
|
// Validate session by DB primary key id and join to user
|
|
241
|
-
const
|
|
222
|
+
const sessionRow = await authRepo.getSessionAuthData(sessionId, 'validate-app-session');
|
|
242
223
|
|
|
243
|
-
if (
|
|
224
|
+
if (!sessionRow) {
|
|
244
225
|
console.log(`[mbkauthe] Session not found for user "${req.session.user.username}"`);
|
|
245
226
|
req.session.destroy();
|
|
246
227
|
clearSessionCookies(res);
|
|
@@ -256,8 +237,6 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
|
|
|
256
237
|
});
|
|
257
238
|
}
|
|
258
239
|
|
|
259
|
-
const sessionRow = result.rows[0];
|
|
260
|
-
|
|
261
240
|
// Check expired
|
|
262
241
|
if (sessionRow.expires_at) {
|
|
263
242
|
const expiresMs = sessionRow.expires_at instanceof Date
|
|
@@ -349,16 +328,14 @@ async function validateApiSession(req, res, next) {
|
|
|
349
328
|
}
|
|
350
329
|
|
|
351
330
|
// Validate session by DB primary key id and join to user
|
|
352
|
-
const
|
|
331
|
+
const sessionRow = await authRepo.getSessionAuthData(sessionId, 'validate-app-session-for-api');
|
|
353
332
|
|
|
354
|
-
if (
|
|
333
|
+
if (!sessionRow) {
|
|
355
334
|
req.session.destroy();
|
|
356
335
|
clearSessionCookies(res);
|
|
357
336
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_INVALID));
|
|
358
337
|
}
|
|
359
338
|
|
|
360
|
-
const sessionRow = result.rows[0];
|
|
361
|
-
|
|
362
339
|
// Check expired
|
|
363
340
|
if (sessionRow.expires_at) {
|
|
364
341
|
const expiresMs = sessionRow.expires_at instanceof Date
|
|
@@ -372,7 +349,7 @@ async function validateApiSession(req, res, next) {
|
|
|
372
349
|
}
|
|
373
350
|
|
|
374
351
|
|
|
375
|
-
if (!
|
|
352
|
+
if (!sessionRow.Active) {
|
|
376
353
|
console.log(`[mbkauthe] Account is inactive for user "${req.session.user.username}"`);
|
|
377
354
|
req.session.destroy();
|
|
378
355
|
clearSessionCookies(res);
|
|
@@ -420,21 +397,15 @@ async function reloadSessionUser(req, res) {
|
|
|
420
397
|
}
|
|
421
398
|
|
|
422
399
|
const normalizedSessionId = String(currentSessionId);
|
|
423
|
-
const
|
|
424
|
-
FROM "Sessions" s
|
|
425
|
-
JOIN "Users" u ON s."UserName" = u."UserName"
|
|
426
|
-
WHERE s.id = $1 LIMIT 1`;
|
|
427
|
-
const result = await dblogin.query({ name: 'reload-session-user', text: query, values: [normalizedSessionId] });
|
|
400
|
+
const row = await authRepo.getSessionWithUserForReload(normalizedSessionId, 'reload-session-user');
|
|
428
401
|
|
|
429
|
-
if (
|
|
402
|
+
if (!row) {
|
|
430
403
|
// Session not found — invalidate session
|
|
431
404
|
req.session.destroy(() => { });
|
|
432
405
|
clearSessionCookies(res);
|
|
433
406
|
return false;
|
|
434
407
|
}
|
|
435
408
|
|
|
436
|
-
const row = result.rows[0];
|
|
437
|
-
|
|
438
409
|
// Check expired
|
|
439
410
|
if (row.expires_at && new Date(row.expires_at) <= new Date()) {
|
|
440
411
|
req.session.destroy(() => { });
|
|
@@ -470,8 +441,8 @@ async function reloadSessionUser(req, res) {
|
|
|
470
441
|
req.session.user.fullname = req.cookies.fullName;
|
|
471
442
|
} else {
|
|
472
443
|
try {
|
|
473
|
-
const prof = await
|
|
474
|
-
if (prof
|
|
444
|
+
const prof = await authRepo.getUserFullNameByUsername(row.UserName, 'reload-get-fullname');
|
|
445
|
+
if (prof && prof.FullName) req.session.user.fullname = prof.FullName;
|
|
475
446
|
} catch (profileErr) {
|
|
476
447
|
console.error(`[mbkauthe] Error fetching fullname during reload:`, profileErr);
|
|
477
448
|
}
|