@wbce-d9/api 12.5.3 → 12.5.5

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/app.js CHANGED
@@ -186,7 +186,9 @@ export default async function createApp() {
186
186
  await emitter.emitInit('middlewares.after', { app });
187
187
  await emitter.emitInit('routes.before', { app });
188
188
  app.use('/auth', authRouter);
189
- app.use('/graphql', graphqlRouter);
189
+ if (env['SERVE_GRAPHQL']) {
190
+ app.use('/graphql', graphqlRouter);
191
+ }
190
192
  app.use('/activity', activityRouter);
191
193
  app.use('/assets', assetsRouter);
192
194
  app.use('/collections', collectionsRouter);
@@ -1,9 +1,47 @@
1
1
  import { Router } from 'express';
2
- import type { User } from '../../types/index.js';
2
+ import type { PrimaryKey, User } from '../../types/index.js';
3
3
  import { AuthDriver } from '../auth.js';
4
4
  export declare class LocalAuthDriver extends AuthDriver {
5
5
  getUserID(payload: Record<string, any>): Promise<string>;
6
6
  verify(user: User, password?: string): Promise<void>;
7
7
  login(user: User, payload: Record<string, any>): Promise<void>;
8
+ private getRegistrationService;
9
+ private getAccountByEmail;
10
+ /**
11
+ * Build the confirmation link handed to a freshly registered user.
12
+ *
13
+ * Without a `verification_url` the link points back at this provider's own verify route, so the
14
+ * endpoint works with no frontend at all. With one, the token is appended to the caller's page,
15
+ * which is expected to forward it here.
16
+ */
17
+ private registerUrl;
18
+ /**
19
+ * When was the last confirmation mail for this pending account sent, if ever.
20
+ */
21
+ private lastMailSentAt;
22
+ /**
23
+ * Public self-service registration for this provider.
24
+ *
25
+ * The account is created `draft`, and d9 won't issue a token for a draft user, so nothing is
26
+ * reachable until the emailed link is redeemed by `verifyEmail`. An address that already has a
27
+ * non-draft account is ignored silently — the caller can't tell the two cases apart. An account
28
+ * still awaiting confirmation has its mail re-sent, which is how a client implements "resend"
29
+ * without a second route.
30
+ */
31
+ registerUser(provider: string, input: {
32
+ email: string;
33
+ password: string;
34
+ first_name?: string | null;
35
+ last_name?: string | null;
36
+ verification_url?: string | null;
37
+ }): Promise<void>;
38
+ /**
39
+ * Redeem a registration token and activate the account. Returns the user id so the route can
40
+ * redirect to it.
41
+ *
42
+ * A token whose account is no longer `draft` — a link clicked twice, or an account since
43
+ * suspended — is reported as invalid rather than replayed.
44
+ */
45
+ verifyEmail(provider: string, token: string): Promise<PrimaryKey>;
8
46
  }
9
47
  export declare function createLocalAuthRouter(provider: string): Router;
@@ -1,16 +1,33 @@
1
+ import { parseJSON } from '@wbce-d9/utils';
1
2
  import argon2 from 'argon2';
2
3
  import { Router } from 'express';
3
4
  import Joi from 'joi';
5
+ import jwt from 'jsonwebtoken';
4
6
  import { performance } from 'perf_hooks';
5
7
  import { ACCESS_COOKIE_OPTIONS, REFRESH_COOKIE_OPTIONS } from '../../constants.js';
8
+ import getDatabase from '../../database/index.js';
9
+ import emitter from '../../emitter.js';
6
10
  import env from '../../env.js';
7
- import { InvalidCredentialsException, InvalidPayloadException } from '../../exceptions/index.js';
11
+ import { ForbiddenException, InvalidCredentialsException, InvalidPayloadException, InvalidTokenException, } from '../../exceptions/index.js';
12
+ import logger from '../../logger.js';
8
13
  import { respond } from '../../middleware/respond.js';
9
14
  import { AuthenticationService } from '../../services/authentication.js';
15
+ import { MailService } from '../../services/mail/index.js';
16
+ import { UsersService } from '../../services/users.js';
10
17
  import asyncHandler from '../../utils/async-handler.js';
18
+ import { getConfigFromEnv } from '../../utils/get-config-from-env.js';
11
19
  import { getIPFromReq } from '../../utils/get-ip-from-req.js';
20
+ import isUrlAllowed from '../../utils/is-url-allowed.js';
21
+ import { verifyJWT } from '../../utils/jwt.js';
12
22
  import { stall } from '../../utils/stall.js';
23
+ import { Url } from '../../utils/url.js';
13
24
  import { AuthDriver } from '../auth.js';
25
+ /**
26
+ * Shortest gap between two confirmation mails for the same pending account. Without it, replaying
27
+ * `/register` for an address that is still awaiting confirmation would send a mail every time,
28
+ * which turns the endpoint into a way to flood a stranger's inbox.
29
+ */
30
+ const REGISTER_MAIL_INTERVAL = 60000;
14
31
  export class LocalAuthDriver extends AuthDriver {
15
32
  async getUserID(payload) {
16
33
  if (!payload['email']) {
@@ -34,9 +51,142 @@ export class LocalAuthDriver extends AuthDriver {
34
51
  async login(user, payload) {
35
52
  await this.verify(user, payload['password']);
36
53
  }
54
+ getRegistrationService() {
55
+ // Registration is requested by an anonymous visitor, so the permission checks on the write
56
+ // have nothing to check against.
57
+ return new UsersService({
58
+ knex: this.knex,
59
+ schema: this.schema,
60
+ accountability: { role: null, admin: true },
61
+ });
62
+ }
63
+ async getAccountByEmail(email) {
64
+ return this.knex
65
+ .select('id', 'status', 'provider', 'auth_data')
66
+ .from('directus_users')
67
+ .whereRaw('LOWER(??) = ?', ['email', email.toLowerCase()])
68
+ .first();
69
+ }
70
+ /**
71
+ * Build the confirmation link handed to a freshly registered user.
72
+ *
73
+ * Without a `verification_url` the link points back at this provider's own verify route, so the
74
+ * endpoint works with no frontend at all. With one, the token is appended to the caller's page,
75
+ * which is expected to forward it here.
76
+ */
77
+ registerUrl(provider, email, url) {
78
+ const payload = { email, scope: 'register', provider };
79
+ const token = jwt.sign(payload, env['SECRET'], { expiresIn: '1d', issuer: 'directus' });
80
+ const verifyUrl = url
81
+ ? new Url(url)
82
+ : new Url(env['PUBLIC_URL']).addPath('auth', 'login', provider, 'register', 'verify-email');
83
+ verifyUrl.setQuery('token', token);
84
+ return verifyUrl.toString();
85
+ }
86
+ /**
87
+ * When was the last confirmation mail for this pending account sent, if ever.
88
+ */
89
+ lastMailSentAt(authData) {
90
+ let data = authData;
91
+ if (typeof data === 'string') {
92
+ try {
93
+ data = parseJSON(data);
94
+ }
95
+ catch {
96
+ return 0;
97
+ }
98
+ }
99
+ const sentAt = data?.['registerMailSentAt'];
100
+ return sentAt ? Date.parse(sentAt) || 0 : 0;
101
+ }
102
+ /**
103
+ * Public self-service registration for this provider.
104
+ *
105
+ * The account is created `draft`, and d9 won't issue a token for a draft user, so nothing is
106
+ * reachable until the emailed link is redeemed by `verifyEmail`. An address that already has a
107
+ * non-draft account is ignored silently — the caller can't tell the two cases apart. An account
108
+ * still awaiting confirmation has its mail re-sent, which is how a client implements "resend"
109
+ * without a second route.
110
+ */
111
+ async registerUser(provider, input) {
112
+ // Read here rather than held on the instance: this class is the base of the SSO drivers,
113
+ // which declare a `config` of their own.
114
+ const config = getConfigFromEnv(`AUTH_${provider.toUpperCase()}_`);
115
+ const url = input.verification_url ?? null;
116
+ if (url && isUrlAllowed(url, config['registerUrlAllowList']) === false) {
117
+ throw new InvalidPayloadException(`Url "${url}" can't be used to verify registrations.`);
118
+ }
119
+ const service = this.getRegistrationService();
120
+ const existing = await this.getAccountByEmail(input.email);
121
+ if (existing && (existing.status !== 'draft' || existing.provider.toLowerCase() !== provider.toLowerCase())) {
122
+ // Already confirmed, suspended/archived, or registered against a different provider.
123
+ // Send nothing, reveal nothing.
124
+ return;
125
+ }
126
+ if (existing && Date.now() - this.lastMailSentAt(existing.auth_data) < REGISTER_MAIL_INTERVAL) {
127
+ return;
128
+ }
129
+ // Not typed as `Partial<User>`: the type lists active|suspended|invited, while the schema also
130
+ // allows draft and archived (`system-data/fields/users.yaml:116`).
131
+ const userPayload = {
132
+ email: input.email,
133
+ password: input.password,
134
+ first_name: input.first_name ?? null,
135
+ last_name: input.last_name ?? null,
136
+ role: config['defaultRoleId'] ?? null,
137
+ status: 'draft',
138
+ // Login checks the user's provider against the one being logged in with
139
+ // (`services/authentication.ts:116`), so the column default would lock out anyone
140
+ // registered against a named local provider.
141
+ provider,
142
+ };
143
+ if (!existing) {
144
+ // Run hook so the end user has the chance to augment the
145
+ // user that is about to be created
146
+ const updatedUserPayload = await emitter.emitFilter(`auth.create`, userPayload, { identifier: input.email, provider, providerPayload: {} }, { database: getDatabase(), schema: this.schema, accountability: null });
147
+ await service.createOne(updatedUserPayload);
148
+ }
149
+ const account = existing ?? (await this.getAccountByEmail(input.email));
150
+ if (!account)
151
+ return;
152
+ const mailService = new MailService({ knex: this.knex, schema: this.schema });
153
+ await mailService.send({
154
+ to: input.email,
155
+ subject: 'Please confirm your email address',
156
+ template: {
157
+ name: 'user-registration',
158
+ data: {
159
+ url: this.registerUrl(provider, input.email, url),
160
+ email: input.email,
161
+ },
162
+ },
163
+ });
164
+ await service.updateOne(account.id, {
165
+ auth_data: JSON.stringify({ registerMailSentAt: new Date().toISOString() }),
166
+ });
167
+ }
168
+ /**
169
+ * Redeem a registration token and activate the account. Returns the user id so the route can
170
+ * redirect to it.
171
+ *
172
+ * A token whose account is no longer `draft` — a link clicked twice, or an account since
173
+ * suspended — is reported as invalid rather than replayed.
174
+ */
175
+ async verifyEmail(provider, token) {
176
+ const { email, scope, provider: tokenProvider, } = verifyJWT(token, env['SECRET']);
177
+ if (scope !== 'register' || tokenProvider !== provider)
178
+ throw new ForbiddenException();
179
+ const account = await this.getAccountByEmail(email);
180
+ if (account?.status !== 'draft' || account.provider.toLowerCase() !== provider.toLowerCase()) {
181
+ throw new InvalidTokenException('Token invalid.');
182
+ }
183
+ await this.getRegistrationService().updateOne(account.id, { status: 'active', auth_data: null });
184
+ return account.id;
185
+ }
37
186
  }
38
187
  export function createLocalAuthRouter(provider) {
39
188
  const router = Router();
189
+ const config = getConfigFromEnv(`AUTH_${provider.toUpperCase()}_`);
40
190
  const userLoginSchema = Joi.object({
41
191
  email: Joi.string().email().required(),
42
192
  password: Joi.string().required(),
@@ -80,5 +230,46 @@ export function createLocalAuthRouter(provider) {
80
230
  res.locals['payload'] = payload;
81
231
  return next();
82
232
  }), respond);
233
+ // The registration routes only exist where the provider is configured for it, so an instance
234
+ // that hasn't opted in doesn't expose them at all.
235
+ if (config['allowPublicRegistration']) {
236
+ const userRegisterSchema = Joi.object({
237
+ email: Joi.string().email().required(),
238
+ password: Joi.string().required(),
239
+ first_name: Joi.string().allow(null, ''),
240
+ last_name: Joi.string().allow(null, ''),
241
+ verification_url: Joi.string().uri(),
242
+ });
243
+ router.post('/register', asyncHandler(async (req, res) => {
244
+ const { error } = userRegisterSchema.validate(req.body);
245
+ if (error)
246
+ throw new InvalidPayloadException(error.message);
247
+ const driver = new LocalAuthDriver({ knex: getDatabase(), schema: req.schema }, config);
248
+ // Answer before doing any work. The response must not vary — in content or in timing —
249
+ // with whether the address is already registered, or the endpoint becomes a way to
250
+ // enumerate accounts.
251
+ res.status(204).end();
252
+ try {
253
+ await driver.registerUser(provider, {
254
+ email: req.body.email,
255
+ password: req.body.password,
256
+ first_name: req.body.first_name ?? null,
257
+ last_name: req.body.last_name ?? null,
258
+ verification_url: req.body.verification_url ?? null,
259
+ });
260
+ }
261
+ catch (err) {
262
+ logger.warn(err, `[register] Failed to register user for provider "${provider}"`);
263
+ }
264
+ }));
265
+ router.get('/register/verify-email', asyncHandler(async (req, res) => {
266
+ if (!req.query['token'] || typeof req.query['token'] !== 'string') {
267
+ throw new InvalidPayloadException(`"token" query parameter is required`);
268
+ }
269
+ const driver = new LocalAuthDriver({ knex: getDatabase(), schema: req.schema }, config);
270
+ const pk = await driver.verifyEmail(provider, req.query['token']);
271
+ return res.redirect(new Url(env['PUBLIC_URL']).addPath('admin', 'users', String(pk)).toString());
272
+ }));
273
+ }
83
274
  return router;
84
275
  }
@@ -1,5 +1,6 @@
1
1
  import { format } from 'date-fns';
2
2
  import { Router } from 'express';
3
+ import env from '../env.js';
3
4
  import { RouteNotFoundException } from '../exceptions/index.js';
4
5
  import { respond } from '../middleware/respond.js';
5
6
  import { ServerService } from '../services/server.js';
@@ -15,6 +16,9 @@ router.get('/specs/oas', asyncHandler(async (req, res, next) => {
15
16
  return next();
16
17
  }), respond);
17
18
  router.get('/specs/graphql/:scope?', asyncHandler(async (req, res) => {
19
+ if (env['SERVE_GRAPHQL'] === false) {
20
+ throw new RouteNotFoundException(req.path);
21
+ }
18
22
  const service = new SpecificationService({
19
23
  accountability: req.accountability,
20
24
  schema: req.schema,
package/dist/env.js CHANGED
@@ -22,6 +22,7 @@ const allowedEnvironmentVars = [
22
22
  'MAX_PAYLOAD_SIZE',
23
23
  'ROOT_REDIRECT',
24
24
  'SERVE_APP',
25
+ 'SERVE_GRAPHQL',
25
26
  'GRAPHQL_INTROSPECTION',
26
27
  'MAX_BATCH_MUTATION',
27
28
  'LOGGER_.+',
@@ -139,6 +140,7 @@ const allowedEnvironmentVars = [
139
140
  'AUTH_.+_LAST_NAME_KEY',
140
141
  'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
141
142
  'AUTH_.+_DEFAULT_ROLE_ID',
143
+ 'AUTH_.+_REGISTER_URL_ALLOW_LIST',
142
144
  'AUTH_.+_ICON',
143
145
  'AUTH_.+_LABEL',
144
146
  'AUTH_.+_PARAMS',
@@ -282,6 +284,7 @@ const defaults = {
282
284
  IP_CUSTOM_HEADER: false,
283
285
  IMPORT_IP_DENY_LIST: ['0.0.0.0', '169.254.169.254'],
284
286
  SERVE_APP: true,
287
+ SERVE_GRAPHQL: true,
285
288
  RELATIONAL_BATCH_SIZE: 25000,
286
289
  EXPORT_BATCH_SIZE: 5000,
287
290
  FILE_METADATA_ALLOW_LIST: 'ifd0.Make,ifd0.Model,exif.FNumber,exif.ExposureTime,exif.FocalLength,exif.ISO',
@@ -304,6 +307,7 @@ const typeMap = {
304
307
  CACHE_SKIP_ALLOWED: 'boolean',
305
308
  IMPORT_IP_DENY_LIST: 'array',
306
309
  FILE_METADATA_ALLOW_LIST: 'array',
310
+ SERVE_GRAPHQL: 'boolean',
307
311
  GRAPHQL_INTROSPECTION: 'boolean',
308
312
  MAX_BATCH_MUTATION: 'number',
309
313
  SERVER_SHUTDOWN_TIMEOUT: 'number',
@@ -0,0 +1,23 @@
1
+ {% layout "base" %}
2
+ {% block content %}
3
+
4
+ <p>
5
+ Welcome to <i>{{ projectName }}</i>. Please click the button below to confirm this email address and activate your account:
6
+ </p>
7
+
8
+ <p style="text-align: center; padding: 20px 0;">
9
+ <a href="{{ url }}">
10
+ <b>Confirm your email</b>
11
+ </a>
12
+ </p>
13
+
14
+ <p>
15
+ If you didn't create this account, you can safely ignore this email.
16
+ </p>
17
+
18
+ <p>
19
+ Thank you,<br>
20
+ The {{ projectName }} Team
21
+ </p>
22
+
23
+ {% endblock %}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbce-d9/api",
3
- "version": "12.5.3",
3
+ "version": "12.5.5",
4
4
  "description": "Directus is a real-time API and App dashboard for managing SQL database content",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -143,20 +143,20 @@
143
143
  "tmp-promise": "3.0.3",
144
144
  "tsx": "3.12.6",
145
145
  "uuid-validate": "0.0.3",
146
- "@wbce-d9/app": "10.1.1",
147
- "@wbce-d9/constants": "10.0.0",
148
- "@wbce-d9/extensions-sdk": "10.0.3",
149
146
  "@wbce-d9/exceptions": "10.0.0",
150
147
  "@wbce-d9/schema": "10.0.0",
151
148
  "@wbce-d9/specs": "9.26.5",
152
149
  "@wbce-d9/storage": "10.0.0",
150
+ "@wbce-d9/constants": "10.0.0",
153
151
  "@wbce-d9/storage-driver-azure": "10.0.2",
154
152
  "@wbce-d9/storage-driver-cloudinary": "10.0.3",
155
153
  "@wbce-d9/storage-driver-local": "10.0.2",
156
154
  "@wbce-d9/storage-driver-gcs": "10.0.2",
157
- "@wbce-d9/storage-driver-s3": "10.0.2",
158
155
  "@wbce-d9/update-check": "10.0.3",
159
- "@wbce-d9/utils": "10.0.2"
156
+ "@wbce-d9/storage-driver-s3": "10.0.2",
157
+ "@wbce-d9/utils": "10.0.2",
158
+ "@wbce-d9/extensions-sdk": "10.0.3",
159
+ "@wbce-d9/app": "10.1.2"
160
160
  },
161
161
  "devDependencies": {
162
162
  "@ngneat/falso": "6.4.0",