@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
|
@@ -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
|
+
"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/
|
|
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/
|
|
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",
|