mbkauthe 5.1.1 → 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 +2 -2
- 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
package/README.md
CHANGED
|
@@ -10,14 +10,14 @@
|
|
|
10
10
|
<img height="64px" src="./public/logo.png" alt="MBKAuthe" />
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
|
-
**MBKAuthe** is an open source authentication package for Node.js
|
|
13
|
+
**MBKAuthe** is an open source authentication package for Node.js and Express, backed by PostgreSQL or SQLite. It handles login, session validation, role/app access checks, optional TOTP 2FA, OAuth login, API token authentication, and multi-session management.
|
|
14
14
|
|
|
15
15
|
> **Note:** MBKAuthe is intentionally focused on authentication and session validation. The broader user, permission, and dashboard management system is a separate MBKTech product named **MBKCore**(closed source for now).
|
|
16
16
|
|
|
17
17
|
## Features
|
|
18
18
|
|
|
19
19
|
- Express middleware for session validation and role checks
|
|
20
|
-
- PostgreSQL
|
|
20
|
+
- PostgreSQL or SQLite storage for users, sessions, 2FA, trusted devices, and API tokens
|
|
21
21
|
- Secure password authentication with PBKDF2
|
|
22
22
|
- Optional TOTP 2FA with trusted devices
|
|
23
23
|
- GitHub App and Google OAuth login flows
|
|
@@ -46,17 +46,24 @@ Copy-Item .env.example .env
|
|
|
46
46
|
|
|
47
47
|
See the [configuration guide](docs/guides/configuration.md) for `mbkautheVar`, `mbkauthShared`, OAuth settings, session settings, and deployment flags.
|
|
48
48
|
|
|
49
|
-
3.
|
|
49
|
+
3. Choose a database backend.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
MBKAuthe supports two backends, selected with `DB_TYPE` in `mbkautheVar`:
|
|
52
|
+
|
|
53
|
+
- **PostgreSQL** (default) - set `LOGIN_DB` to a connection string. Recommended for production and multi-instance deployments.
|
|
54
|
+
- **SQLite** - set `DB_TYPE` to `sqlite` and `SQLITE_PATH` to a file path (created if missing). No database server required - convenient for development, tests, and small single-instance deployments. Uses `better-sqlite3` with WAL mode; expect `-wal`/`-shm` side files next to the database file. See the [SQLite backend notes](docs/guides/database.md#sqlite-backend-notes) in the database guide.
|
|
55
|
+
|
|
56
|
+
4. Create database tables.
|
|
52
57
|
|
|
53
58
|
```bash
|
|
54
59
|
npm run create-tables
|
|
55
60
|
```
|
|
56
61
|
|
|
62
|
+
The script applies [docs/schema/db.sql](docs/schema/db.sql) (PostgreSQL) or [docs/schema/db.sqlite.sql](docs/schema/db.sqlite.sql) (SQLite) to the configured backend. You can also run the matching SQL file yourself.
|
|
63
|
+
|
|
57
64
|
The schema includes a default SuperAdmin user (`support` / `12345678`). Change that password immediately. See the [database guide](docs/guides/database.md).
|
|
58
65
|
|
|
59
|
-
|
|
66
|
+
5. Mount MBKAuthe in Express.
|
|
60
67
|
|
|
61
68
|
```javascript
|
|
62
69
|
import express from "express";
|
|
@@ -93,7 +100,8 @@ app.listen(3000);
|
|
|
93
100
|
- `strictValidateSession` - require cookie session authentication only.
|
|
94
101
|
- `strictValidateSessionAndRole` - strict cookie session plus role check.
|
|
95
102
|
- `authenticate(token)` - protect server-to-server routes with a static bearer token.
|
|
96
|
-
- `dblogin` - access the configured
|
|
103
|
+
- `dblogin` - access the configured database pool (`pg.Pool` or the SQLite adapter, per `DB_TYPE`).
|
|
104
|
+
- `dbType` - the active backend: `"postgres"` or `"sqlite"`.
|
|
97
105
|
|
|
98
106
|
See the [API reference](docs/reference/api.md) for endpoints, middleware, examples, security notes, and rate limits.
|
|
99
107
|
|
|
@@ -149,6 +157,7 @@ Development-only diagnostics are mounted when `process.env.env === "dev"`:
|
|
|
149
157
|
- Set an appropriate `COOKIE_EXPIRE_TIME`
|
|
150
158
|
- Store secrets in environment variables
|
|
151
159
|
- Configure OAuth credentials only when the matching provider is enabled
|
|
160
|
+
- If using the SQLite backend, put `SQLITE_PATH` on persistent disk (not ephemeral/serverless storage) and back up the database together with its `-wal`/`-shm` side files
|
|
152
161
|
|
|
153
162
|
Vercel deployments can use shared OAuth credentials through `mbkauthShared`.
|
|
154
163
|
|
package/docs/README.md
CHANGED
|
@@ -27,7 +27,8 @@ This directory is organized by how the documentation is used:
|
|
|
27
27
|
|
|
28
28
|
## Assets
|
|
29
29
|
|
|
30
|
-
- [Database schema SQL](schema/db.sql)
|
|
30
|
+
- [Database schema SQL - PostgreSQL](schema/db.sql)
|
|
31
|
+
- [Database schema SQL - SQLite](schema/db.sqlite.sql)
|
|
31
32
|
- [Authentication flow Mermaid source](diagrams/auth-flows.mmd)
|
|
32
33
|
- [Authentication process Mermaid source](diagrams/auth-processes.mmd)
|
|
33
34
|
- [Rendered diagram images](images/)
|
|
@@ -39,12 +39,25 @@ This document describes the environment variables MBKAuth expects and keeps brie
|
|
|
39
39
|
- Example: `"DOMAIN":"localhost"`
|
|
40
40
|
- Required: Yes
|
|
41
41
|
|
|
42
|
+
- DB_TYPE
|
|
43
|
+
- Description: Which database backend to use.
|
|
44
|
+
- Values: `postgres` (default) / `sqlite`
|
|
45
|
+
- Example: `"DB_TYPE":"sqlite"`
|
|
46
|
+
- Required: No (defaults to `postgres`)
|
|
47
|
+
|
|
42
48
|
- LOGIN_DB
|
|
43
49
|
- Description: PostgreSQL connection string for auth (must start with `postgresql://` or `postgres://`).
|
|
44
50
|
- Example: `"LOGIN_DB":"postgresql://user:pass@localhost:5432/mbkauth"`
|
|
45
|
-
- Required: Yes
|
|
51
|
+
- Required: Yes, when `DB_TYPE` is `postgres` (the default)
|
|
46
52
|
- Create free postgres db: https://neon.com/
|
|
47
53
|
|
|
54
|
+
- SQLITE_PATH
|
|
55
|
+
- Description: Path to the SQLite database file (created if missing). Uses `better-sqlite3` under the hood.
|
|
56
|
+
- Example: `"SQLITE_PATH":"./data/mbkauthe.sqlite"`
|
|
57
|
+
- Required: Yes, when `DB_TYPE` is `sqlite`
|
|
58
|
+
- Default: `./mbkauthe.sqlite`
|
|
59
|
+
- Run `npm run create-tables` after setting this to create the schema (loads `docs/schema/db.sqlite.sql` instead of the Postgres `db.sql`).
|
|
60
|
+
|
|
48
61
|
- MBKAUTH_TWO_FA_ENABLE
|
|
49
62
|
- Description: Enable Two-Factor Authentication.
|
|
50
63
|
- Values: `true` / `false` / `f`
|
|
@@ -123,6 +136,14 @@ Production (short):
|
|
|
123
136
|
mbkautheVar={"APP_NAME":"mbkauthe","Main_SECRET_TOKEN":"prod-token","SESSION_SECRET_KEY":"prod-secret","IS_DEPLOYED":"true","DOMAIN":"yourdomain.com","LOGIN_DB":"postgresql://dbuser:secure@db:5432/mbkauth_prod","MBKAUTH_TWO_FA_ENABLE":"true"}
|
|
124
137
|
```
|
|
125
138
|
|
|
139
|
+
SQLite (no external database required):
|
|
140
|
+
|
|
141
|
+
```env
|
|
142
|
+
mbkautheVar={"APP_NAME":"mbkauthe","Main_SECRET_TOKEN":"dev-token","SESSION_SECRET_KEY":"dev-secret","IS_DEPLOYED":"false","DOMAIN":"localhost","DB_TYPE":"sqlite","SQLITE_PATH":"./data/mbkauthe.sqlite","MBKAUTH_TWO_FA_ENABLE":"false"}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Then run `npm run create-tables` to create `./data/mbkauthe.sqlite` with the schema in `docs/schema/db.sqlite.sql`.
|
|
146
|
+
|
|
126
147
|
---
|
|
127
148
|
|
|
128
149
|
## Rules & best practices
|
package/docs/guides/database.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[Back to docs index](../README.md) | [Back to project README](../../README.md)
|
|
4
4
|
|
|
5
|
-
**Executable DDL lives only in [`docs/schema/db.sql`](../schema/db.sql).** This file explains what
|
|
5
|
+
**Executable DDL lives only in [`docs/schema/db.sql`](../schema/db.sql) (Postgres) and [`docs/schema/db.sqlite.sql`](../schema/db.sqlite.sql) (SQLite).** This file explains what those scripts create and how the app uses them. Run the Postgres script when bootstrapping or aligning a database (for example `psql $DATABASE_URL -f docs/schema/db.sql`). The app can also apply the right script for the configured backend via `lib/createTable.js`.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -91,3 +91,34 @@ const encryptedPassword = hashPassword("your-password", "newusername");
|
|
|
91
91
|
```
|
|
92
92
|
|
|
93
93
|
Replace usernames, roles (`SuperAdmin`, `NormalUser`, `Guest`, `member`), and flags (`Active`, `HaveMailAccount`) to match your needs.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## SQLite backend notes
|
|
98
|
+
|
|
99
|
+
When `DB_TYPE` is `sqlite` (see the [configuration guide](configuration.md)), the app uses one `better-sqlite3` connection wrapped by `lib/db/sqlitePool.js`. The schema comes from [`docs/schema/db.sqlite.sql`](../schema/db.sqlite.sql) instead of `db.sql`.
|
|
100
|
+
|
|
101
|
+
- The database runs in WAL journal mode with foreign keys enabled. Expect `-wal` and `-shm` side files next to the database file; do not delete them while the app is running, and include them when copying a live database (or checkpoint first with `PRAGMA wal_checkpoint(TRUNCATE)`).
|
|
102
|
+
- Concurrent requests are safe: an internal FIFO mutex serializes statements so a plain query can never run inside another request's open transaction.
|
|
103
|
+
|
|
104
|
+
### Warning: inside a transaction, only use `txRepo`
|
|
105
|
+
|
|
106
|
+
The mutex is not re-entrant. `pool.query()` waits for the mutex, and an open transaction holds it — so a `pool.query()` call from inside a `withTransaction` callback waits on the transaction it is part of and **deadlocks the request** (it hangs with no error). This includes indirect calls through a repository bound to the pool.
|
|
107
|
+
|
|
108
|
+
```javascript
|
|
109
|
+
await authRepo.withTransaction(async (txRepo) => {
|
|
110
|
+
// Correct: txRepo wraps the client that holds the lock.
|
|
111
|
+
await txRepo.insertAppSession(username, expiresAt, meta);
|
|
112
|
+
|
|
113
|
+
// WRONG - deadlocks: authRepo is bound to the pool, which
|
|
114
|
+
// waits for the lock this transaction is holding.
|
|
115
|
+
// await authRepo.getUserProfileByUsername(username);
|
|
116
|
+
|
|
117
|
+
// WRONG for the same reason:
|
|
118
|
+
// await pool.query('SELECT 1');
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Rule of thumb: inside the `withTransaction` callback, only touch the `txRepo` parameter — never `authRepo`, the pool, or anything else that reaches the pool. Queries against the pool are fine again once the callback returns.
|
|
123
|
+
|
|
124
|
+
(The Postgres backend has the same rule with a different failure mode: a `pool.query()` inside a transaction runs on a different pooled connection, silently outside the transaction.)
|
|
@@ -98,7 +98,7 @@ When a user logs in, MBKAuthe creates a session and sets the following cookies:
|
|
|
98
98
|
### Session Lifetime
|
|
99
99
|
|
|
100
100
|
- Default: 2 days (configurable via `COOKIE_EXPIRE_TIME`)
|
|
101
|
-
- Application sessions are stored in the `Sessions` table in PostgreSQL
|
|
101
|
+
- Application sessions are stored in the `Sessions` table in the configured database (PostgreSQL or SQLite, per `DB_TYPE`)
|
|
102
102
|
- Sessions persist across subdomains in production
|
|
103
103
|
|
|
104
104
|
---
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
-- SQLite schema for mbkauthe.
|
|
2
|
+
--
|
|
3
|
+
-- This mirrors the auth-relevant subset of docs/schema/db.sql (Postgres).
|
|
4
|
+
-- Tables belonging to the wider MBKTech platform rather than mbkauthe
|
|
5
|
+
-- itself (todos, notifications, categories, etc.) are intentionally not
|
|
6
|
+
-- included here — add those separately in your own app schema if needed.
|
|
7
|
+
--
|
|
8
|
+
-- Notes on the Postgres -> SQLite translation:
|
|
9
|
+
-- * "integer GENERATED ... AS IDENTITY" -> INTEGER PRIMARY KEY AUTOINCREMENT
|
|
10
|
+
-- * "boolean" -> INTEGER (0/1)
|
|
11
|
+
-- * "jsonb"/"json" -> TEXT (store JSON as text)
|
|
12
|
+
-- * "timestamp with time zone" -> TEXT (CURRENT_TIMESTAMP format)
|
|
13
|
+
-- * "uuid DEFAULT gen_random_uuid()" -> TEXT DEFAULT (uuid v4 expression)
|
|
14
|
+
-- * GIN indexes / COMMENT ON TABLE -> omitted (no SQLite equivalent)
|
|
15
|
+
|
|
16
|
+
PRAGMA foreign_keys = ON;
|
|
17
|
+
|
|
18
|
+
-- Table: Users
|
|
19
|
+
CREATE TABLE IF NOT EXISTS "Users" (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
"UserName" TEXT UNIQUE,
|
|
22
|
+
"Password" TEXT DEFAULT '12345670',
|
|
23
|
+
"Active" INTEGER DEFAULT 0,
|
|
24
|
+
"Role" TEXT DEFAULT 'NormalUser',
|
|
25
|
+
"HaveMailAccount" INTEGER DEFAULT 0,
|
|
26
|
+
"AllowedApps" TEXT DEFAULT '["Portal", "mbkauthe"]',
|
|
27
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
28
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
29
|
+
last_login TEXT,
|
|
30
|
+
"PasswordEnc" TEXT,
|
|
31
|
+
"FullName" TEXT,
|
|
32
|
+
"UserId" TEXT UNIQUE CHECK ("UserId" IS NULL OR length("UserId") = 9),
|
|
33
|
+
email TEXT DEFAULT 'support@mbktech.org',
|
|
34
|
+
"Image" TEXT DEFAULT 'https://portal.mbktech.org/icon.svg',
|
|
35
|
+
"Bio" TEXT DEFAULT 'I am ....',
|
|
36
|
+
"SocialAccounts" TEXT DEFAULT '{}',
|
|
37
|
+
"Positions" TEXT DEFAULT '{"Not_Permanent": "Member Is Not Permanent"}'
|
|
38
|
+
);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_users_active ON "Users" ("Active");
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_users_email ON "Users" (email);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS idx_users_last_login ON "Users" (last_login);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_users_role ON "Users" ("Role");
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_users_username ON "Users" ("UserName");
|
|
44
|
+
CREATE INDEX IF NOT EXISTS idx_users_userid ON "Users" ("UserId");
|
|
45
|
+
|
|
46
|
+
-- Table: ApiTokens
|
|
47
|
+
CREATE TABLE IF NOT EXISTS "ApiTokens" (
|
|
48
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
49
|
+
"UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
50
|
+
"Name" TEXT NOT NULL CHECK (length(trim("Name")) > 0),
|
|
51
|
+
"TokenHash" TEXT NOT NULL UNIQUE,
|
|
52
|
+
"Prefix" TEXT NOT NULL,
|
|
53
|
+
"Permissions" TEXT DEFAULT '{"scope": "read-only", "allowedApps": null}' NOT NULL,
|
|
54
|
+
"LastUsed" TEXT,
|
|
55
|
+
"CreatedAt" TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
56
|
+
"ExpiresAt" TEXT,
|
|
57
|
+
CHECK ("ExpiresAt" IS NULL OR "ExpiresAt" > "CreatedAt")
|
|
58
|
+
);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_expires ON "ApiTokens" ("ExpiresAt") WHERE "ExpiresAt" IS NOT NULL;
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_apitokens_username_created ON "ApiTokens" ("UserName", "CreatedAt" DESC);
|
|
61
|
+
|
|
62
|
+
-- Table: PasswordResets
|
|
63
|
+
CREATE TABLE IF NOT EXISTS "PasswordResets" (
|
|
64
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
65
|
+
"UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
66
|
+
"resetToken" TEXT,
|
|
67
|
+
"resetTokenExpires" TEXT,
|
|
68
|
+
"resetAttempts" INTEGER DEFAULT 0,
|
|
69
|
+
"lastResetAttempt" TEXT,
|
|
70
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
71
|
+
);
|
|
72
|
+
CREATE INDEX IF NOT EXISTS idx_password_resets_token ON "PasswordResets" ("resetToken");
|
|
73
|
+
|
|
74
|
+
-- Table: Sessions (mbkauthe's own app-session table — distinct from the
|
|
75
|
+
-- express-session store table below, which is just "session")
|
|
76
|
+
CREATE TABLE IF NOT EXISTS "Sessions" (
|
|
77
|
+
id TEXT PRIMARY KEY DEFAULT (
|
|
78
|
+
lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
|
79
|
+
substr(lower(hex(randomblob(2))), 2) || '-' ||
|
|
80
|
+
substr('89ab', (abs(random()) % 4) + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
|
81
|
+
lower(hex(randomblob(6)))
|
|
82
|
+
),
|
|
83
|
+
"UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
84
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
85
|
+
expires_at TEXT,
|
|
86
|
+
meta TEXT
|
|
87
|
+
);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON "Sessions" (expires_at) WHERE expires_at IS NOT NULL;
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_user_created ON "Sessions" ("UserName", created_at);
|
|
90
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_username_expires ON "Sessions" ("UserName", expires_at);
|
|
91
|
+
|
|
92
|
+
-- Table: TrustedDevices
|
|
93
|
+
CREATE TABLE IF NOT EXISTS "TrustedDevices" (
|
|
94
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
95
|
+
"UserName" TEXT NOT NULL REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
96
|
+
"DeviceToken" TEXT NOT NULL UNIQUE,
|
|
97
|
+
"DeviceName" TEXT,
|
|
98
|
+
"UserAgent" TEXT,
|
|
99
|
+
"IpAddress" TEXT,
|
|
100
|
+
"CreatedAt" TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
101
|
+
"ExpiresAt" TEXT NOT NULL,
|
|
102
|
+
"LastUsed" TEXT DEFAULT CURRENT_TIMESTAMP
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_trusted_devices_expires ON "TrustedDevices" ("ExpiresAt");
|
|
105
|
+
CREATE INDEX IF NOT EXISTS idx_trusted_devices_username_expires ON "TrustedDevices" ("UserName", "ExpiresAt");
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_trusted_devices_token_user_expires ON "TrustedDevices" ("DeviceToken", "UserName", "ExpiresAt");
|
|
107
|
+
|
|
108
|
+
-- Table: TwoFA
|
|
109
|
+
CREATE TABLE IF NOT EXISTS "TwoFA" (
|
|
110
|
+
"UserName" TEXT NOT NULL PRIMARY KEY REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
111
|
+
"TwoFAStatus" INTEGER DEFAULT 0 NOT NULL,
|
|
112
|
+
"TwoFASecret" TEXT
|
|
113
|
+
);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_twofa_username_status ON "TwoFA" ("UserName", "TwoFAStatus");
|
|
115
|
+
|
|
116
|
+
-- Table: session (express-session store; see lib/session/SqliteSessionStore.js)
|
|
117
|
+
CREATE TABLE IF NOT EXISTS "session" (
|
|
118
|
+
sid TEXT PRIMARY KEY,
|
|
119
|
+
sess TEXT NOT NULL,
|
|
120
|
+
expire TEXT NOT NULL,
|
|
121
|
+
username TEXT REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
122
|
+
last_activity TEXT DEFAULT CURRENT_TIMESTAMP
|
|
123
|
+
);
|
|
124
|
+
CREATE INDEX IF NOT EXISTS idx_session_expire ON "session" (expire);
|
|
125
|
+
|
|
126
|
+
-- Table: user_github
|
|
127
|
+
CREATE TABLE IF NOT EXISTS user_github (
|
|
128
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
129
|
+
user_name TEXT REFERENCES "Users"("UserName") ON DELETE CASCADE,
|
|
130
|
+
github_id TEXT UNIQUE,
|
|
131
|
+
github_username TEXT,
|
|
132
|
+
access_token TEXT,
|
|
133
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
134
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
135
|
+
installation_id INTEGER,
|
|
136
|
+
installation_target_type TEXT
|
|
137
|
+
);
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_user_github_user_name ON user_github (user_name);
|
|
139
|
+
|
|
140
|
+
-- Table: user_google
|
|
141
|
+
CREATE TABLE IF NOT EXISTS user_google (
|
|
142
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
143
|
+
user_name TEXT REFERENCES "Users"("UserName"),
|
|
144
|
+
google_id TEXT UNIQUE,
|
|
145
|
+
google_email TEXT,
|
|
146
|
+
access_token TEXT,
|
|
147
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
|
148
|
+
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
-- Seed user (hash-only). Default password is "12345678" for this sample account.
|
|
152
|
+
-- Hash was generated using mbkauthe's "hashPassword(password, username)" function.
|
|
153
|
+
INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "FullName")
|
|
154
|
+
VALUES ('support', 'b8b10c1c9006d8c30ab81c412463c65ff6dae3293d9bfbaf5fd8e275081d0947f000a828004e2fbd3a8f6ef5a35ae3eddd4c57b00ecab376b12e607a16a57459', 'SuperAdmin', 1, 0, 'Support User')
|
|
155
|
+
ON CONFLICT("UserName") DO NOTHING;
|
package/index.d.ts
CHANGED
|
@@ -50,7 +50,12 @@ declare module 'mbkauthe' {
|
|
|
50
50
|
Main_SECRET_TOKEN: string;
|
|
51
51
|
IS_DEPLOYED: 'true' | 'false' | 'f';
|
|
52
52
|
DOMAIN: string;
|
|
53
|
-
|
|
53
|
+
/** Defaults to 'postgres'. Set to 'sqlite' to use the SQLite backend instead. */
|
|
54
|
+
DB_TYPE?: 'postgres' | 'sqlite';
|
|
55
|
+
/** Required when DB_TYPE is 'postgres' (default). */
|
|
56
|
+
LOGIN_DB?: string;
|
|
57
|
+
/** Required when DB_TYPE is 'sqlite'. Path to the SQLite database file. */
|
|
58
|
+
SQLITE_PATH?: string;
|
|
54
59
|
MBKAUTH_TWO_FA_ENABLE: 'true' | 'false' | 'f';
|
|
55
60
|
COOKIE_EXPIRE_TIME?: number;
|
|
56
61
|
DEVICE_TRUST_DURATION_DAYS?: number;
|
|
@@ -339,7 +344,26 @@ declare module 'mbkauthe' {
|
|
|
339
344
|
export function getLatestVersion(): Promise<string>;
|
|
340
345
|
|
|
341
346
|
// Exports
|
|
342
|
-
|
|
347
|
+
/** A `pg.Pool` when DB_TYPE is 'postgres' (default), or a SqlitePool-shaped adapter when DB_TYPE is 'sqlite'. */
|
|
348
|
+
export const dblogin: Pool | {
|
|
349
|
+
query(text: string | { text: string; values?: any[]; name?: string }, values?: any[]): Promise<{ rows: any[]; rowCount: number }>;
|
|
350
|
+
connect(): Promise<{ query: Function; release: () => void }>;
|
|
351
|
+
end(): Promise<void>;
|
|
352
|
+
};
|
|
353
|
+
export const dbType: 'postgres' | 'sqlite';
|
|
354
|
+
/** SQL dialect helpers for the active backend (see lib/db/dialects/). */
|
|
355
|
+
export const dialect: {
|
|
356
|
+
name: 'postgres' | 'sqlite';
|
|
357
|
+
quoteIdentifier(name: string): string;
|
|
358
|
+
param(index: number): string;
|
|
359
|
+
now(): string;
|
|
360
|
+
boolean(value: any): string;
|
|
361
|
+
supportsReturning: boolean;
|
|
362
|
+
returningClause(columns: string): string;
|
|
363
|
+
limitOffset(options?: { limit?: number; offset?: number }): string;
|
|
364
|
+
/** `null` on SQLite, which has no table-level lock statement. */
|
|
365
|
+
lockTable: ((tableSql: string, mode?: string) => string) | null;
|
|
366
|
+
};
|
|
343
367
|
export const mbkautheVar: MBKAuthConfig;
|
|
344
368
|
export const cachedCookieOptions: ReturnType<typeof getCookieOptions>;
|
|
345
369
|
export const cachedClearCookieOptions: ReturnType<typeof getClearCookieOptions>;
|
package/index.js
CHANGED
|
@@ -94,7 +94,7 @@ export * from "./lib/middleware/auth.js";
|
|
|
94
94
|
export * from "./lib/middleware/index.js";
|
|
95
95
|
export { validateTokenScope } from "./lib/middleware/scopeValidator.js";
|
|
96
96
|
export * from "#response.js";
|
|
97
|
-
export { dblogin } from "#pool.js";
|
|
97
|
+
export { dblogin, dbType, dialect } from "#pool.js";
|
|
98
98
|
export { getLatestVersion } from "./lib/routes/misc.js";
|
|
99
99
|
export * from "./lib/routes/auth.js";
|
|
100
100
|
export * from "./lib/utils/errors.js";
|
package/lib/config/index.js
CHANGED
|
@@ -7,7 +7,7 @@ const logConfig = createLogger("config");
|
|
|
7
7
|
|
|
8
8
|
const CONFIG_KEYS = [
|
|
9
9
|
"APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
|
|
10
|
-
"IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
|
|
10
|
+
"IS_DEPLOYED", "DB_TYPE", "LOGIN_DB", "SQLITE_PATH", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
|
|
11
11
|
"GITHUB_LOGIN_ENABLED", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_LOGIN_ENABLED", "GOOGLE_CLIENT_ID",
|
|
12
12
|
"GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
|
|
13
13
|
];
|
|
@@ -15,6 +15,8 @@ const CONFIG_KEYS = [
|
|
|
15
15
|
const DEFAULT_CONFIG = {
|
|
16
16
|
DEVICE_TRUST_DURATION_DAYS: 7,
|
|
17
17
|
IS_DEPLOYED: 'false',
|
|
18
|
+
DB_TYPE: 'postgres',
|
|
19
|
+
SQLITE_PATH: './mbkauthe.sqlite',
|
|
18
20
|
MBKAUTH_TWO_FA_ENABLE: 'false',
|
|
19
21
|
COOKIE_EXPIRE_TIME: 2,
|
|
20
22
|
loginRedirectURL: '/dashboard',
|
|
@@ -25,11 +27,13 @@ const DEFAULT_CONFIG = {
|
|
|
25
27
|
|
|
26
28
|
const BOOLEAN_KEYS = ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'];
|
|
27
29
|
const STRING_KEYS = [
|
|
28
|
-
"APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "LOGIN_DB", "DOMAIN", "loginRedirectURL",
|
|
30
|
+
"APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "DB_TYPE", "LOGIN_DB", "SQLITE_PATH", "DOMAIN", "loginRedirectURL",
|
|
29
31
|
"GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
|
|
30
32
|
"GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"
|
|
31
33
|
];
|
|
32
|
-
|
|
34
|
+
// LOGIN_DB is required for postgres but not for sqlite (SQLITE_PATH is used instead);
|
|
35
|
+
// enforced conditionally inside validateConfiguration below.
|
|
36
|
+
const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED",
|
|
33
37
|
"MBKAUTH_TWO_FA_ENABLE", "DOMAIN"];
|
|
34
38
|
|
|
35
39
|
const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
@@ -137,6 +141,13 @@ function normalizeAndValidateConfig(config, errors) {
|
|
|
137
141
|
config.APP_NAME = config.APP_NAME.toLowerCase();
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
if (hasValue(config.DB_TYPE)) {
|
|
145
|
+
config.DB_TYPE = config.DB_TYPE.toLowerCase();
|
|
146
|
+
if (!["postgres", "sqlite"].includes(config.DB_TYPE)) {
|
|
147
|
+
errors.push("mbkautheVar.DB_TYPE must be either 'postgres' or 'sqlite'");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
140
151
|
if (hasValue(config.DOMAIN)) {
|
|
141
152
|
const domain = config.DOMAIN.toLowerCase().replace(/^\.+/, '');
|
|
142
153
|
config.DOMAIN = domain;
|
|
@@ -203,9 +214,17 @@ function validateConfiguration() {
|
|
|
203
214
|
}
|
|
204
215
|
}
|
|
205
216
|
|
|
206
|
-
// Validate
|
|
207
|
-
if (mbkautheVar.
|
|
208
|
-
|
|
217
|
+
// Validate the DB connection setting for the selected DB_TYPE
|
|
218
|
+
if (mbkautheVar.DB_TYPE === "sqlite") {
|
|
219
|
+
if (isBlank(mbkautheVar.SQLITE_PATH)) {
|
|
220
|
+
errors.push("mbkautheVar.SQLITE_PATH is required and cannot be empty when DB_TYPE is 'sqlite'");
|
|
221
|
+
}
|
|
222
|
+
} else {
|
|
223
|
+
if (isBlank(mbkautheVar.LOGIN_DB)) {
|
|
224
|
+
errors.push("mbkautheVar.LOGIN_DB is required and cannot be empty when DB_TYPE is 'postgres'");
|
|
225
|
+
} else if (!mbkautheVar.LOGIN_DB.startsWith('postgresql://') && !mbkautheVar.LOGIN_DB.startsWith('postgres://')) {
|
|
226
|
+
errors.push("mbkautheVar.LOGIN_DB must be a valid PostgreSQL connection string");
|
|
227
|
+
}
|
|
209
228
|
}
|
|
210
229
|
|
|
211
230
|
// If there are validation errors, throw them all at once
|
package/lib/createTable.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
import { dblogin, dbType } from "./pool.js";
|
|
2
2
|
import { readFile } from "fs/promises";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
@@ -11,8 +11,10 @@ async function main() {
|
|
|
11
11
|
const startTime = performance.now();
|
|
12
12
|
|
|
13
13
|
console.log("[mbkauthe] Starting schema creation...");
|
|
14
|
+
console.log(`[mbkauthe] Database type: ${dbType}`);
|
|
14
15
|
|
|
15
|
-
const
|
|
16
|
+
const schemaFile = dbType === "sqlite" ? "db.sqlite.sql" : "db.sql";
|
|
17
|
+
const schemaPath = path.resolve(__dirname, "../docs/schema", schemaFile);
|
|
16
18
|
|
|
17
19
|
console.log(`[mbkauthe] Schema file: ${schemaPath}`);
|
|
18
20
|
|
|
@@ -31,6 +33,28 @@ async function main() {
|
|
|
31
33
|
`[mbkauthe] Detected approximately ${statementCount} SQL statements`
|
|
32
34
|
);
|
|
33
35
|
|
|
36
|
+
if (dbType === "sqlite") {
|
|
37
|
+
try {
|
|
38
|
+
console.log("[mbkauthe] Applying SQLite schema...");
|
|
39
|
+
const queryStart = performance.now();
|
|
40
|
+
|
|
41
|
+
dblogin.execScript(schemaSql);
|
|
42
|
+
|
|
43
|
+
const queryDuration = (performance.now() - queryStart).toFixed(2);
|
|
44
|
+
console.log(`[mbkauthe] Schema applied successfully in ${queryDuration} ms`);
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error("[mbkauthe] Failed to apply schema");
|
|
47
|
+
console.error("Message:", err.message);
|
|
48
|
+
process.exitCode = 1;
|
|
49
|
+
} finally {
|
|
50
|
+
console.log("[mbkauthe] Closing database connection...");
|
|
51
|
+
await dblogin.end();
|
|
52
|
+
const totalDuration = (performance.now() - startTime).toFixed(2);
|
|
53
|
+
console.log(`[mbkauthe] Finished in ${totalDuration} ms`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
try {
|
|
35
59
|
console.log("[mbkauthe] Testing database connection...");
|
|
36
60
|
|
package/lib/db/AuthRepository.js
CHANGED
|
@@ -76,6 +76,38 @@ export class AuthRepository extends BaseRepository {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
async touchTrustedDevice(deviceTokenHash, username) {
|
|
79
|
+
// SQLite's RETURNING clause can only reference columns of the table
|
|
80
|
+
// being updated, not columns joined in via FROM — so do the UPDATE and
|
|
81
|
+
// the follow-up SELECT as two statements for that dialect.
|
|
82
|
+
if (this.dialect.name === "sqlite") {
|
|
83
|
+
const updated = await this.executeRaw({
|
|
84
|
+
name: "check-trusted-device-update",
|
|
85
|
+
text: `
|
|
86
|
+
UPDATE "TrustedDevices"
|
|
87
|
+
SET "LastUsed" = CURRENT_TIMESTAMP
|
|
88
|
+
WHERE "DeviceToken" = $1
|
|
89
|
+
AND "UserName" = $2
|
|
90
|
+
AND "ExpiresAt" > CURRENT_TIMESTAMP
|
|
91
|
+
RETURNING "UserName", "ExpiresAt"
|
|
92
|
+
`,
|
|
93
|
+
values: [deviceTokenHash, username]
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const row = updated.rows?.[0];
|
|
97
|
+
if (!row) return null;
|
|
98
|
+
|
|
99
|
+
const userResult = await this.executeRaw({
|
|
100
|
+
name: "check-trusted-device-user",
|
|
101
|
+
text: `SELECT "UserId", "Active", "Role", "AllowedApps" FROM "Users" WHERE "UserName" = $1 AND "Active" = TRUE`,
|
|
102
|
+
values: [row.UserName]
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const userRow = userResult.rows?.[0];
|
|
106
|
+
if (!userRow) return null;
|
|
107
|
+
|
|
108
|
+
return { ...row, ...userRow };
|
|
109
|
+
}
|
|
110
|
+
|
|
79
111
|
const query = `
|
|
80
112
|
UPDATE "TrustedDevices" td
|
|
81
113
|
SET "LastUsed" = NOW()
|
|
@@ -97,6 +129,22 @@ export class AuthRepository extends BaseRepository {
|
|
|
97
129
|
}
|
|
98
130
|
|
|
99
131
|
async cleanupAndCountUserSessions(username, queryName = "cleanup-and-count-user-sessions") {
|
|
132
|
+
// SQLite has no writable CTEs (WITH ... AS (DELETE ...) SELECT), so split
|
|
133
|
+
// this into two statements for that dialect instead.
|
|
134
|
+
if (this.dialect.name === "sqlite") {
|
|
135
|
+
await this.executeRaw({
|
|
136
|
+
name: `${queryName}-delete`,
|
|
137
|
+
text: `DELETE FROM "Sessions" WHERE "UserName" = $1 AND expires_at IS NOT NULL AND expires_at <= NOW()`,
|
|
138
|
+
values: [username]
|
|
139
|
+
});
|
|
140
|
+
const result = await this.executeRaw({
|
|
141
|
+
name: `${queryName}-count`,
|
|
142
|
+
text: `SELECT COUNT(*) AS count FROM "Sessions" WHERE "UserName" = $1`,
|
|
143
|
+
values: [username]
|
|
144
|
+
});
|
|
145
|
+
return Number(result.rows?.[0]?.count ?? 0);
|
|
146
|
+
}
|
|
147
|
+
|
|
100
148
|
const query = `
|
|
101
149
|
WITH deleted AS (
|
|
102
150
|
DELETE FROM "Sessions"
|
|
@@ -245,7 +293,17 @@ export class AuthRepository extends BaseRepository {
|
|
|
245
293
|
}
|
|
246
294
|
|
|
247
295
|
async updateApiTokenLastUsed(tokenId, queryName = null, minIntervalMinutes = 15) {
|
|
248
|
-
const query =
|
|
296
|
+
const query = this.dialect.name === "sqlite"
|
|
297
|
+
? `
|
|
298
|
+
UPDATE "ApiTokens"
|
|
299
|
+
SET "LastUsed" = CURRENT_TIMESTAMP
|
|
300
|
+
WHERE id = ?
|
|
301
|
+
AND (
|
|
302
|
+
"LastUsed" IS NULL
|
|
303
|
+
OR "LastUsed" < datetime('now', '-' || ? || ' minutes')
|
|
304
|
+
)
|
|
305
|
+
`
|
|
306
|
+
: `
|
|
249
307
|
UPDATE "ApiTokens"
|
|
250
308
|
SET "LastUsed" = NOW()
|
|
251
309
|
WHERE id = $1
|
package/lib/db/BaseRepository.js
CHANGED
|
@@ -152,6 +152,14 @@ export class BaseRepository {
|
|
|
152
152
|
return new this.constructor({ db, dialect: this.dialect });
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Runs `fn(txRepo)` inside BEGIN..COMMIT on a dedicated client.
|
|
157
|
+
* Inside the callback, use ONLY the provided txRepo. Calling the outer
|
|
158
|
+
* repository or the pool from within the callback breaks transactional
|
|
159
|
+
* semantics on Postgres (runs on a different pooled connection) and
|
|
160
|
+
* DEADLOCKS on SQLite (the pool waits for the mutex this transaction
|
|
161
|
+
* holds). See docs/guides/database.md, "SQLite backend notes".
|
|
162
|
+
*/
|
|
155
163
|
async withTransaction(fn) {
|
|
156
164
|
if (!this.db || typeof this.db.connect !== "function") {
|
|
157
165
|
return fn(this);
|
|
@@ -184,6 +192,11 @@ export class BaseRepository {
|
|
|
184
192
|
}
|
|
185
193
|
|
|
186
194
|
async advisoryTransactionLock(lockKey, queryName = "advisory-transaction-lock") {
|
|
195
|
+
// pg_advisory_xact_lock is Postgres-only. SQLite (via better-sqlite3) has
|
|
196
|
+
// no separate connections to contend over: a write transaction already
|
|
197
|
+
// holds an exclusive file-level lock for its duration, so this is a safe no-op.
|
|
198
|
+
if (this.dialect.name !== "postgres") return null;
|
|
199
|
+
|
|
187
200
|
return this.executeRaw({
|
|
188
201
|
name: queryName,
|
|
189
202
|
text: "SELECT pg_advisory_xact_lock(hashtext($1))",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const quoteIdentifier = (name) => `"${String(name).replace(/"/g, '""')}"`;
|
|
2
|
+
|
|
3
|
+
export const sqliteDialect = {
|
|
4
|
+
name: "sqlite",
|
|
5
|
+
quoteIdentifier,
|
|
6
|
+
param: () => "?",
|
|
7
|
+
now: () => "CURRENT_TIMESTAMP",
|
|
8
|
+
boolean: (value) => (value ? "1" : "0"),
|
|
9
|
+
// better-sqlite3 bundles SQLite >= 3.35, which supports RETURNING.
|
|
10
|
+
supportsReturning: true,
|
|
11
|
+
returningClause: (columns) => ` RETURNING ${columns}`,
|
|
12
|
+
limitOffset: ({ limit, offset } = {}) => {
|
|
13
|
+
const parts = [];
|
|
14
|
+
if (typeof limit === "number") parts.push(`LIMIT ${limit}`);
|
|
15
|
+
// SQLite requires a LIMIT clause before OFFSET; -1 means "no limit".
|
|
16
|
+
if (typeof offset === "number") {
|
|
17
|
+
if (!parts.length) parts.push("LIMIT -1");
|
|
18
|
+
parts.push(`OFFSET ${offset}`);
|
|
19
|
+
}
|
|
20
|
+
return parts.length ? ` ${parts.join(" ")}` : "";
|
|
21
|
+
},
|
|
22
|
+
// SQLite has no table-level lock statement: writers are already serialized
|
|
23
|
+
// by the file-level lock taken for the duration of a write transaction.
|
|
24
|
+
lockTable: null
|
|
25
|
+
};
|