@actual-app/sync-server 25.4.0-alpha.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/.dockerignore +12 -0
- package/README.md +19 -0
- package/app.js +11 -0
- package/babel.config.json +3 -0
- package/bin/@actual-app/sync-server +55 -0
- package/docker/alpine.Dockerfile +62 -0
- package/docker/ubuntu.Dockerfile +63 -0
- package/docker-compose.yml +29 -0
- package/jest.config.json +19 -0
- package/jest.global-setup.js +101 -0
- package/jest.global-teardown.js +6 -0
- package/migrations/1694360000000-create-folders.js +25 -0
- package/migrations/1694360479680-create-account-db.js +30 -0
- package/migrations/1694362247011-create-secret-table.js +16 -0
- package/migrations/1702667624000-rename-nordigen-secrets.js +19 -0
- package/migrations/1718889148000-openid.js +41 -0
- package/migrations/1719409568000-multiuser.js +116 -0
- package/package.json +64 -0
- package/src/account-db.js +239 -0
- package/src/accounts/openid.js +361 -0
- package/src/accounts/password.js +149 -0
- package/src/app-account.js +155 -0
- package/src/app-admin.js +410 -0
- package/src/app-admin.test.js +381 -0
- package/src/app-gocardless/README.md +198 -0
- package/src/app-gocardless/app-gocardless.js +274 -0
- package/src/app-gocardless/bank-factory.js +91 -0
- package/src/app-gocardless/banks/abanca_caglesmm.js +22 -0
- package/src/app-gocardless/banks/abnamro_abnanl2a.js +57 -0
- package/src/app-gocardless/banks/american_express_aesudef1.js +40 -0
- package/src/app-gocardless/banks/bancsabadell_bsabesbbb.js +31 -0
- package/src/app-gocardless/banks/bank.interface.ts +51 -0
- package/src/app-gocardless/banks/bank_of_ireland_b365_bofiie2d.js +39 -0
- package/src/app-gocardless/banks/bankinter_bkbkesmm.js +24 -0
- package/src/app-gocardless/banks/belfius_gkccbebb.js +17 -0
- package/src/app-gocardless/banks/berliner_sparkasse_beladebexxx.js +61 -0
- package/src/app-gocardless/banks/bnp_be_gebabebb.js +73 -0
- package/src/app-gocardless/banks/cbc_cregbebb.js +34 -0
- package/src/app-gocardless/banks/commerzbank_cobadeff.js +51 -0
- package/src/app-gocardless/banks/danskebank_dabno22.js +39 -0
- package/src/app-gocardless/banks/direkt_heladef1822.js +18 -0
- package/src/app-gocardless/banks/easybank_bawaatww.js +50 -0
- package/src/app-gocardless/banks/entercard_swednokk.js +40 -0
- package/src/app-gocardless/banks/fortuneo_ftnofrp1xxx.js +46 -0
- package/src/app-gocardless/banks/hype_hyeeit22.js +74 -0
- package/src/app-gocardless/banks/ing_ingbrobu.js +70 -0
- package/src/app-gocardless/banks/ing_ingddeff.js +47 -0
- package/src/app-gocardless/banks/ing_pl_ingbplpw.js +46 -0
- package/src/app-gocardless/banks/integration-bank.js +115 -0
- package/src/app-gocardless/banks/isybank_itbbitmm.js +18 -0
- package/src/app-gocardless/banks/kbc_kredbebb.js +33 -0
- package/src/app-gocardless/banks/lhv-lhvbee22.js +36 -0
- package/src/app-gocardless/banks/mbank_retail_brexplpw.js +56 -0
- package/src/app-gocardless/banks/nationwide_naiagb21.js +46 -0
- package/src/app-gocardless/banks/nbg_ethngraaxxx.js +51 -0
- package/src/app-gocardless/banks/norwegian_xx_norwnok1.js +74 -0
- package/src/app-gocardless/banks/revolut_revolt21.js +37 -0
- package/src/app-gocardless/banks/sandboxfinance_sfin0000.js +28 -0
- package/src/app-gocardless/banks/seb_kort_bank_ab.js +58 -0
- package/src/app-gocardless/banks/seb_privat.js +29 -0
- package/src/app-gocardless/banks/sparnord_spnodk22.js +24 -0
- package/src/app-gocardless/banks/spk_karlsruhe_karsde66.js +61 -0
- package/src/app-gocardless/banks/spk_marburg_biedenkopf_heladef1mar.js +30 -0
- package/src/app-gocardless/banks/spk_worms_alzey_ried_malade51wor.js +19 -0
- package/src/app-gocardless/banks/ssk_dusseldorf_dussdeddxxx.js +50 -0
- package/src/app-gocardless/banks/swedbank_habalv22.js +47 -0
- package/src/app-gocardless/banks/tests/abanca_caglesmm.spec.js +21 -0
- package/src/app-gocardless/banks/tests/abnamro_abnanl2a.spec.js +61 -0
- package/src/app-gocardless/banks/tests/bancsabadell_bsabesbbb.spec.js +53 -0
- package/src/app-gocardless/banks/tests/belfius_gkccbebb.spec.js +22 -0
- package/src/app-gocardless/banks/tests/cbc_cregbebb.spec.js +34 -0
- package/src/app-gocardless/banks/tests/commerzbank_cobadeff.spec.js +110 -0
- package/src/app-gocardless/banks/tests/easybank_bawaatww.spec.js +54 -0
- package/src/app-gocardless/banks/tests/fortuneo_ftnofrp1xxx.spec.js +206 -0
- package/src/app-gocardless/banks/tests/ing_ingddeff.spec.js +302 -0
- package/src/app-gocardless/banks/tests/ing_pl_ingbplpw.spec.js +202 -0
- package/src/app-gocardless/banks/tests/integration_bank.spec.js +158 -0
- package/src/app-gocardless/banks/tests/kbc_kredbebb.spec.js +38 -0
- package/src/app-gocardless/banks/tests/lhv-lhvbee22.spec.js +68 -0
- package/src/app-gocardless/banks/tests/mbank_retail_brexplpw.spec.js +171 -0
- package/src/app-gocardless/banks/tests/nationwide_naiagb21.spec.js +105 -0
- package/src/app-gocardless/banks/tests/nbg_ethngraaxxx.spec.js +48 -0
- package/src/app-gocardless/banks/tests/revolut_revolt21.spec.js +42 -0
- package/src/app-gocardless/banks/tests/sandboxfinance_sfin0000.spec.js +133 -0
- package/src/app-gocardless/banks/tests/spk_marburg_biedenkopf_heladef1mar.spec.js +256 -0
- package/src/app-gocardless/banks/tests/ssk_dusseldorf_dussdeddxxx.spec.js +102 -0
- package/src/app-gocardless/banks/tests/swedbank_habalv22.spec.js +57 -0
- package/src/app-gocardless/banks/tests/virgin_nrnbgb22.spec.js +54 -0
- package/src/app-gocardless/banks/util/extract-payeeName-from-remittanceInfo.js +36 -0
- package/src/app-gocardless/banks/virgin_nrnbgb22.js +39 -0
- package/src/app-gocardless/errors.js +84 -0
- package/src/app-gocardless/gocardless-node.types.ts +497 -0
- package/src/app-gocardless/gocardless.types.ts +93 -0
- package/src/app-gocardless/link.html +18 -0
- package/src/app-gocardless/services/gocardless-service.js +620 -0
- package/src/app-gocardless/services/tests/fixtures.js +181 -0
- package/src/app-gocardless/services/tests/gocardless-service.spec.js +537 -0
- package/src/app-gocardless/tests/bank-factory.spec.js +20 -0
- package/src/app-gocardless/tests/utils.spec.js +162 -0
- package/src/app-gocardless/util/handle-error.js +16 -0
- package/src/app-gocardless/utils.js +45 -0
- package/src/app-openid.js +108 -0
- package/src/app-pluggyai/app-pluggyai.js +215 -0
- package/src/app-pluggyai/pluggyai-service.js +120 -0
- package/src/app-secrets.js +61 -0
- package/src/app-simplefin/app-simplefin.js +418 -0
- package/src/app-sync/errors.js +13 -0
- package/src/app-sync/services/files-service.js +243 -0
- package/src/app-sync/tests/services/files-service.test.js +250 -0
- package/src/app-sync/validation.js +77 -0
- package/src/app-sync.js +391 -0
- package/src/app-sync.test.js +877 -0
- package/src/app.js +145 -0
- package/src/config-types.ts +44 -0
- package/src/db.js +58 -0
- package/src/load-config.js +307 -0
- package/src/migrations.js +36 -0
- package/src/run-migrations.js +8 -0
- package/src/scripts/disable-openid.js +44 -0
- package/src/scripts/enable-openid.js +53 -0
- package/src/scripts/health-check.js +23 -0
- package/src/scripts/reset-password.js +51 -0
- package/src/secrets.test.js +83 -0
- package/src/services/secrets-service.js +94 -0
- package/src/services/user-service.js +272 -0
- package/src/sql/messages.sql +9 -0
- package/src/sync-simple.js +95 -0
- package/src/util/hash.js +5 -0
- package/src/util/middlewares.js +62 -0
- package/src/util/paths.js +13 -0
- package/src/util/payee-name.js +45 -0
- package/src/util/prompt.js +88 -0
- package/src/util/title/index.js +59 -0
- package/src/util/title/lower-case.js +93 -0
- package/src/util/title/specials.js +21 -0
- package/src/util/validate-user.js +68 -0
- package/tsconfig.json +21 -0
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@actual-app/sync-server",
|
|
3
|
+
"version": "25.4.0-alpha.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"description": "actual syncing server",
|
|
6
|
+
"bin": "bin/@actual-app/sync-server",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node app",
|
|
10
|
+
"start-monitor": "nodemon app",
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest --coverage",
|
|
13
|
+
"db:migrate": "NODE_ENV=development node src/run-migrations.js up",
|
|
14
|
+
"db:downgrade": "NODE_ENV=development node src/run-migrations.js down",
|
|
15
|
+
"db:test-migrate": "NODE_ENV=test node src/run-migrations.js up",
|
|
16
|
+
"db:test-downgrade": "NODE_ENV=test node src/run-migrations.js down",
|
|
17
|
+
"reset-password": "node src/scripts/reset-password.js",
|
|
18
|
+
"disable-openid": "node src/scripts/disable-openid.js",
|
|
19
|
+
"health-check": "node src/scripts/health-check.js"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@actual-app/crdt": "2.1.0",
|
|
23
|
+
"@actual-app/web": "25.4.0-alpha.0",
|
|
24
|
+
"bcrypt": "^5.1.1",
|
|
25
|
+
"better-sqlite3": "^11.9.1",
|
|
26
|
+
"body-parser": "^1.20.3",
|
|
27
|
+
"convict": "^6.2.4",
|
|
28
|
+
"cors": "^2.8.5",
|
|
29
|
+
"date-fns": "^2.30.0",
|
|
30
|
+
"debug": "^4.4.0",
|
|
31
|
+
"express": "4.21.2",
|
|
32
|
+
"express-actuator": "1.8.4",
|
|
33
|
+
"express-rate-limit": "^7.5.0",
|
|
34
|
+
"express-response-size": "^0.0.3",
|
|
35
|
+
"express-winston": "^4.2.0",
|
|
36
|
+
"jws": "^4.0.0",
|
|
37
|
+
"migrate": "^2.1.0",
|
|
38
|
+
"nordigen-node": "^1.4.0",
|
|
39
|
+
"openid-client": "^5.4.3",
|
|
40
|
+
"pluggy-sdk": "^0.68.1",
|
|
41
|
+
"uuid": "^9.0.1",
|
|
42
|
+
"winston": "^3.17.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@babel/core": "^7.26.10",
|
|
46
|
+
"@babel/preset-typescript": "^7.20.2",
|
|
47
|
+
"@types/babel__core": "^7",
|
|
48
|
+
"@types/bcrypt": "^5.0.2",
|
|
49
|
+
"@types/better-sqlite3": "^7.6.12",
|
|
50
|
+
"@types/convict": "^6",
|
|
51
|
+
"@types/cors": "^2.8.17",
|
|
52
|
+
"@types/express": "^5.0.0",
|
|
53
|
+
"@types/express-actuator": "^1.8.3",
|
|
54
|
+
"@types/jest": "^29.5.14",
|
|
55
|
+
"@types/node": "^22.14.0",
|
|
56
|
+
"@types/supertest": "^2.0.16",
|
|
57
|
+
"@types/uuid": "^9.0.8",
|
|
58
|
+
"http-proxy-middleware": "^3.0.3",
|
|
59
|
+
"jest": "^29.7.0",
|
|
60
|
+
"nodemon": "^3.1.9",
|
|
61
|
+
"supertest": "^6.3.4",
|
|
62
|
+
"typescript": "^5.8.2"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import * as bcrypt from 'bcrypt';
|
|
4
|
+
|
|
5
|
+
import { bootstrapOpenId } from './accounts/openid.js';
|
|
6
|
+
import { bootstrapPassword, loginWithPassword } from './accounts/password.js';
|
|
7
|
+
import { openDatabase } from './db.js';
|
|
8
|
+
import { config } from './load-config.js';
|
|
9
|
+
|
|
10
|
+
let _accountDb;
|
|
11
|
+
|
|
12
|
+
export function getAccountDb() {
|
|
13
|
+
if (_accountDb === undefined) {
|
|
14
|
+
const dbPath = join(config.get('serverFiles'), 'account.sqlite');
|
|
15
|
+
_accountDb = openDatabase(dbPath);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return _accountDb;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function needsBootstrap() {
|
|
22
|
+
const accountDb = getAccountDb();
|
|
23
|
+
const rows = accountDb.all('SELECT * FROM auth');
|
|
24
|
+
return rows.length === 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function listLoginMethods() {
|
|
28
|
+
const accountDb = getAccountDb();
|
|
29
|
+
const rows = accountDb.all('SELECT method, display_name, active FROM auth');
|
|
30
|
+
return rows
|
|
31
|
+
.filter(f =>
|
|
32
|
+
rows.length > 1 && config.get('enforceOpenId')
|
|
33
|
+
? f.method === 'openid'
|
|
34
|
+
: true,
|
|
35
|
+
)
|
|
36
|
+
.map(r => ({
|
|
37
|
+
method: r.method,
|
|
38
|
+
active: r.active,
|
|
39
|
+
displayName: r.display_name,
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getActiveLoginMethod() {
|
|
44
|
+
const accountDb = getAccountDb();
|
|
45
|
+
const { method } =
|
|
46
|
+
accountDb.first('SELECT method FROM auth WHERE active = 1') || {};
|
|
47
|
+
return method;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* Get the Login Method in the following order
|
|
52
|
+
* req (the frontend can say which method in the case it wants to resort to forcing password auth)
|
|
53
|
+
* config options
|
|
54
|
+
* fall back to using password
|
|
55
|
+
*/
|
|
56
|
+
export function getLoginMethod(req) {
|
|
57
|
+
if (
|
|
58
|
+
typeof req !== 'undefined' &&
|
|
59
|
+
(req.body || { loginMethod: null }).loginMethod &&
|
|
60
|
+
config.get('allowedLoginMethods').includes(req.body.loginMethod)
|
|
61
|
+
) {
|
|
62
|
+
return req.body.loginMethod;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
//BY-PASS ANY OTHER CONFIGURATION TO ENSURE HEADER AUTH
|
|
66
|
+
if (
|
|
67
|
+
config.get('loginMethod') === 'header' &&
|
|
68
|
+
config.get('allowedLoginMethods').includes('header')
|
|
69
|
+
) {
|
|
70
|
+
return config.get('loginMethod');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const activeMethod = getActiveLoginMethod();
|
|
74
|
+
return activeMethod || config.get('loginMethod');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function bootstrap(loginSettings, forced = false) {
|
|
78
|
+
if (!loginSettings) {
|
|
79
|
+
return { error: 'invalid-login-settings' };
|
|
80
|
+
}
|
|
81
|
+
const passEnabled = 'password' in loginSettings;
|
|
82
|
+
const openIdEnabled = 'openId' in loginSettings;
|
|
83
|
+
|
|
84
|
+
const accountDb = getAccountDb();
|
|
85
|
+
accountDb.mutate('BEGIN TRANSACTION');
|
|
86
|
+
try {
|
|
87
|
+
const { countOfOwner } =
|
|
88
|
+
accountDb.first(
|
|
89
|
+
`SELECT count(*) as countOfOwner
|
|
90
|
+
FROM users
|
|
91
|
+
WHERE users.user_name <> '' and users.owner = 1`,
|
|
92
|
+
) || {};
|
|
93
|
+
|
|
94
|
+
if (!forced && (!openIdEnabled || countOfOwner > 0)) {
|
|
95
|
+
if (!needsBootstrap()) {
|
|
96
|
+
accountDb.mutate('ROLLBACK');
|
|
97
|
+
return { error: 'already-bootstrapped' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!passEnabled && !openIdEnabled) {
|
|
102
|
+
accountDb.mutate('ROLLBACK');
|
|
103
|
+
return { error: 'no-auth-method-selected' };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (passEnabled && openIdEnabled && !forced) {
|
|
107
|
+
accountDb.mutate('ROLLBACK');
|
|
108
|
+
return { error: 'max-one-method-allowed' };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (passEnabled) {
|
|
112
|
+
const { error } = bootstrapPassword(loginSettings.password);
|
|
113
|
+
if (error) {
|
|
114
|
+
accountDb.mutate('ROLLBACK');
|
|
115
|
+
return { error };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (openIdEnabled && forced) {
|
|
120
|
+
const { error } = await bootstrapOpenId(loginSettings.openId);
|
|
121
|
+
if (error) {
|
|
122
|
+
accountDb.mutate('ROLLBACK');
|
|
123
|
+
return { error };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
accountDb.mutate('COMMIT');
|
|
128
|
+
return passEnabled ? loginWithPassword(loginSettings.password) : {};
|
|
129
|
+
} catch (error) {
|
|
130
|
+
accountDb.mutate('ROLLBACK');
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function isAdmin(userId) {
|
|
136
|
+
return hasPermission(userId, 'ADMIN');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function hasPermission(userId, permission) {
|
|
140
|
+
return getUserPermission(userId) === permission;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function enableOpenID(loginSettings) {
|
|
144
|
+
if (!loginSettings || !loginSettings.openId) {
|
|
145
|
+
return { error: 'invalid-login-settings' };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const { error } = (await bootstrapOpenId(loginSettings.openId)) || {};
|
|
149
|
+
if (error) {
|
|
150
|
+
return { error };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getAccountDb().mutate('DELETE FROM sessions');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function disableOpenID(loginSettings) {
|
|
157
|
+
if (!loginSettings || !loginSettings.password) {
|
|
158
|
+
return { error: 'invalid-login-settings' };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const accountDb = getAccountDb();
|
|
162
|
+
const { extra_data: passwordHash } =
|
|
163
|
+
accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [
|
|
164
|
+
'password',
|
|
165
|
+
]) || {};
|
|
166
|
+
|
|
167
|
+
if (!passwordHash) {
|
|
168
|
+
return { error: 'invalid-password' };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!loginSettings?.password) {
|
|
172
|
+
return { error: 'invalid-password' };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (passwordHash) {
|
|
176
|
+
const confirmed = bcrypt.compareSync(loginSettings.password, passwordHash);
|
|
177
|
+
|
|
178
|
+
if (!confirmed) {
|
|
179
|
+
return { error: 'invalid-password' };
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const { error } = (await bootstrapPassword(loginSettings.password)) || {};
|
|
184
|
+
if (error) {
|
|
185
|
+
return { error };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
accountDb.transaction(() => {
|
|
190
|
+
accountDb.mutate('DELETE FROM sessions');
|
|
191
|
+
accountDb.mutate(
|
|
192
|
+
`DELETE FROM user_access
|
|
193
|
+
WHERE user_access.user_id IN (
|
|
194
|
+
SELECT users.id
|
|
195
|
+
FROM users
|
|
196
|
+
WHERE users.user_name <> ?
|
|
197
|
+
);`,
|
|
198
|
+
[''],
|
|
199
|
+
);
|
|
200
|
+
accountDb.mutate('DELETE FROM users WHERE user_name <> ?', ['']);
|
|
201
|
+
accountDb.mutate('DELETE FROM auth WHERE method = ?', ['openid']);
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
console.error('Error cleaning up openid information:', err);
|
|
205
|
+
return { error: 'database-error' };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function getSession(token) {
|
|
210
|
+
const accountDb = getAccountDb();
|
|
211
|
+
return accountDb.first('SELECT * FROM sessions WHERE token = ?', [token]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function getUserInfo(userId) {
|
|
215
|
+
const accountDb = getAccountDb();
|
|
216
|
+
return accountDb.first('SELECT * FROM users WHERE id = ?', [userId]);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getUserPermission(userId) {
|
|
220
|
+
const accountDb = getAccountDb();
|
|
221
|
+
const { role } = accountDb.first(
|
|
222
|
+
`SELECT role FROM users
|
|
223
|
+
WHERE users.id = ?`,
|
|
224
|
+
[userId],
|
|
225
|
+
) || { role: '' };
|
|
226
|
+
|
|
227
|
+
return role;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function clearExpiredSessions() {
|
|
231
|
+
const clearThreshold = Math.floor(Date.now() / 1000) - 3600;
|
|
232
|
+
|
|
233
|
+
const deletedSessions = getAccountDb().mutate(
|
|
234
|
+
'DELETE FROM sessions WHERE expires_at <> -1 and expires_at < ?',
|
|
235
|
+
[clearThreshold],
|
|
236
|
+
).changes;
|
|
237
|
+
|
|
238
|
+
console.log(`Deleted ${deletedSessions} old sessions`);
|
|
239
|
+
}
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { generators, Issuer } from 'openid-client';
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
|
|
4
|
+
import { clearExpiredSessions, getAccountDb } from '../account-db.js';
|
|
5
|
+
import { config } from '../load-config.js';
|
|
6
|
+
import {
|
|
7
|
+
getUserByUsername,
|
|
8
|
+
transferAllFilesFromUser,
|
|
9
|
+
} from '../services/user-service.js';
|
|
10
|
+
import { TOKEN_EXPIRATION_NEVER } from '../util/validate-user.js';
|
|
11
|
+
|
|
12
|
+
import { checkPassword } from './password.js';
|
|
13
|
+
|
|
14
|
+
export async function bootstrapOpenId(configParameter) {
|
|
15
|
+
if (!('issuer' in configParameter) && !('discoveryURL' in configParameter)) {
|
|
16
|
+
return { error: 'missing-issuer-or-discoveryURL' };
|
|
17
|
+
}
|
|
18
|
+
if (!('client_id' in configParameter)) {
|
|
19
|
+
return { error: 'missing-client-id' };
|
|
20
|
+
}
|
|
21
|
+
if (!('client_secret' in configParameter)) {
|
|
22
|
+
return { error: 'missing-client-secret' };
|
|
23
|
+
}
|
|
24
|
+
if (!('server_hostname' in configParameter)) {
|
|
25
|
+
return { error: 'missing-server-hostname' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
//FOR BACKWARD COMPATIBLITY:
|
|
30
|
+
//If we don't put discoverURL into the issuer, it will break already enabled openid instances
|
|
31
|
+
if (configParameter.discoveryURL) {
|
|
32
|
+
configParameter.issuer = configParameter.discoveryURL;
|
|
33
|
+
delete configParameter.discoveryURL;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await setupOpenIdClient(configParameter);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('Error setting up OpenID client:', err);
|
|
39
|
+
return { error: 'configuration-error' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const accountDb = getAccountDb();
|
|
43
|
+
try {
|
|
44
|
+
accountDb.transaction(() => {
|
|
45
|
+
accountDb.mutate('DELETE FROM auth WHERE method = ?', ['openid']);
|
|
46
|
+
accountDb.mutate('UPDATE auth SET active = 0');
|
|
47
|
+
accountDb.mutate(
|
|
48
|
+
"INSERT INTO auth (method, display_name, extra_data, active) VALUES ('openid', 'OpenID', ?, 1)",
|
|
49
|
+
[JSON.stringify(configParameter)],
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('Error updating auth table:', err);
|
|
54
|
+
return { error: 'database-error' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function setupOpenIdClient(configParameter) {
|
|
61
|
+
const issuer =
|
|
62
|
+
typeof configParameter.issuer === 'string'
|
|
63
|
+
? await Issuer.discover(configParameter.issuer)
|
|
64
|
+
: new Issuer({
|
|
65
|
+
issuer: configParameter.issuer.name,
|
|
66
|
+
authorization_endpoint: configParameter.issuer.authorization_endpoint,
|
|
67
|
+
token_endpoint: configParameter.issuer.token_endpoint,
|
|
68
|
+
userinfo_endpoint: configParameter.issuer.userinfo_endpoint,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const client = new issuer.Client({
|
|
72
|
+
client_id: configParameter.client_id,
|
|
73
|
+
client_secret: configParameter.client_secret,
|
|
74
|
+
redirect_uri: new URL(
|
|
75
|
+
'/openid/callback',
|
|
76
|
+
configParameter.server_hostname,
|
|
77
|
+
).toString(),
|
|
78
|
+
validate_id_token: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return client;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loginWithOpenIdSetup(
|
|
85
|
+
returnUrl,
|
|
86
|
+
firstTimeLoginPassword = '',
|
|
87
|
+
) {
|
|
88
|
+
if (!returnUrl) {
|
|
89
|
+
return { error: 'return-url-missing' };
|
|
90
|
+
}
|
|
91
|
+
if (!isValidRedirectUrl(returnUrl)) {
|
|
92
|
+
return { error: 'invalid-return-url' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const accountDb = getAccountDb();
|
|
96
|
+
|
|
97
|
+
const { countUsersWithUserName } = accountDb.first(
|
|
98
|
+
'SELECT count(*) as countUsersWithUserName FROM users WHERE user_name <> ?',
|
|
99
|
+
[''],
|
|
100
|
+
);
|
|
101
|
+
if (countUsersWithUserName === 0) {
|
|
102
|
+
const valid = checkPassword(firstTimeLoginPassword);
|
|
103
|
+
|
|
104
|
+
if (!valid) {
|
|
105
|
+
return { error: 'invalid-password' };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let config = accountDb.first('SELECT extra_data FROM auth WHERE method = ?', [
|
|
110
|
+
'openid',
|
|
111
|
+
]);
|
|
112
|
+
if (!config) {
|
|
113
|
+
return { error: 'openid-not-configured' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
config = JSON.parse(config['extra_data']);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error('Error parsing OpenID configuration:', err);
|
|
120
|
+
return { error: 'openid-setup-failed' };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let client;
|
|
124
|
+
try {
|
|
125
|
+
client = await setupOpenIdClient(config);
|
|
126
|
+
} catch (err) {
|
|
127
|
+
console.error('Error setting up OpenID client:', err);
|
|
128
|
+
return { error: 'openid-setup-failed' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const state = generators.state();
|
|
132
|
+
const code_verifier = generators.codeVerifier();
|
|
133
|
+
const code_challenge = generators.codeChallenge(code_verifier);
|
|
134
|
+
|
|
135
|
+
const now_time = Date.now();
|
|
136
|
+
const expiry_time = now_time + 300 * 1000;
|
|
137
|
+
|
|
138
|
+
accountDb.mutate(
|
|
139
|
+
'DELETE FROM pending_openid_requests WHERE expiry_time < ?',
|
|
140
|
+
[now_time],
|
|
141
|
+
);
|
|
142
|
+
accountDb.mutate(
|
|
143
|
+
'INSERT INTO pending_openid_requests (state, code_verifier, return_url, expiry_time) VALUES (?, ?, ?, ?)',
|
|
144
|
+
[state, code_verifier, returnUrl, expiry_time],
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const url = client.authorizationUrl({
|
|
148
|
+
response_type: 'code',
|
|
149
|
+
scope: 'openid email profile',
|
|
150
|
+
state,
|
|
151
|
+
code_challenge,
|
|
152
|
+
code_challenge_method: 'S256',
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return { url };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export async function loginWithOpenIdFinalize(body) {
|
|
159
|
+
if (!body.code) {
|
|
160
|
+
return { error: 'missing-authorization-code' };
|
|
161
|
+
}
|
|
162
|
+
if (!body.state) {
|
|
163
|
+
return { error: 'missing-state' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const accountDb = getAccountDb();
|
|
167
|
+
let configFromDb = accountDb.first(
|
|
168
|
+
"SELECT extra_data FROM auth WHERE method = 'openid' AND active = 1",
|
|
169
|
+
);
|
|
170
|
+
if (!configFromDb) {
|
|
171
|
+
return { error: 'openid-not-configured' };
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
configFromDb = JSON.parse(configFromDb['extra_data']);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error('Error parsing OpenID configuration:', err);
|
|
177
|
+
return { error: 'openid-setup-failed' };
|
|
178
|
+
}
|
|
179
|
+
let client;
|
|
180
|
+
try {
|
|
181
|
+
client = await setupOpenIdClient(configFromDb);
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error('Error setting up OpenID client:', err);
|
|
184
|
+
return { error: 'openid-setup-failed' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const pendingRequest = accountDb.first(
|
|
188
|
+
'SELECT code_verifier, return_url FROM pending_openid_requests WHERE state = ? AND expiry_time > ?',
|
|
189
|
+
[body.state, Date.now()],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
if (!pendingRequest) {
|
|
193
|
+
return { error: 'invalid-or-expired-state' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { code_verifier, return_url } = pendingRequest;
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
let tokenSet = null;
|
|
200
|
+
|
|
201
|
+
if (!configFromDb.authMethod || configFromDb.authMethod === 'openid') {
|
|
202
|
+
const params = { code: body.code, state: body.state, iss: body.iss };
|
|
203
|
+
tokenSet = await client.callback(client.redirect_uris[0], params, {
|
|
204
|
+
code_verifier,
|
|
205
|
+
state: body.state,
|
|
206
|
+
});
|
|
207
|
+
} else {
|
|
208
|
+
tokenSet = await client.grant({
|
|
209
|
+
grant_type: 'authorization_code',
|
|
210
|
+
code: body.code,
|
|
211
|
+
redirect_uri: client.redirect_uris[0],
|
|
212
|
+
code_verifier,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
const userInfo = await client.userinfo(tokenSet.access_token);
|
|
216
|
+
const identity =
|
|
217
|
+
userInfo.preferred_username ??
|
|
218
|
+
userInfo.login ??
|
|
219
|
+
userInfo.email ??
|
|
220
|
+
userInfo.id ??
|
|
221
|
+
userInfo.sub;
|
|
222
|
+
|
|
223
|
+
if (identity == null) {
|
|
224
|
+
return { error: 'openid-grant-failed: no identification was found' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let userId = null;
|
|
228
|
+
try {
|
|
229
|
+
accountDb.transaction(() => {
|
|
230
|
+
const { countUsersWithUserName } = accountDb.first(
|
|
231
|
+
'SELECT count(*) as countUsersWithUserName FROM users WHERE user_name <> ?',
|
|
232
|
+
[''],
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Check if user was created by another transaction
|
|
236
|
+
const existingUser = accountDb.first(
|
|
237
|
+
'SELECT id FROM users WHERE user_name = ?',
|
|
238
|
+
[identity],
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (
|
|
242
|
+
!existingUser &&
|
|
243
|
+
(countUsersWithUserName === 0 ||
|
|
244
|
+
config.get('userCreationMode') === 'login')
|
|
245
|
+
) {
|
|
246
|
+
userId = uuidv4();
|
|
247
|
+
accountDb.mutate(
|
|
248
|
+
'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, 1, ?, ?)',
|
|
249
|
+
[
|
|
250
|
+
userId,
|
|
251
|
+
identity,
|
|
252
|
+
userInfo.name ?? userInfo.email ?? identity,
|
|
253
|
+
countUsersWithUserName === 0 ? '1' : '0',
|
|
254
|
+
countUsersWithUserName === 0 ? 'ADMIN' : 'BASIC',
|
|
255
|
+
],
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
if (countUsersWithUserName === 0) {
|
|
259
|
+
const userFromPasswordMethod = getUserByUsername('');
|
|
260
|
+
if (userFromPasswordMethod) {
|
|
261
|
+
transferAllFilesFromUser(userId, userFromPasswordMethod.user_id);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} else {
|
|
265
|
+
const { id: userIdFromDb, display_name: displayName } =
|
|
266
|
+
accountDb.first(
|
|
267
|
+
'SELECT id, display_name FROM users WHERE user_name = ? and enabled = 1',
|
|
268
|
+
[identity],
|
|
269
|
+
) || {};
|
|
270
|
+
|
|
271
|
+
if (userIdFromDb == null) {
|
|
272
|
+
throw new Error('openid-grant-failed');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!displayName && userInfo.name) {
|
|
276
|
+
accountDb.mutate('UPDATE users set display_name = ? WHERE id = ?', [
|
|
277
|
+
userInfo.name,
|
|
278
|
+
userIdFromDb,
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
userId = userIdFromDb;
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error.message === 'user-already-exists') {
|
|
287
|
+
return { error: 'user-already-exists' };
|
|
288
|
+
} else if (error.message === 'openid-grant-failed') {
|
|
289
|
+
return { error: 'openid-grant-failed' };
|
|
290
|
+
} else {
|
|
291
|
+
throw error; // Re-throw other unexpected errors
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const token = uuidv4();
|
|
296
|
+
|
|
297
|
+
let expiration;
|
|
298
|
+
if (config.get('token_expiration') === 'openid-provider') {
|
|
299
|
+
expiration = tokenSet.expires_at ?? TOKEN_EXPIRATION_NEVER;
|
|
300
|
+
} else if (config.get('token_expiration') === 'never') {
|
|
301
|
+
expiration = TOKEN_EXPIRATION_NEVER;
|
|
302
|
+
} else if (typeof config.get('token_expiration') === 'number') {
|
|
303
|
+
expiration =
|
|
304
|
+
Math.floor(Date.now() / 1000) + config.get('token_expiration') * 60;
|
|
305
|
+
} else {
|
|
306
|
+
expiration = Math.floor(Date.now() / 1000) + 10 * 60;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
accountDb.mutate(
|
|
310
|
+
'INSERT INTO sessions (token, expires_at, user_id, auth_method) VALUES (?, ?, ?, ?)',
|
|
311
|
+
[token, expiration, userId, 'openid'],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
clearExpiredSessions();
|
|
315
|
+
|
|
316
|
+
return { url: `${return_url}/openid-cb?token=${token}` };
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.error('OpenID grant failed:', err);
|
|
319
|
+
return { error: 'openid-grant-failed' };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function getServerHostname() {
|
|
324
|
+
const auth = getAccountDb().first(
|
|
325
|
+
'select * from auth WHERE method = ? and active = 1',
|
|
326
|
+
['openid'],
|
|
327
|
+
);
|
|
328
|
+
if (auth && auth.extra_data) {
|
|
329
|
+
try {
|
|
330
|
+
const openIdConfig = JSON.parse(auth.extra_data);
|
|
331
|
+
return openIdConfig.server_hostname;
|
|
332
|
+
} catch (error) {
|
|
333
|
+
console.error('Error parsing OpenID configuration:', error);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function isValidRedirectUrl(url) {
|
|
340
|
+
const serverHostname = getServerHostname();
|
|
341
|
+
|
|
342
|
+
if (!serverHostname) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const redirectUrl = new URL(url);
|
|
348
|
+
const serverUrl = new URL(serverHostname);
|
|
349
|
+
|
|
350
|
+
if (
|
|
351
|
+
redirectUrl.hostname === serverUrl.hostname ||
|
|
352
|
+
redirectUrl.hostname === 'localhost'
|
|
353
|
+
) {
|
|
354
|
+
return true;
|
|
355
|
+
} else {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
} catch (err) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
}
|