@wbce-d9/api 12.5.4 → 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.
@@ -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
  }
package/dist/env.js CHANGED
@@ -140,6 +140,7 @@ const allowedEnvironmentVars = [
140
140
  'AUTH_.+_LAST_NAME_KEY',
141
141
  'AUTH_.+_ALLOW_PUBLIC_REGISTRATION',
142
142
  'AUTH_.+_DEFAULT_ROLE_ID',
143
+ 'AUTH_.+_REGISTER_URL_ALLOW_LIST',
143
144
  'AUTH_.+_ICON',
144
145
  'AUTH_.+_LABEL',
145
146
  'AUTH_.+_PARAMS',
@@ -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.4",
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/extensions-sdk": "10.0.3",
148
- "@wbce-d9/constants": "10.0.0",
146
+ "@wbce-d9/exceptions": "10.0.0",
149
147
  "@wbce-d9/schema": "10.0.0",
150
148
  "@wbce-d9/specs": "9.26.5",
151
- "@wbce-d9/exceptions": "10.0.0",
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",
152
+ "@wbce-d9/storage-driver-cloudinary": "10.0.3",
154
153
  "@wbce-d9/storage-driver-local": "10.0.2",
155
- "@wbce-d9/storage-driver-s3": "10.0.2",
156
154
  "@wbce-d9/storage-driver-gcs": "10.0.2",
157
155
  "@wbce-d9/update-check": "10.0.3",
156
+ "@wbce-d9/storage-driver-s3": "10.0.2",
158
157
  "@wbce-d9/utils": "10.0.2",
159
- "@wbce-d9/storage-driver-cloudinary": "10.0.3"
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",