@wbce-d9/api 12.6.1 → 12.7.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/dist/auth/drivers/baseoauth.js +18 -7
- package/dist/auth/drivers/baseoauth.test.d.ts +1 -0
- package/dist/env.js +1 -0
- package/dist/flows.d.ts +9 -0
- package/dist/flows.js +37 -7
- package/dist/flows.test.d.ts +1 -0
- package/dist/logger.js +4 -3
- package/dist/services/graphql/index.js +3 -0
- package/dist/services/payload.js +5 -2
- package/dist/services/users.js +35 -29
- package/package.json +7 -7
|
@@ -39,11 +39,16 @@ export class BaseOAuthDriver extends LocalAuthDriver {
|
|
|
39
39
|
provider: this.getConfig()['provider'],
|
|
40
40
|
providerPayload: { accessToken: tokenSet.access_token, userInfo },
|
|
41
41
|
}, { database: getDatabase(), schema: this.schema, accountability: null });
|
|
42
|
-
// Update user to update refresh_token and other properties that might have changed
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
// Update user to update refresh_token and other properties that might have changed.
|
|
43
|
+
// Keep the stored refresh_token if the provider didn't issue a new one (e.g. Google when consent was
|
|
44
|
+
// already granted): refresh() still checks it against the provider
|
|
45
|
+
const updateUserPayload = userPayload.auth_data
|
|
46
|
+
? { ...updatedUserPayload, auth_data: userPayload.auth_data }
|
|
47
|
+
: updatedUserPayload;
|
|
48
|
+
// Skip empty updates, they crash (#127)
|
|
49
|
+
if (Object.keys(updateUserPayload).length > 0) {
|
|
50
|
+
await this.getUserService().updateOne(userId, updateUserPayload);
|
|
51
|
+
}
|
|
47
52
|
return userId;
|
|
48
53
|
}
|
|
49
54
|
// --- NEW USER ----
|
|
@@ -131,10 +136,16 @@ export class BaseOAuthDriver extends LocalAuthDriver {
|
|
|
131
136
|
throw this.handleError(e);
|
|
132
137
|
}
|
|
133
138
|
}
|
|
139
|
+
else if (this.getConfig()['requireRefreshToken'] === false) {
|
|
140
|
+
// Opt-in: the provider issued no refresh token (e.g. Google when consent was already granted),
|
|
141
|
+
// so the Directus session is the only source of truth for this user
|
|
142
|
+
logger.debug(`[${this.getClientName()}] No refresh token stored, continuing (REQUIRE_REFRESH_TOKEN=false)`);
|
|
143
|
+
}
|
|
134
144
|
else {
|
|
135
|
-
// If no auth connexion, throw user out
|
|
145
|
+
// If no auth connexion, throw user out. On login, the callback retries once with prompt=consent
|
|
146
|
+
// so the provider can issue a refresh_token
|
|
136
147
|
logger.error(`No [${this.getClientName()}] Session found`);
|
|
137
|
-
throw new
|
|
148
|
+
throw new InvalidTokenException();
|
|
138
149
|
}
|
|
139
150
|
}
|
|
140
151
|
handleError = (e) => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/env.js
CHANGED
|
@@ -141,6 +141,7 @@ const allowedEnvironmentVars = [
|
|
|
141
141
|
'AUTH_.+_FIRST_NAME_KEY',
|
|
142
142
|
'AUTH_.+_LAST_NAME_KEY',
|
|
143
143
|
'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
|
|
144
|
+
'AUTH_.+_REQUIRE_REFRESH_TOKEN',
|
|
144
145
|
'AUTH_.+_DEFAULT_ROLE_ID',
|
|
145
146
|
'AUTH_.+_REGISTER_URL_ALLOW_LIST',
|
|
146
147
|
'AUTH_.+_ICON',
|
package/dist/flows.d.ts
CHANGED
|
@@ -22,6 +22,15 @@ declare class FlowManager {
|
|
|
22
22
|
private checkManualTriggerAccess;
|
|
23
23
|
private unload;
|
|
24
24
|
private executeFlow;
|
|
25
|
+
/**
|
|
26
|
+
* Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
|
|
27
|
+
* The data is returned as is when there is nothing to conceal.
|
|
28
|
+
*/
|
|
29
|
+
private concealEventPayload;
|
|
30
|
+
/**
|
|
31
|
+
* Options are resolved against `concealedKeyedData` for the log operation and for the returned options (stored in
|
|
32
|
+
* the flow revisions), and against `keyedData` for every other operation.
|
|
33
|
+
*/
|
|
25
34
|
private executeOperation;
|
|
26
35
|
}
|
|
27
36
|
export {};
|
package/dist/flows.js
CHANGED
|
@@ -2,7 +2,7 @@ import * as sharedExceptions from '@wbce-d9/exceptions';
|
|
|
2
2
|
import { Action } from '@wbce-d9/constants';
|
|
3
3
|
import { applyOptionsData, isValidJSON, parseJSON, toArray } from '@wbce-d9/utils';
|
|
4
4
|
import fastRedact from 'fast-redact';
|
|
5
|
-
import { omit, pick } from 'lodash-es';
|
|
5
|
+
import { cloneDeep, omit, pick } from 'lodash-es';
|
|
6
6
|
import { get } from 'micromustache';
|
|
7
7
|
import { schedule, validate } from 'node-cron';
|
|
8
8
|
import getDatabase from './database/index.js';
|
|
@@ -15,6 +15,7 @@ import { getMessenger } from './messenger.js';
|
|
|
15
15
|
import { ActivityService } from './services/activity.js';
|
|
16
16
|
import * as services from './services/index.js';
|
|
17
17
|
import { FlowsService } from './services/flows.js';
|
|
18
|
+
import { PayloadService } from './services/payload.js';
|
|
18
19
|
import { RevisionsService } from './services/revisions.js';
|
|
19
20
|
import { constructFlowTree } from './utils/construct-flow-tree.js';
|
|
20
21
|
import { getSchema } from './utils/get-schema.js';
|
|
@@ -273,13 +274,22 @@ class FlowManager {
|
|
|
273
274
|
[ACCOUNTABILITY_KEY]: context?.['accountability'] ?? null,
|
|
274
275
|
[ENV_KEY]: pick(env, env['FLOWS_ENV_ALLOW_LIST'] ? toArray(env['FLOWS_ENV_ALLOW_LIST']) : []),
|
|
275
276
|
};
|
|
277
|
+
// Concealed fields of the event payload (e.g. passwords, tokens) must never end up in logs or revisions
|
|
278
|
+
const concealedData = await this.concealEventPayload(flow, data, database, schema);
|
|
279
|
+
const concealedKeyedData = {
|
|
280
|
+
...keyedData,
|
|
281
|
+
[TRIGGER_KEY]: concealedData,
|
|
282
|
+
[LAST_KEY]: concealedData,
|
|
283
|
+
};
|
|
276
284
|
let nextOperation = flow.operation;
|
|
277
285
|
let lastOperationStatus = 'unknown';
|
|
278
286
|
const steps = [];
|
|
279
287
|
while (nextOperation !== null) {
|
|
280
|
-
const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, context);
|
|
288
|
+
const { successor, data, status, options } = await this.executeOperation(nextOperation, keyedData, concealedKeyedData, context);
|
|
281
289
|
keyedData[nextOperation.key] = data;
|
|
282
290
|
keyedData[LAST_KEY] = data;
|
|
291
|
+
concealedKeyedData[nextOperation.key] = data;
|
|
292
|
+
concealedKeyedData[LAST_KEY] = data;
|
|
283
293
|
lastOperationStatus = status;
|
|
284
294
|
steps.push({ operation: nextOperation.id, key: nextOperation.key, status, options });
|
|
285
295
|
nextOperation = successor;
|
|
@@ -310,7 +320,7 @@ class FlowManager {
|
|
|
310
320
|
item: flow.id,
|
|
311
321
|
data: {
|
|
312
322
|
steps: steps,
|
|
313
|
-
data: redactLogs(omit(
|
|
323
|
+
data: redactLogs(omit(concealedKeyedData, '$accountability.permissions')), // Permissions is a ton of data, and is just a copy of what's in the directus_permissions table
|
|
314
324
|
},
|
|
315
325
|
});
|
|
316
326
|
}
|
|
@@ -326,15 +336,35 @@ class FlowManager {
|
|
|
326
336
|
}
|
|
327
337
|
return undefined;
|
|
328
338
|
}
|
|
329
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Return a copy of the event data whose payload has its concealed fields masked, as they would be on read.
|
|
341
|
+
* The data is returned as is when there is nothing to conceal.
|
|
342
|
+
*/
|
|
343
|
+
async concealEventPayload(flow, data, knex, schema) {
|
|
344
|
+
if (flow.trigger !== 'event')
|
|
345
|
+
return data;
|
|
346
|
+
const { collection, payload } = data;
|
|
347
|
+
if (!schema.collections[collection] || !payload)
|
|
348
|
+
return data;
|
|
349
|
+
const payloadService = new PayloadService(collection, { knex, schema });
|
|
350
|
+
const concealedData = cloneDeep(data);
|
|
351
|
+
concealedData.payload = await payloadService.processValues('read', concealedData.payload);
|
|
352
|
+
return concealedData;
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Options are resolved against `concealedKeyedData` for the log operation and for the returned options (stored in
|
|
356
|
+
* the flow revisions), and against `keyedData` for every other operation.
|
|
357
|
+
*/
|
|
358
|
+
async executeOperation(operation, keyedData, concealedKeyedData, context = {}) {
|
|
330
359
|
if (!(operation.type in this.operations)) {
|
|
331
360
|
logger.warn(`Couldn't find operation ${operation.type}`);
|
|
332
361
|
return { successor: null, status: 'unknown', data: null, options: null };
|
|
333
362
|
}
|
|
334
363
|
const handler = this.operations[operation.type];
|
|
335
364
|
const options = applyOptionsData(operation.options, keyedData);
|
|
365
|
+
const concealedOptions = applyOptionsData(operation.options, concealedKeyedData);
|
|
336
366
|
try {
|
|
337
|
-
let result = await handler(options, {
|
|
367
|
+
let result = await handler(operation.type === 'log' ? concealedOptions : options, {
|
|
338
368
|
services,
|
|
339
369
|
exceptions: { ...exceptions, ...sharedExceptions },
|
|
340
370
|
env,
|
|
@@ -352,7 +382,7 @@ class FlowManager {
|
|
|
352
382
|
if (typeof result === 'object' && result !== null) {
|
|
353
383
|
result = mapValuesDeep(result, (_, value) => (value === undefined ? null : value));
|
|
354
384
|
}
|
|
355
|
-
return { successor: operation.resolve, status: 'resolve', data: result ?? null, options };
|
|
385
|
+
return { successor: operation.resolve, status: 'resolve', data: result ?? null, options: concealedOptions };
|
|
356
386
|
}
|
|
357
387
|
catch (error) {
|
|
358
388
|
let data;
|
|
@@ -374,7 +404,7 @@ class FlowManager {
|
|
|
374
404
|
successor: operation.reject,
|
|
375
405
|
status: 'reject',
|
|
376
406
|
data,
|
|
377
|
-
options,
|
|
407
|
+
options: concealedOptions,
|
|
378
408
|
};
|
|
379
409
|
}
|
|
380
410
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/logger.js
CHANGED
|
@@ -6,17 +6,18 @@ import { URL } from 'url';
|
|
|
6
6
|
import env from './env.js';
|
|
7
7
|
import { REDACT_TEXT } from './constants.js';
|
|
8
8
|
import { getConfigFromEnv } from './utils/get-config-from-env.js';
|
|
9
|
+
const REDACTED_PATHS = ['req.headers.authorization', 'req.headers.cookie', 'req.query.access_token'];
|
|
9
10
|
const pinoOptions = {
|
|
10
11
|
level: env['LOG_LEVEL'] || 'info',
|
|
11
12
|
redact: {
|
|
12
|
-
paths:
|
|
13
|
+
paths: REDACTED_PATHS,
|
|
13
14
|
censor: REDACT_TEXT,
|
|
14
15
|
},
|
|
15
16
|
};
|
|
16
17
|
export const httpLoggerOptions = {
|
|
17
18
|
level: env['LOG_LEVEL'] || 'info',
|
|
18
19
|
redact: {
|
|
19
|
-
paths:
|
|
20
|
+
paths: REDACTED_PATHS,
|
|
20
21
|
censor: REDACT_TEXT,
|
|
21
22
|
},
|
|
22
23
|
};
|
|
@@ -43,7 +44,7 @@ if (env['LOG_STYLE'] !== 'raw') {
|
|
|
43
44
|
}
|
|
44
45
|
if (env['LOG_STYLE'] === 'raw') {
|
|
45
46
|
httpLoggerOptions.redact = {
|
|
46
|
-
paths: [
|
|
47
|
+
paths: [...REDACTED_PATHS, 'res.headers'],
|
|
47
48
|
censor: (value, pathParts) => {
|
|
48
49
|
const path = pathParts.join('.');
|
|
49
50
|
if (path === 'res.headers') {
|
|
@@ -104,6 +104,9 @@ export class GraphQLService {
|
|
|
104
104
|
return formattedResult;
|
|
105
105
|
}
|
|
106
106
|
getSchema(type = 'schema') {
|
|
107
|
+
if (type === 'sdl' && env['GRAPHQL_INTROSPECTION'] === false) {
|
|
108
|
+
throw new ForbiddenException();
|
|
109
|
+
}
|
|
107
110
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
108
111
|
const self = this;
|
|
109
112
|
const schemaComposer = new SchemaComposer();
|
package/dist/services/payload.js
CHANGED
|
@@ -3,7 +3,7 @@ import { format, isValid, parseISO } from 'date-fns';
|
|
|
3
3
|
import flat from 'flat';
|
|
4
4
|
import Joi from 'joi';
|
|
5
5
|
import { clone, cloneDeep, isNil, isObject, isPlainObject, omit, pick } from 'lodash-es';
|
|
6
|
-
import { v4 as uuid } from 'uuid';
|
|
6
|
+
import { v4 as uuid, validate as validateUUID } from 'uuid';
|
|
7
7
|
import { parse as wktToGeoJSON } from 'wellknown';
|
|
8
8
|
import { getHelpers } from '../database/helpers/index.js';
|
|
9
9
|
import getDatabase from '../database/index.js';
|
|
@@ -44,7 +44,10 @@ export class PayloadService {
|
|
|
44
44
|
if (action === 'create' && !value) {
|
|
45
45
|
return uuid();
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
if (action === 'create' && !validateUUID(String(value))) {
|
|
48
|
+
throw new InvalidPayloadException(`Invalid UUID "${value}"`);
|
|
49
|
+
}
|
|
50
|
+
return action === 'create' ? String(value).toLowerCase() : value;
|
|
48
51
|
},
|
|
49
52
|
async 'cast-boolean'({ action, value }) {
|
|
50
53
|
if (action === 'read') {
|
package/dist/services/users.js
CHANGED
|
@@ -5,6 +5,7 @@ import { cloneDeep, isEmpty } from 'lodash-es';
|
|
|
5
5
|
import { performance } from 'perf_hooks';
|
|
6
6
|
import getDatabase from '../database/index.js';
|
|
7
7
|
import env from '../env.js';
|
|
8
|
+
import logger from '../logger.js';
|
|
8
9
|
import { RecordNotUniqueException } from '../exceptions/database/record-not-unique.js';
|
|
9
10
|
import { ForbiddenException, InvalidPayloadException, UnprocessableEntityException } from '../exceptions/index.js';
|
|
10
11
|
import isUrlAllowed from '../utils/is-url-allowed.js';
|
|
@@ -340,37 +341,42 @@ export class UsersService extends ItemsService {
|
|
|
340
341
|
async requestPasswordReset(email, url, subject) {
|
|
341
342
|
const STALL_TIME = 500;
|
|
342
343
|
const timeStart = performance.now();
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
344
|
+
try {
|
|
345
|
+
if (url && isUrlAllowed(url, env['PASSWORD_RESET_URL_ALLOW_LIST']) === false) {
|
|
346
|
+
throw new InvalidPayloadException(`Url "${url}" can't be used to reset passwords.`);
|
|
347
|
+
}
|
|
348
|
+
const user = await this.getUserByEmail(email);
|
|
349
|
+
if (user?.status !== 'active') {
|
|
350
|
+
throw new ForbiddenException();
|
|
351
|
+
}
|
|
352
|
+
const mailService = new MailService({
|
|
353
|
+
schema: this.schema,
|
|
354
|
+
knex: this.knex,
|
|
355
|
+
accountability: this.accountability,
|
|
356
|
+
});
|
|
357
|
+
const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
|
|
358
|
+
const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
|
|
359
|
+
const acceptURL = url
|
|
360
|
+
? new Url(url).setQuery('token', token).toString()
|
|
361
|
+
: new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
|
|
362
|
+
const subjectLine = subject ? subject : 'Password Reset Request';
|
|
363
|
+
mailService
|
|
364
|
+
.send({
|
|
365
|
+
to: user.email,
|
|
366
|
+
subject: subjectLine,
|
|
367
|
+
template: {
|
|
368
|
+
name: 'password-reset',
|
|
369
|
+
data: {
|
|
370
|
+
url: acceptURL,
|
|
371
|
+
email: user.email,
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
.catch((err) => logger.warn(err, `[email] ${err}`));
|
|
347
376
|
}
|
|
348
|
-
|
|
349
|
-
|
|
377
|
+
finally {
|
|
378
|
+
await stall(STALL_TIME, timeStart);
|
|
350
379
|
}
|
|
351
|
-
const mailService = new MailService({
|
|
352
|
-
schema: this.schema,
|
|
353
|
-
knex: this.knex,
|
|
354
|
-
accountability: this.accountability,
|
|
355
|
-
});
|
|
356
|
-
const payload = { email: user.email, scope: 'password-reset', hash: getSimpleHash('' + user.password) };
|
|
357
|
-
const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
|
|
358
|
-
const acceptURL = url
|
|
359
|
-
? new Url(url).setQuery('token', token).toString()
|
|
360
|
-
: new Url(env['PUBLIC_URL']).addPath('admin', 'reset-password').setQuery('token', token).toString();
|
|
361
|
-
const subjectLine = subject ? subject : 'Password Reset Request';
|
|
362
|
-
await mailService.send({
|
|
363
|
-
to: user.email,
|
|
364
|
-
subject: subjectLine,
|
|
365
|
-
template: {
|
|
366
|
-
name: 'password-reset',
|
|
367
|
-
data: {
|
|
368
|
-
url: acceptURL,
|
|
369
|
-
email: user.email,
|
|
370
|
-
},
|
|
371
|
-
},
|
|
372
|
-
});
|
|
373
|
-
await stall(STALL_TIME, timeStart);
|
|
374
380
|
}
|
|
375
381
|
async resetPassword(token, password) {
|
|
376
382
|
const { email, scope, hash } = jwt.verify(token, env['SECRET'], { issuer: 'directus' });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wbce-d9/api",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.7.0",
|
|
4
4
|
"description": "Directus is a real-time API and App dashboard for managing SQL database content",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -144,20 +144,20 @@
|
|
|
144
144
|
"tmp-promise": "3.0.3",
|
|
145
145
|
"tsx": "3.12.6",
|
|
146
146
|
"uuid-validate": "0.0.3",
|
|
147
|
-
"@wbce-d9/app": "11.
|
|
148
|
-
"@wbce-d9/
|
|
149
|
-
"@wbce-d9/exceptions": "10.0.1",
|
|
147
|
+
"@wbce-d9/app": "11.2.0",
|
|
148
|
+
"@wbce-d9/extensions-sdk": "10.0.5",
|
|
150
149
|
"@wbce-d9/schema": "10.0.0",
|
|
151
|
-
"@wbce-d9/storage": "10.0.0",
|
|
152
150
|
"@wbce-d9/specs": "9.26.5",
|
|
153
151
|
"@wbce-d9/storage-driver-azure": "10.0.4",
|
|
154
152
|
"@wbce-d9/storage-driver-cloudinary": "10.0.5",
|
|
155
|
-
"@wbce-d9/extensions-sdk": "10.0.5",
|
|
156
153
|
"@wbce-d9/storage-driver-local": "10.0.4",
|
|
157
154
|
"@wbce-d9/storage-driver-gcs": "10.0.4",
|
|
158
155
|
"@wbce-d9/storage-driver-s3": "10.0.4",
|
|
159
156
|
"@wbce-d9/update-check": "10.0.4",
|
|
160
|
-
"@wbce-d9/
|
|
157
|
+
"@wbce-d9/storage": "10.0.0",
|
|
158
|
+
"@wbce-d9/utils": "10.0.4",
|
|
159
|
+
"@wbce-d9/constants": "10.0.0",
|
|
160
|
+
"@wbce-d9/exceptions": "10.0.1"
|
|
161
161
|
},
|
|
162
162
|
"devDependencies": {
|
|
163
163
|
"@ngneat/falso": "6.4.0",
|