mbkauthe 5.1.0 → 5.2.1
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 +15 -6
- package/docs/README.md +2 -1
- package/docs/guides/configuration.md +22 -1
- package/docs/guides/database.md +32 -1
- package/docs/reference/api/authentication.md +1 -1
- package/docs/schema/db.sqlite.sql +155 -0
- package/index.d.ts +26 -2
- package/index.js +1 -1
- package/lib/config/index.js +25 -6
- package/lib/createTable.js +26 -2
- package/lib/db/AuthRepository.js +59 -1
- package/lib/db/BaseRepository.js +13 -0
- package/lib/db/dialects/sqlite.js +25 -0
- package/lib/db/sqlSqliteTranslate.js +62 -0
- package/lib/db/sqlitePool.js +210 -0
- package/lib/middleware/auth.js +3 -3
- package/lib/middleware/index.js +16 -6
- package/lib/pool.js +27 -12
- package/lib/routes/auth.js +3 -2
- package/lib/routes/misc.js +2 -2
- package/lib/routes/oauth.js +2 -2
- package/lib/session/SqliteSessionStore.js +127 -0
- package/lib/tests/integration.spec.js +888 -0
- package/lib/tests/sqlite.spec.js +592 -0
- package/{test.spec.js → lib/tests/test.spec.js} +10 -10
- package/lib/utils/response.js +1 -0
- package/package.json +2 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort translator for the small, consistent subset of Postgres SQL
|
|
3
|
+
* used throughout mbkauthe's raw queries (see AuthRepository.js). This is
|
|
4
|
+
* NOT a general-purpose SQL translator — it only handles the specific
|
|
5
|
+
* constructs this codebase actually emits:
|
|
6
|
+
*
|
|
7
|
+
* - `$1`, `$2`, ... positional params -> `?`
|
|
8
|
+
* - `col = ANY($1)` array matching -> `col IN (?, ?, ...)`
|
|
9
|
+
* - `NOW()` -> `CURRENT_TIMESTAMP`
|
|
10
|
+
* - bare `TRUE` / `FALSE` literals -> `1` / `0`
|
|
11
|
+
* - `::type` casts -> stripped
|
|
12
|
+
*
|
|
13
|
+
* Queries with more exotic Postgres-only syntax (INTERVAL arithmetic,
|
|
14
|
+
* pg_advisory_xact_lock, LOCK TABLE, etc.) are handled by dialect-specific
|
|
15
|
+
* branches in the repositories/pool instead of trying to regex them here.
|
|
16
|
+
*/
|
|
17
|
+
export function translatePgToSqlite(text, values = []) {
|
|
18
|
+
let sql = String(text ?? "")
|
|
19
|
+
.replace(/::\w+(\[\])?/g, "")
|
|
20
|
+
.replace(/\bNOW\(\)/gi, "CURRENT_TIMESTAMP")
|
|
21
|
+
.replace(/\bTRUE\b/g, "1")
|
|
22
|
+
.replace(/\bFALSE\b/g, "0");
|
|
23
|
+
|
|
24
|
+
// Queries already written in native SQLite style with `?` placeholders
|
|
25
|
+
// (e.g. SqliteSessionStore) have no $N tokens to rewrite — pass their
|
|
26
|
+
// values through untouched instead of rebuilding an empty array.
|
|
27
|
+
if (!/\$\d/.test(sql)) {
|
|
28
|
+
return { text: sql, values };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const tokenRegex = /(=\s*ANY\(\$(\d+)\))|(\$(\d+))/g;
|
|
32
|
+
let out = "";
|
|
33
|
+
let lastIndex = 0;
|
|
34
|
+
let match;
|
|
35
|
+
const newValues = [];
|
|
36
|
+
|
|
37
|
+
while ((match = tokenRegex.exec(sql)) !== null) {
|
|
38
|
+
out += sql.slice(lastIndex, match.index);
|
|
39
|
+
|
|
40
|
+
if (match[1]) {
|
|
41
|
+
// `col = ANY($N)` -> `col IN (?, ?, ...)`
|
|
42
|
+
const paramIndex = Number(match[2]) - 1;
|
|
43
|
+
const rawValue = values[paramIndex];
|
|
44
|
+
const arr = Array.isArray(rawValue) ? rawValue : [rawValue];
|
|
45
|
+
if (arr.length === 0) {
|
|
46
|
+
out += "IN (NULL)"; // never matches, mirrors ANY('{}') semantics
|
|
47
|
+
} else {
|
|
48
|
+
out += `IN (${arr.map(() => "?").join(", ")})`;
|
|
49
|
+
newValues.push(...arr);
|
|
50
|
+
}
|
|
51
|
+
} else if (match[3]) {
|
|
52
|
+
const paramIndex = Number(match[4]) - 1;
|
|
53
|
+
out += "?";
|
|
54
|
+
newValues.push(values[paramIndex]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
lastIndex = tokenRegex.lastIndex;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
out += sql.slice(lastIndex);
|
|
61
|
+
return { text: out, values: newValues };
|
|
62
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { translatePgToSqlite } from "./sqlSqliteTranslate.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* FIFO promise mutex. All statements run on ONE shared better-sqlite3
|
|
6
|
+
* handle, and BEGIN/COMMIT are connection state — so a transaction must
|
|
7
|
+
* hold exclusive access for its whole (async) lifetime, or queries from
|
|
8
|
+
* other requests interleave between its awaits and silently join it
|
|
9
|
+
* (or a second BEGIN throws "cannot start a transaction within a
|
|
10
|
+
* transaction"). Individual statements are synchronous, so waiters queue
|
|
11
|
+
* for microseconds, not I/O time.
|
|
12
|
+
*/
|
|
13
|
+
class Mutex {
|
|
14
|
+
constructor() {
|
|
15
|
+
this._tail = Promise.resolve();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolves to a release function once the lock is held. */
|
|
19
|
+
acquire() {
|
|
20
|
+
const prev = this._tail;
|
|
21
|
+
let release;
|
|
22
|
+
this._tail = new Promise((resolve) => {
|
|
23
|
+
let released = false;
|
|
24
|
+
release = () => {
|
|
25
|
+
if (released) return;
|
|
26
|
+
released = true;
|
|
27
|
+
resolve();
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
return prev.then(() => release);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Exclusive handle returned by SqlitePool.connect(). Holds the pool mutex
|
|
36
|
+
* from connect() until release(), so a BEGIN..COMMIT sequence issued on it
|
|
37
|
+
* cannot interleave with queries from other requests. release() is
|
|
38
|
+
* idempotent. Queries on the client itself do NOT re-acquire the mutex —
|
|
39
|
+
* the client is the holder. Do not call pool.query() from code running
|
|
40
|
+
* inside a transaction (use the client / txRepo): it would wait for the
|
|
41
|
+
* lock the transaction is holding and deadlock.
|
|
42
|
+
*/
|
|
43
|
+
class SqliteClient {
|
|
44
|
+
constructor(db, releaseLock) {
|
|
45
|
+
this.db = db;
|
|
46
|
+
this._releaseLock = releaseLock;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async query(queryOrText, maybeValues) {
|
|
50
|
+
const { text, values, name } = normalizeQueryArgs(queryOrText, maybeValues);
|
|
51
|
+
return runQuery(this.db, text, values, name);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
release() {
|
|
55
|
+
this._releaseLock();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeQueryArgs(queryOrText, maybeValues) {
|
|
60
|
+
if (typeof queryOrText === "string") {
|
|
61
|
+
return { text: queryOrText, values: maybeValues || [], name: undefined };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
text: queryOrText?.text ?? "",
|
|
65
|
+
values: queryOrText?.values ?? [],
|
|
66
|
+
name: queryOrText?.name
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* better-sqlite3 only binds numbers, strings, bigints, buffers, and null.
|
|
72
|
+
* Coerce the extra types the pg driver would serialize for us: Date ->
|
|
73
|
+
* SQLite's CURRENT_TIMESTAMP text format ('YYYY-MM-DD HH:MM:SS', UTC) so
|
|
74
|
+
* comparisons against CURRENT_TIMESTAMP stay correct, boolean -> 1/0,
|
|
75
|
+
* undefined -> null.
|
|
76
|
+
*/
|
|
77
|
+
function coerceBindValue(value) {
|
|
78
|
+
if (value === undefined) return null;
|
|
79
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
80
|
+
if (value instanceof Date) {
|
|
81
|
+
return value.toISOString().slice(0, 19).replace("T", " ");
|
|
82
|
+
}
|
|
83
|
+
// pg serializes objects/arrays bound to json/jsonb params; mirror that.
|
|
84
|
+
if (typeof value === "object" && value !== null && !Buffer.isBuffer(value)) {
|
|
85
|
+
return JSON.stringify(value);
|
|
86
|
+
}
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read-side row normalization so results match what the pg driver returns.
|
|
92
|
+
*
|
|
93
|
+
* Postgres jsonb columns arrive as parsed objects/arrays and timestamp
|
|
94
|
+
* columns as Date objects; better-sqlite3 returns raw TEXT for both. All
|
|
95
|
+
* consumers (login app-authorization checks, API-token permissions, expiry
|
|
96
|
+
* comparisons) are written against the pg shapes, so convert by column name
|
|
97
|
+
* — the schema is fixed and known. Timestamp text is UTC (it comes from
|
|
98
|
+
* CURRENT_TIMESTAMP or coerceBindValue), but `new Date('YYYY-MM-DD
|
|
99
|
+
* HH:MM:SS')` would parse it as LOCAL time, skewing every expiry check by
|
|
100
|
+
* the machine's UTC offset — hence the explicit 'Z'.
|
|
101
|
+
*
|
|
102
|
+
* "sess" and "expire" (express-session store) are deliberately NOT listed:
|
|
103
|
+
* SqliteSessionStore owns those columns and does its own JSON/UTC handling.
|
|
104
|
+
*/
|
|
105
|
+
const JSON_COLUMNS = new Set([
|
|
106
|
+
"AllowedApps",
|
|
107
|
+
"user_allowed_apps", // alias of Users.AllowedApps in getApiTokenByHash
|
|
108
|
+
"Permissions",
|
|
109
|
+
"Positions",
|
|
110
|
+
"SocialAccounts",
|
|
111
|
+
"meta"
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const TIMESTAMP_COLUMNS = new Set([
|
|
115
|
+
"expires_at",
|
|
116
|
+
"created_at",
|
|
117
|
+
"updated_at",
|
|
118
|
+
"last_login",
|
|
119
|
+
"last_activity",
|
|
120
|
+
"connect_expire", // alias of session.expire in getSessionValidity
|
|
121
|
+
"CreatedAt",
|
|
122
|
+
"ExpiresAt",
|
|
123
|
+
"LastUsed"
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const SQLITE_TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2}(?:\.\d+)?)(Z)?$/;
|
|
127
|
+
|
|
128
|
+
function normalizeRow(row) {
|
|
129
|
+
for (const key of Object.keys(row)) {
|
|
130
|
+
const value = row[key];
|
|
131
|
+
if (typeof value !== "string") continue;
|
|
132
|
+
|
|
133
|
+
if (JSON_COLUMNS.has(key)) {
|
|
134
|
+
try {
|
|
135
|
+
row[key] = JSON.parse(value);
|
|
136
|
+
} catch {
|
|
137
|
+
// Not valid JSON — leave the raw string rather than lose data.
|
|
138
|
+
}
|
|
139
|
+
} else if (TIMESTAMP_COLUMNS.has(key)) {
|
|
140
|
+
const m = SQLITE_TIMESTAMP_RE.exec(value);
|
|
141
|
+
if (m) {
|
|
142
|
+
row[key] = new Date(`${m[1]}T${m[2]}Z`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return row;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runQuery(db, rawText, rawValues) {
|
|
150
|
+
const trimmed = rawText.trim();
|
|
151
|
+
const upper = trimmed.toUpperCase();
|
|
152
|
+
|
|
153
|
+
// Transaction control statements pass straight through.
|
|
154
|
+
if (upper === "BEGIN" || upper === "COMMIT" || upper === "ROLLBACK") {
|
|
155
|
+
db.exec(upper);
|
|
156
|
+
return { rows: [], rowCount: 0, command: upper };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { text, values } = translatePgToSqlite(trimmed, rawValues);
|
|
160
|
+
const stmt = db.prepare(text);
|
|
161
|
+
const bindValues = values.map(coerceBindValue);
|
|
162
|
+
|
|
163
|
+
if (stmt.reader) {
|
|
164
|
+
const rows = stmt.all(...bindValues).map(normalizeRow);
|
|
165
|
+
return { rows, rowCount: rows.length, command: "SELECT" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const info = stmt.run(...bindValues);
|
|
169
|
+
return {
|
|
170
|
+
rows: [],
|
|
171
|
+
rowCount: info.changes,
|
|
172
|
+
command: "EXECUTE",
|
|
173
|
+
lastInsertRowid: info.lastInsertRowid
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class SqlitePool {
|
|
178
|
+
constructor(filePath) {
|
|
179
|
+
this.db = new Database(filePath);
|
|
180
|
+
this.db.pragma("journal_mode = WAL");
|
|
181
|
+
this.db.pragma("foreign_keys = ON");
|
|
182
|
+
this.mutex = new Mutex();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async query(queryOrText, maybeValues) {
|
|
186
|
+
const { text, values } = normalizeQueryArgs(queryOrText, maybeValues);
|
|
187
|
+
// Take the mutex per statement so a plain query can never land inside
|
|
188
|
+
// another request's open transaction.
|
|
189
|
+
const release = await this.mutex.acquire();
|
|
190
|
+
try {
|
|
191
|
+
return runQuery(this.db, text, values);
|
|
192
|
+
} finally {
|
|
193
|
+
release();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async connect() {
|
|
198
|
+
const release = await this.mutex.acquire();
|
|
199
|
+
return new SqliteClient(this.db, release);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Run a raw multi-statement SQL script (schema files, migrations). */
|
|
203
|
+
execScript(sql) {
|
|
204
|
+
this.db.exec(sql);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async end() {
|
|
208
|
+
this.db.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
package/lib/middleware/auth.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { dblogin } from "#pool.js";
|
|
1
|
+
import { dblogin, dialect } from "#pool.js";
|
|
2
2
|
import { mbkautheVar } from "#config.js";
|
|
3
3
|
import { renderError } from "#response.js";
|
|
4
4
|
import { clearSessionCookies, cachedCookieOptions, encryptSessionId } from "#cookies.js";
|
|
@@ -16,7 +16,7 @@ const MAX_API_TOKEN_LENGTH = 4096;
|
|
|
16
16
|
const API_TOKEN_LAST_USED_INTERVAL_MS = 15 * 60 * 1000;
|
|
17
17
|
const API_TOKEN_SESSION_RESTORE = Symbol('mbkauthe.apiTokenSessionRestore');
|
|
18
18
|
const apiTokenLastUsedCache = new Map();
|
|
19
|
-
const authRepo = new AuthRepository({ db: dblogin });
|
|
19
|
+
const authRepo = new AuthRepository({ db: dblogin, dialect });
|
|
20
20
|
const logAuth = createLogger("auth");
|
|
21
21
|
|
|
22
22
|
function pruneApiTokenLastUsedCache(now) {
|
|
@@ -482,7 +482,7 @@ const checkRolePermission = (requiredRoles, notAllowed) => {
|
|
|
482
482
|
try {
|
|
483
483
|
const authUser = req.auth?.user || req.session?.user;
|
|
484
484
|
|
|
485
|
-
if (!authUser || !authUser.
|
|
485
|
+
if (!authUser || !authUser.username) {
|
|
486
486
|
logAuth(`User not authenticated`);
|
|
487
487
|
if (isJsonRequest(req)) {
|
|
488
488
|
return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
|
package/lib/middleware/index.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import session from "express-session";
|
|
2
2
|
import pgSession from "connect-pg-simple";
|
|
3
3
|
const PgSession = pgSession(session);
|
|
4
|
-
import { dblogin, runWithRequestContext } from "#pool.js";
|
|
4
|
+
import { dblogin, dialect, dbType, runWithRequestContext } from "#pool.js";
|
|
5
5
|
import { mbkautheVar } from "#config.js";
|
|
6
6
|
import { cachedCookieOptions, decryptSessionId, encryptSessionId, cachedClearCookieOptions, getCookieDomain, getCookieSecure, isAllowedOriginHostname } from "#cookies.js";
|
|
7
7
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
8
8
|
import { isUserAuthorizedForApp } from "../utils/appAccess.js";
|
|
9
|
+
import { SqliteSessionStore } from "../session/SqliteSessionStore.js";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
const sessionStore = dbType === "sqlite"
|
|
12
|
+
? new SqliteSessionStore({
|
|
13
|
+
db: dblogin,
|
|
14
|
+
tableName: "session",
|
|
15
|
+
createTableIfMissing: true,
|
|
16
|
+
disableTouch: true
|
|
17
|
+
})
|
|
18
|
+
: new PgSession({
|
|
13
19
|
pool: dblogin,
|
|
14
20
|
tableName: "session",
|
|
15
21
|
createTableIfMissing: true,
|
|
@@ -17,7 +23,11 @@ export const sessionConfig = {
|
|
|
17
23
|
// This avoids an UPDATE per request, which can significantly reduce DB load under burst traffic.
|
|
18
24
|
// The session will still expire based on the cookie maxAge and the TTL stored when the session is saved.
|
|
19
25
|
disableTouch: true
|
|
20
|
-
})
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Session configuration
|
|
29
|
+
export const sessionConfig = {
|
|
30
|
+
store: sessionStore,
|
|
21
31
|
secret: mbkautheVar.SESSION_SECRET_KEY,
|
|
22
32
|
resave: false,
|
|
23
33
|
saveUninitialized: false,
|
|
@@ -35,7 +45,7 @@ export const sessionConfig = {
|
|
|
35
45
|
name: 'mbkauthe.sid'
|
|
36
46
|
};
|
|
37
47
|
|
|
38
|
-
const authRepo = new AuthRepository({ db: dblogin });
|
|
48
|
+
const authRepo = new AuthRepository({ db: dblogin, dialect });
|
|
39
49
|
const hasAuthorizationHeader = (req) => typeof req.headers?.authorization === 'string' && req.headers.authorization.trim().length > 0;
|
|
40
50
|
|
|
41
51
|
export function securityHeadersMiddleware(req, res, next) {
|
package/lib/pool.js
CHANGED
|
@@ -2,24 +2,39 @@ import pkg from "pg";
|
|
|
2
2
|
const { Pool } = pkg;
|
|
3
3
|
import { mbkautheVar } from "#config.js";
|
|
4
4
|
import { attachDevQueryLogger, runWithRequestContext, getRequestContext } from "./utils/dbQueryLogger.js";
|
|
5
|
+
import { postgresDialect } from "./db/dialects/postgres.js";
|
|
6
|
+
import { sqliteDialect } from "./db/dialects/sqlite.js";
|
|
7
|
+
import { SqlitePool } from "./db/sqlitePool.js";
|
|
5
8
|
import dotenv from "dotenv";
|
|
6
9
|
dotenv.config();
|
|
7
10
|
|
|
8
11
|
export { runWithRequestContext, getRequestContext };
|
|
9
12
|
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
ssl: { rejectUnauthorized: true },
|
|
13
|
-
max: 10,
|
|
14
|
-
idleTimeoutMillis: 30000,
|
|
15
|
-
connectionTimeoutMillis: 5000,
|
|
16
|
-
application_name: `${mbkautheVar.APP_NAME}-mbkauthe-app`,
|
|
17
|
-
};
|
|
13
|
+
export const dbType = (mbkautheVar.DB_TYPE || "postgres").toLowerCase();
|
|
14
|
+
export const dialect = dbType === "sqlite" ? sqliteDialect : postgresDialect;
|
|
18
15
|
|
|
19
|
-
|
|
16
|
+
let dblogin;
|
|
20
17
|
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
if (dbType === "sqlite") {
|
|
19
|
+
dblogin = new SqlitePool(mbkautheVar.SQLITE_PATH);
|
|
20
|
+
} else {
|
|
21
|
+
const poolConfig = {
|
|
22
|
+
connectionString: mbkautheVar.LOGIN_DB,
|
|
23
|
+
ssl: { rejectUnauthorized: true },
|
|
24
|
+
max: 10,
|
|
25
|
+
idleTimeoutMillis: 30000,
|
|
26
|
+
connectionTimeoutMillis: 5000,
|
|
27
|
+
application_name: `${mbkautheVar.APP_NAME}-mbkauthe-app`,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
dblogin = new Pool(poolConfig);
|
|
31
|
+
|
|
32
|
+
// Keep pool.js focused on pool setup; attach dev-only query logger from dedicated module.
|
|
33
|
+
// Only meaningful for the pg driver's event-emitter based pool.
|
|
34
|
+
attachDevQueryLogger(dblogin);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { dblogin };
|
|
23
38
|
|
|
24
39
|
/*
|
|
25
40
|
attachDevQueryLogger([
|
|
@@ -38,4 +53,4 @@ attachDevQueryLogger(dblogin);
|
|
|
38
53
|
console.error(`[mbkauthe] Database connection error (pool):`, err);
|
|
39
54
|
}
|
|
40
55
|
})();
|
|
41
|
-
*/
|
|
56
|
+
*/
|
package/lib/routes/auth.js
CHANGED
|
@@ -2,7 +2,7 @@ import express from "express";
|
|
|
2
2
|
import csurf from "csurf";
|
|
3
3
|
import speakeasy from "speakeasy";
|
|
4
4
|
import rateLimit from 'express-rate-limit';
|
|
5
|
-
import { dblogin } from "#pool.js";
|
|
5
|
+
import { dblogin, dialect } from "#pool.js";
|
|
6
6
|
import { mbkautheVar } from "#config.js";
|
|
7
7
|
import {
|
|
8
8
|
cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
|
|
@@ -17,7 +17,7 @@ import { AuthRepository } from "../db/AuthRepository.js";
|
|
|
17
17
|
import { createLogger } from "../utils/logger.js";
|
|
18
18
|
|
|
19
19
|
const router = express.Router();
|
|
20
|
-
const authRepo = new AuthRepository({ db: dblogin });
|
|
20
|
+
const authRepo = new AuthRepository({ db: dblogin, dialect });
|
|
21
21
|
const logAuth = createLogger("auth");
|
|
22
22
|
|
|
23
23
|
// Helper function to clear profile picture cache
|
|
@@ -376,6 +376,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
|
|
|
376
376
|
passwordMatches = await verifyPassword(password, user.UserName, user.PasswordEnc);
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
+
// To Bypass Password check for testing, comment out this condition
|
|
379
380
|
if (!passwordMatches) {
|
|
380
381
|
logError('Login attempt', ErrorCodes.INCORRECT_PASSWORD, { username: trimmedUsername });
|
|
381
382
|
return res.status(401).json(
|
package/lib/routes/misc.js
CHANGED
|
@@ -5,7 +5,7 @@ import { mbkautheVar, packageJson, appVersion } from "#config.js";
|
|
|
5
5
|
import { renderError, renderPage } from "#response.js";
|
|
6
6
|
import { authenticate, sessVal, sessRole } from "../middleware/auth.js";
|
|
7
7
|
import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js";
|
|
8
|
-
import { dblogin } from "#pool.js";
|
|
8
|
+
import { dblogin, dialect } from "#pool.js";
|
|
9
9
|
import { clearSessionCookies, decryptSessionId, cachedCookieOptions, getCookieDomain } from "#cookies.js";
|
|
10
10
|
import { AuthRepository } from "../db/AuthRepository.js";
|
|
11
11
|
import { isSafeFetchUrl } from "../utils/urlSafety.js";
|
|
@@ -21,7 +21,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
const router = express.Router();
|
|
24
|
-
const authRepo = new AuthRepository({ db: dblogin });
|
|
24
|
+
const authRepo = new AuthRepository({ db: dblogin, dialect });
|
|
25
25
|
const logMisc = createLogger("misc");
|
|
26
26
|
const PROFILE_IMAGE_CACHE_SECONDS = 300;
|
|
27
27
|
const PROFILE_IMAGE_CACHE_CONTROL = `private, max-age=${PROFILE_IMAGE_CACHE_SECONDS}, stale-while-revalidate=${PROFILE_IMAGE_CACHE_SECONDS}`;
|
package/lib/routes/oauth.js
CHANGED
|
@@ -4,7 +4,7 @@ import GitHubStrategy from 'passport-github2';
|
|
|
4
4
|
import GoogleStrategy from 'passport-google-oauth20';
|
|
5
5
|
import csurf from 'csurf';
|
|
6
6
|
import rateLimit from 'express-rate-limit';
|
|
7
|
-
import { dblogin } from "#pool.js";
|
|
7
|
+
import { dblogin, dialect } 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";
|
|
@@ -12,7 +12,7 @@ import { AuthRepository } from "../db/AuthRepository.js";
|
|
|
12
12
|
import { createLogger } from "../utils/logger.js";
|
|
13
13
|
|
|
14
14
|
const router = express.Router();
|
|
15
|
-
const authRepo = new AuthRepository({ db: dblogin });
|
|
15
|
+
const authRepo = new AuthRepository({ db: dblogin, dialect });
|
|
16
16
|
const logOAuth = createLogger("oauth");
|
|
17
17
|
|
|
18
18
|
// CSRF protection middleware
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import session from "express-session";
|
|
2
|
+
|
|
3
|
+
const Store = session.Store;
|
|
4
|
+
|
|
5
|
+
// SQLite's CURRENT_TIMESTAMP format ('YYYY-MM-DD HH:MM:SS', UTC) — used so
|
|
6
|
+
// this store's `expire` column sorts/compares correctly against any other
|
|
7
|
+
// CURRENT_TIMESTAMP-derived timestamp text elsewhere in the SQLite schema.
|
|
8
|
+
function toSqliteTimestamp(date) {
|
|
9
|
+
return date.toISOString().slice(0, 19).replace("T", " ");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* express-session store for the SQLite backend. Mirrors the table shape and
|
|
14
|
+
* public behavior of connect-pg-simple (same "session" table: sid, sess,
|
|
15
|
+
* expire, username, last_activity) so admin/cleanup queries elsewhere in
|
|
16
|
+
* mbkauthe that read the "session" table work unchanged across both
|
|
17
|
+
* database backends.
|
|
18
|
+
*/
|
|
19
|
+
export class SqliteSessionStore extends Store {
|
|
20
|
+
constructor({ db, tableName = "session", createTableIfMissing = false, disableTouch = false } = {}) {
|
|
21
|
+
super();
|
|
22
|
+
if (!db) throw new Error("[mbkauthe] SqliteSessionStore requires a `db` (SqlitePool) instance");
|
|
23
|
+
this.db = db;
|
|
24
|
+
this.tableName = tableName;
|
|
25
|
+
this.disableTouch = disableTouch;
|
|
26
|
+
|
|
27
|
+
if (createTableIfMissing) {
|
|
28
|
+
this.db.execScript(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS "${this.tableName}" (
|
|
30
|
+
sid TEXT PRIMARY KEY,
|
|
31
|
+
sess TEXT NOT NULL,
|
|
32
|
+
expire TEXT NOT NULL,
|
|
33
|
+
username TEXT,
|
|
34
|
+
last_activity TEXT DEFAULT CURRENT_TIMESTAMP
|
|
35
|
+
);
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_${this.tableName}_expire ON "${this.tableName}" (expire);
|
|
37
|
+
`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get(sid, callback) {
|
|
42
|
+
this.db.query(`SELECT sess, expire FROM "${this.tableName}" WHERE sid = ?`, [sid])
|
|
43
|
+
.then((result) => {
|
|
44
|
+
const row = result.rows?.[0];
|
|
45
|
+
if (!row) return callback(null, undefined);
|
|
46
|
+
|
|
47
|
+
if (row.expire && new Date(row.expire.replace(" ", "T") + "Z").getTime() <= Date.now()) {
|
|
48
|
+
return this.destroy(sid, (err) => callback(err, undefined));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
callback(null, JSON.parse(row.sess));
|
|
53
|
+
} catch (err) {
|
|
54
|
+
callback(err);
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.catch(callback);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
set(sid, sessionData, callback) {
|
|
61
|
+
const maxAge = sessionData.cookie?.maxAge;
|
|
62
|
+
const expireDate = typeof maxAge === "number"
|
|
63
|
+
? new Date(Date.now() + maxAge)
|
|
64
|
+
: new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
65
|
+
|
|
66
|
+
const sess = JSON.stringify(sessionData);
|
|
67
|
+
const expire = toSqliteTimestamp(expireDate);
|
|
68
|
+
const username = sessionData.user?.UserName ?? sessionData.UserName ?? null;
|
|
69
|
+
|
|
70
|
+
this.db.query(
|
|
71
|
+
`INSERT INTO "${this.tableName}" (sid, sess, expire, username, last_activity)
|
|
72
|
+
VALUES (?, ?, ?, ?, ?)
|
|
73
|
+
ON CONFLICT(sid) DO UPDATE SET sess = excluded.sess, expire = excluded.expire,
|
|
74
|
+
username = excluded.username, last_activity = excluded.last_activity`,
|
|
75
|
+
[sid, sess, expire, username, toSqliteTimestamp(new Date())]
|
|
76
|
+
)
|
|
77
|
+
.then(() => callback?.(null))
|
|
78
|
+
.catch((err) => callback?.(err));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
destroy(sid, callback) {
|
|
82
|
+
this.db.query(`DELETE FROM "${this.tableName}" WHERE sid = ?`, [sid])
|
|
83
|
+
.then(() => callback?.(null))
|
|
84
|
+
.catch((err) => callback?.(err));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
touch(sid, sessionData, callback) {
|
|
88
|
+
if (this.disableTouch) return callback?.(null);
|
|
89
|
+
|
|
90
|
+
const maxAge = sessionData.cookie?.maxAge;
|
|
91
|
+
const expireDate = typeof maxAge === "number"
|
|
92
|
+
? new Date(Date.now() + maxAge)
|
|
93
|
+
: new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
94
|
+
|
|
95
|
+
this.db.query(
|
|
96
|
+
`UPDATE "${this.tableName}" SET expire = ?, last_activity = ? WHERE sid = ?`,
|
|
97
|
+
[toSqliteTimestamp(expireDate), toSqliteTimestamp(new Date()), sid]
|
|
98
|
+
)
|
|
99
|
+
.then(() => callback?.(null))
|
|
100
|
+
.catch((err) => callback?.(err));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
all(callback) {
|
|
104
|
+
this.db.query(`SELECT sid, sess FROM "${this.tableName}"`, [])
|
|
105
|
+
.then((result) => {
|
|
106
|
+
try {
|
|
107
|
+
const sessions = (result.rows || []).map((row) => ({ sid: row.sid, ...JSON.parse(row.sess) }));
|
|
108
|
+
callback(null, sessions);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
callback(err);
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(callback);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
length(callback) {
|
|
117
|
+
this.db.query(`SELECT COUNT(*) AS count FROM "${this.tableName}"`, [])
|
|
118
|
+
.then((result) => callback(null, Number(result.rows?.[0]?.count ?? 0)))
|
|
119
|
+
.catch(callback);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
clear(callback) {
|
|
123
|
+
this.db.query(`DELETE FROM "${this.tableName}"`, [])
|
|
124
|
+
.then(() => callback?.(null))
|
|
125
|
+
.catch((err) => callback?.(err));
|
|
126
|
+
}
|
|
127
|
+
}
|