ghost 6.0.5 → 6.0.7
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/components/tryghost-i18n-6.0.7.tgz +0 -0
- package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +1 -1
- package/core/built/admin/assets/admin-x-activitypub/{index-DBxGycCG.mjs → index-B-ckGCDl.mjs} +20083 -16775
- package/core/built/admin/assets/admin-x-activitypub/{index-Db9aLAi4.mjs → index-C81KQoIh.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{CodeEditorView-B4W7CQcA.mjs → CodeEditorView-BfL5FINN.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
- package/core/built/admin/assets/admin-x-settings/{index-jv9DN3ZO.mjs → index-C_ZS-INP.mjs} +2 -2
- package/core/built/admin/assets/admin-x-settings/{index-CuwMM9FM.mjs → index-DiNZ3HQD.mjs} +12 -4
- package/core/built/admin/assets/admin-x-settings/{modals-CUGEPPYA.mjs → modals-BEiWgHsk.mjs} +8807 -8799
- package/core/built/admin/assets/{chunk.524.0e2bff9b664f925d7af7.js → chunk.524.9f989b3664418d6271a7.js} +6 -6
- package/core/built/admin/assets/{chunk.582.a8f6c436bbec6f9ba678.js → chunk.582.2421014e45b977b43b68.js} +8 -8
- package/core/built/admin/assets/{ghost-2b02de85a93ec9a5623180b373cece35.js → ghost-182ed60de3f37fff8a40cdd65d3bd2ef.js} +55 -50
- package/core/built/admin/assets/{ghost-2c537ee89c36199137eafc1768fd7de8.css → ghost-a7a53bf80dc45c37ae9c174a0d02a882.css} +1 -1
- package/core/built/admin/assets/{ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css → ghost-dark-6e0062029f988d8676e87f22d8e7f4a3.css} +1 -1
- package/core/built/admin/assets/posts/posts.js +78821 -77904
- package/core/built/admin/assets/stats/stats.js +21689 -21645
- package/core/built/admin/index.html +4 -4
- package/core/server/data/tinybird/datasources/analytics_events.datasource +3 -2
- package/core/server/data/tinybird/datasources/analytics_events_test.datasource +3 -2
- package/core/server/data/tinybird/endpoints/api_top_utm_campaigns.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_contents.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_mediums.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_sources.pipe +31 -0
- package/core/server/data/tinybird/endpoints/api_top_utm_terms.pipe +31 -0
- package/core/server/data/tinybird/scripts/configure-ghost.sh +4 -2
- package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +108 -0
- package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +108 -0
- package/core/server/services/lib/magic-link/MagicLink.js +98 -12
- package/core/server/services/members/MembersConfigProvider.js +11 -1
- package/core/server/services/members/SingleUseTokenProvider.js +218 -5
- package/core/server/services/members/api.js +18 -8
- package/core/server/services/members/emails/signin.js +42 -5
- package/core/server/services/members/members-api/controllers/RouterController.js +105 -18
- package/core/server/services/members/members-api/members-api.js +12 -7
- package/core/server/services/members/members-ssr.js +5 -3
- package/core/server/services/members/middleware.js +2 -2
- package/core/server/services/stats/PostsStatsService.js +26 -9
- package/core/server/services/stats/StatsService.js +1 -1
- package/core/server/web/members/app.js +12 -3
- package/core/shared/config/defaults.json +1 -1
- package/core/shared/labs.js +3 -1
- package/package.json +9 -9
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +213 -323
- package/components/tryghost-i18n-6.0.5.tgz +0 -0
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
const {ValidationError} = require('@tryghost/errors');
|
|
3
|
+
const tpl = require('@tryghost/tpl');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const {hotp} = require('otplib');
|
|
6
|
+
|
|
7
|
+
const messages = {
|
|
8
|
+
OTC_SECRET_NOT_CONFIGURED: 'OTC secret not configured',
|
|
9
|
+
INVALID_OTC_VERIFICATION_HASH: 'Invalid OTC verification hash',
|
|
10
|
+
INVALID_TOKEN: 'Invalid token provided',
|
|
11
|
+
TOKEN_EXPIRED: 'Token expired',
|
|
12
|
+
DERIVE_OTC_MISSING_INPUT: 'tokenId and tokenValue are required'
|
|
13
|
+
};
|
|
3
14
|
|
|
4
15
|
class SingleUseTokenProvider {
|
|
5
16
|
/**
|
|
@@ -8,12 +19,14 @@ class SingleUseTokenProvider {
|
|
|
8
19
|
* @param {number} dependencies.validityPeriod - How long a token is valid for from it's creation in milliseconds.
|
|
9
20
|
* @param {number} dependencies.validityPeriodAfterUsage - How long a token is valid after first usage, in milliseconds.
|
|
10
21
|
* @param {number} dependencies.maxUsageCount - How many times a token can be used.
|
|
22
|
+
* @param {string} [dependencies.secret] - Secret for generating and verifying OTP codes.
|
|
11
23
|
*/
|
|
12
|
-
constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount}) {
|
|
24
|
+
constructor({SingleUseTokenModel, validityPeriod, validityPeriodAfterUsage, maxUsageCount, secret}) {
|
|
13
25
|
this.model = SingleUseTokenModel;
|
|
14
26
|
this.validityPeriod = validityPeriod;
|
|
15
27
|
this.validityPeriodAfterUsage = validityPeriodAfterUsage;
|
|
16
28
|
this.maxUsageCount = maxUsageCount;
|
|
29
|
+
this.secret = secret;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
/**
|
|
@@ -39,6 +52,9 @@ class SingleUseTokenProvider {
|
|
|
39
52
|
* If the token is invalid the returned Promise will reject.
|
|
40
53
|
*
|
|
41
54
|
* @param {string} token
|
|
55
|
+
* @param {Object} [options] - Optional configuration object
|
|
56
|
+
* @param {Object} [options.transacting] - Database transaction object
|
|
57
|
+
* @param {string} [options.otcVerification] - OTC verification hash for additional validation
|
|
42
58
|
*
|
|
43
59
|
* @returns {Promise<Object<string, any>>}
|
|
44
60
|
*/
|
|
@@ -52,17 +68,29 @@ class SingleUseTokenProvider {
|
|
|
52
68
|
});
|
|
53
69
|
}
|
|
54
70
|
|
|
71
|
+
if (options.otcVerification) {
|
|
72
|
+
const isValidOTCVerification = await this._validateOTCVerificationHash(options.otcVerification, token);
|
|
73
|
+
if (!isValidOTCVerification) {
|
|
74
|
+
throw new ValidationError({
|
|
75
|
+
message: tpl(messages.INVALID_OTC_VERIFICATION_HASH),
|
|
76
|
+
code: 'INVALID_OTC_VERIFICATION_HASH'
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
55
81
|
const model = await this.model.findOne({token}, {transacting: options.transacting, forUpdate: true});
|
|
56
82
|
|
|
57
83
|
if (!model) {
|
|
58
84
|
throw new ValidationError({
|
|
59
|
-
message:
|
|
85
|
+
message: tpl(messages.INVALID_TOKEN),
|
|
86
|
+
code: 'INVALID_TOKEN'
|
|
60
87
|
});
|
|
61
88
|
}
|
|
62
89
|
|
|
63
90
|
if (model.get('used_count') >= this.maxUsageCount) {
|
|
64
91
|
throw new ValidationError({
|
|
65
|
-
message:
|
|
92
|
+
message: tpl(messages.TOKEN_EXPIRED),
|
|
93
|
+
code: 'TOKEN_EXPIRED'
|
|
66
94
|
});
|
|
67
95
|
}
|
|
68
96
|
|
|
@@ -75,7 +103,8 @@ class SingleUseTokenProvider {
|
|
|
75
103
|
|
|
76
104
|
if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
|
|
77
105
|
throw new ValidationError({
|
|
78
|
-
message:
|
|
106
|
+
message: tpl(messages.TOKEN_EXPIRED),
|
|
107
|
+
code: 'TOKEN_EXPIRED'
|
|
79
108
|
});
|
|
80
109
|
}
|
|
81
110
|
}
|
|
@@ -83,7 +112,8 @@ class SingleUseTokenProvider {
|
|
|
83
112
|
|
|
84
113
|
if (tokenLifetimeMilliseconds > this.validityPeriod) {
|
|
85
114
|
throw new ValidationError({
|
|
86
|
-
message:
|
|
115
|
+
message: tpl(messages.TOKEN_EXPIRED),
|
|
116
|
+
code: 'TOKEN_EXPIRED'
|
|
87
117
|
});
|
|
88
118
|
}
|
|
89
119
|
|
|
@@ -106,6 +136,189 @@ class SingleUseTokenProvider {
|
|
|
106
136
|
return {};
|
|
107
137
|
}
|
|
108
138
|
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @private
|
|
142
|
+
* @method deriveCounter
|
|
143
|
+
* Derives a counter from a token ID and value
|
|
144
|
+
*
|
|
145
|
+
* @param {string} tokenId
|
|
146
|
+
* @param {string} tokenValue
|
|
147
|
+
* @returns {number}
|
|
148
|
+
*/
|
|
149
|
+
deriveCounter(tokenId, tokenValue) {
|
|
150
|
+
const msg = `${tokenId}|${tokenValue}`;
|
|
151
|
+
const digest = crypto.createHash('sha256').update(msg).digest();
|
|
152
|
+
return digest.readUInt32BE(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* @method deriveOTC
|
|
157
|
+
* Derives an OTC (one-time code) from a token ID and value
|
|
158
|
+
*
|
|
159
|
+
* @param {string} tokenId - Token ID
|
|
160
|
+
* @param {string} tokenValue - Token value
|
|
161
|
+
* @returns {string} The generated one-time code
|
|
162
|
+
*/
|
|
163
|
+
deriveOTC(tokenId, tokenValue) {
|
|
164
|
+
if (!this.secret) {
|
|
165
|
+
throw new ValidationError({
|
|
166
|
+
message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
|
|
167
|
+
code: 'OTC_SECRET_NOT_CONFIGURED'
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!tokenId || !tokenValue) {
|
|
172
|
+
throw new ValidationError({
|
|
173
|
+
message: tpl(messages.DERIVE_OTC_MISSING_INPUT),
|
|
174
|
+
code: 'DERIVE_OTC_MISSING_INPUT'
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const counter = this.deriveCounter(tokenId, tokenValue);
|
|
179
|
+
return hotp.generate(this.secret, counter);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* @method verifyOTC
|
|
184
|
+
* Verifies an OTC (one-time code) by looking up the token and performing HOTP verification
|
|
185
|
+
*
|
|
186
|
+
* @param {string} otcRef - Reference for the one-time code
|
|
187
|
+
* @param {string} otc - The one-time code to verify
|
|
188
|
+
* @returns {Promise<boolean>} Returns true if the OTC is valid, false otherwise
|
|
189
|
+
*/
|
|
190
|
+
async verifyOTC(otcRef, otc) {
|
|
191
|
+
if (!this.secret || !otcRef || !otc) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
const model = await this.model.findOne({id: otcRef});
|
|
197
|
+
|
|
198
|
+
if (!model) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const tokenValue = model.get('token');
|
|
203
|
+
const counter = this.deriveCounter(otcRef, tokenValue);
|
|
204
|
+
return hotp.verify({token: otc, secret: this.secret, counter});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* @method getIdByToken
|
|
212
|
+
* Retrieves the ID associated with a given token.
|
|
213
|
+
*
|
|
214
|
+
* @param {string} token - The token to look up.
|
|
215
|
+
* @returns {Promise<string|null>} The ID if found, or null if not found or on error.
|
|
216
|
+
*/
|
|
217
|
+
async getIdByToken(token) {
|
|
218
|
+
try {
|
|
219
|
+
const model = await this.model.findOne({token});
|
|
220
|
+
return model ? model.get('id') : null;
|
|
221
|
+
} catch (err) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @method getTokenByRef
|
|
228
|
+
* Retrieves the token associated with a given reference.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} ref - The reference to look up.
|
|
231
|
+
* @returns {Promise<string|null>} The token if found, or null if not found or on error.
|
|
232
|
+
*/
|
|
233
|
+
async getTokenByRef(ref) {
|
|
234
|
+
try {
|
|
235
|
+
const model = await this.model.findOne({id: ref});
|
|
236
|
+
return model ? model.get('token') : null;
|
|
237
|
+
} catch (err) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* @method createOTCVerificationHash
|
|
244
|
+
* Creates an OTC verification hash for a given token and one-time code.
|
|
245
|
+
*
|
|
246
|
+
* @param {string} otc - The one-time code
|
|
247
|
+
* @param {string} token - The token value
|
|
248
|
+
* @param {number} [timestamp] - Optional timestamp to use for the hash, defaults to current time
|
|
249
|
+
* @returns {string} The OTC verification hash
|
|
250
|
+
*/
|
|
251
|
+
createOTCVerificationHash(otc, token, timestamp) {
|
|
252
|
+
if (!this.secret) {
|
|
253
|
+
throw new ValidationError({
|
|
254
|
+
message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
|
|
255
|
+
code: 'OTC_SECRET_NOT_CONFIGURED'
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// timestamp allows us to restrict the hash's lifetime window
|
|
260
|
+
timestamp ??= Math.floor(Date.now() / 1000);
|
|
261
|
+
|
|
262
|
+
const dataToHash = `${otc}:${token}:${timestamp}`;
|
|
263
|
+
|
|
264
|
+
const secret = Buffer.from(this.secret, 'hex');
|
|
265
|
+
const hmac = crypto.createHmac('sha256', secret);
|
|
266
|
+
hmac.update(dataToHash);
|
|
267
|
+
|
|
268
|
+
return hmac.digest('hex');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @private
|
|
273
|
+
* @method _validateOTCVerificationHash
|
|
274
|
+
* Validates OTC verification hash by recreating and comparing the hash.
|
|
275
|
+
* Private because it's only used internally by the public validate method.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} otcVerificationHash - The hash to validate (timestamp:hash format)
|
|
278
|
+
* @param {string} token - The token value
|
|
279
|
+
* @returns {Promise<boolean>} - True if hash is valid, false otherwise
|
|
280
|
+
*/
|
|
281
|
+
async _validateOTCVerificationHash(otcVerificationHash, token) {
|
|
282
|
+
try {
|
|
283
|
+
if (!this.secret || !otcVerificationHash || !token) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Parse timestamp:hash format
|
|
288
|
+
const parts = otcVerificationHash.split(':');
|
|
289
|
+
if (parts.length !== 2) {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const timestamp = parseInt(parts[0]);
|
|
294
|
+
const providedHash = parts[1];
|
|
295
|
+
|
|
296
|
+
// Check if hash is expired (5 minute window)
|
|
297
|
+
const now = Math.floor(Date.now() / 1000);
|
|
298
|
+
const maxAge = 5 * 60; // 5 minutes in seconds
|
|
299
|
+
if (now - timestamp > maxAge) {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const tokenId = await this.getIdByToken(token);
|
|
304
|
+
if (!tokenId) {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Derive the original OTC that was used to create this hash
|
|
309
|
+
const otc = this.deriveOTC(tokenId, token);
|
|
310
|
+
|
|
311
|
+
const expectedHash = this.createOTCVerificationHash(otc, token, timestamp);
|
|
312
|
+
|
|
313
|
+
// Compare the hashes using constant-time comparison to prevent timing attacks
|
|
314
|
+
return crypto.timingSafeEqual(
|
|
315
|
+
Buffer.from(providedHash, 'hex'),
|
|
316
|
+
Buffer.from(expectedHash, 'hex')
|
|
317
|
+
);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
109
322
|
}
|
|
110
323
|
|
|
111
324
|
module.exports = SingleUseTokenProvider;
|
|
@@ -57,7 +57,8 @@ function createApiInstance(config) {
|
|
|
57
57
|
SingleUseTokenModel: models.SingleUseToken,
|
|
58
58
|
validityPeriod: MAGIC_LINK_TOKEN_VALIDITY,
|
|
59
59
|
validityPeriodAfterUsage: MAGIC_LINK_TOKEN_VALIDITY_AFTER_USAGE,
|
|
60
|
-
maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT
|
|
60
|
+
maxUsageCount: MAGIC_LINK_TOKEN_MAX_USAGE_COUNT,
|
|
61
|
+
secret: settingsCache.get('members_email_auth_secret')
|
|
61
62
|
})
|
|
62
63
|
},
|
|
63
64
|
mail: {
|
|
@@ -75,7 +76,7 @@ function createApiInstance(config) {
|
|
|
75
76
|
return ghostMailer.send(msg);
|
|
76
77
|
}
|
|
77
78
|
},
|
|
78
|
-
getSubject(type) {
|
|
79
|
+
getSubject(type, otc) {
|
|
79
80
|
const siteTitle = settingsCache.get('title');
|
|
80
81
|
switch (type) {
|
|
81
82
|
case 'subscribe':
|
|
@@ -88,10 +89,14 @@ function createApiInstance(config) {
|
|
|
88
89
|
return `📫 ${t(`Confirm your email update for {siteTitle}!`, {siteTitle, interpolation: {escapeValue: false}})}`;
|
|
89
90
|
case 'signin':
|
|
90
91
|
default:
|
|
91
|
-
|
|
92
|
+
if (otc) {
|
|
93
|
+
return `🔑 ${t('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, interpolation: {escapeValue: false}})}`;
|
|
94
|
+
} else {
|
|
95
|
+
return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
|
|
96
|
+
}
|
|
92
97
|
}
|
|
93
98
|
},
|
|
94
|
-
getText(url, type, email) {
|
|
99
|
+
getText(url, type, email, otc) {
|
|
95
100
|
const siteTitle = settingsCache.get('title');
|
|
96
101
|
switch (type) {
|
|
97
102
|
case 'subscribe':
|
|
@@ -162,10 +167,14 @@ function createApiInstance(config) {
|
|
|
162
167
|
`;
|
|
163
168
|
case 'signin':
|
|
164
169
|
default:
|
|
170
|
+
/* eslint-disable indent */
|
|
165
171
|
return trimLeadingWhitespace`
|
|
166
172
|
${t(`Hey there,`)}
|
|
167
173
|
|
|
168
|
-
${
|
|
174
|
+
${otc
|
|
175
|
+
? `${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}: ${otc}\n\n${t('Or use this link to securely sign in', {interpolation: {escapeValue: false}})}:`
|
|
176
|
+
: `${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}`
|
|
177
|
+
}
|
|
169
178
|
|
|
170
179
|
${url}
|
|
171
180
|
|
|
@@ -177,10 +186,11 @@ function createApiInstance(config) {
|
|
|
177
186
|
|
|
178
187
|
${t('Sent to {email}', {email})}
|
|
179
188
|
${t('If you did not make this request, you can safely ignore this email.')}
|
|
180
|
-
|
|
189
|
+
`;
|
|
190
|
+
/* eslint-enable indent */
|
|
181
191
|
}
|
|
182
192
|
},
|
|
183
|
-
getHTML(url, type, email) {
|
|
193
|
+
getHTML(url, type, email, otc) {
|
|
184
194
|
const siteTitle = settingsCache.get('title');
|
|
185
195
|
const siteUrl = urlUtils.urlFor('home', true);
|
|
186
196
|
const domain = urlUtils.urlFor('home', true).match(new RegExp('^https?://([^/:?#]+)(?:[/:?#]|$)', 'i'));
|
|
@@ -197,7 +207,7 @@ function createApiInstance(config) {
|
|
|
197
207
|
return updateEmail({t, url, email, siteTitle, accentColor, siteDomain, siteUrl});
|
|
198
208
|
case 'signin':
|
|
199
209
|
default:
|
|
200
|
-
return signinEmail({t, url, email, siteTitle, accentColor, siteDomain, siteUrl});
|
|
210
|
+
return signinEmail({t, url, otc, email, siteTitle, accentColor, siteDomain, siteUrl});
|
|
201
211
|
}
|
|
202
212
|
}
|
|
203
213
|
},
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
/* eslint-disable indent */
|
|
2
|
+
module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteDomain, siteUrl}) => `
|
|
2
3
|
<!doctype html>
|
|
3
4
|
<html>
|
|
4
5
|
<head>
|
|
5
6
|
<meta name="viewport" content="width=device-width">
|
|
6
7
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
|
7
|
-
|
|
8
|
+
${otc
|
|
9
|
+
? `<title>🔑 ${t('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, interpolation: {escapeValue: false}})}</title>`
|
|
10
|
+
: `<title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
|
|
11
|
+
}
|
|
8
12
|
<style>
|
|
9
13
|
/* -------------------------------------
|
|
10
14
|
RESPONSIVE AND MOBILE FRIENDLY STYLES
|
|
@@ -107,7 +111,10 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
|
|
|
107
111
|
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
|
|
108
112
|
|
|
109
113
|
<!-- START CENTERED CONTAINER -->
|
|
110
|
-
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">${t('Welcome back to {siteTitle}!', {siteTitle, interpolation: {escapeValue: false}})}</span>
|
|
114
|
+
<span class="preheader" style="color: transparent; display: none; height: 0; max-height: 0; max-width: 0; opacity: 0; overflow: hidden; mso-hide: all; visibility: hidden; width: 0;">${otc ? t('Welcome back to {siteTitle}! Your verification code is {otc}.', {siteTitle, otc, interpolation: {escapeValue: false}}) : t('Welcome back to {siteTitle}!', {siteTitle, interpolation: {escapeValue: false}})}</span>
|
|
115
|
+
<div style="display:none; max-height:0; overflow:hidden; mso-hide: all;" aria-hidden="true" role="presentation">
|
|
116
|
+
|
|
117
|
+
</div>
|
|
111
118
|
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
|
|
112
119
|
|
|
113
120
|
<!-- START MAIN CONTENT AREA -->
|
|
@@ -117,7 +124,36 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
|
|
|
117
124
|
<tr>
|
|
118
125
|
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
|
|
119
126
|
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 24px; margin: 0; margin-bottom: 15px;">${t('Hey there,')}</p>
|
|
120
|
-
|
|
127
|
+
${otc ?
|
|
128
|
+
`<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 24px;">${t(`Welcome back! Here's your code to sign in to {siteTitle}. For your security, it's only valid for 24 hours`, {siteTitle, interpolation: {escapeValue: false}})}:</p>
|
|
129
|
+
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box; margin-bottom: 32px;">
|
|
130
|
+
<tbody>
|
|
131
|
+
<tr>
|
|
132
|
+
<td style="padding: 16px; background-color: #F4F5F6; border-radius: 8px; text-align: center; vertical-align: middle;" valign="middle">
|
|
133
|
+
<h2 style="text-align: center; vertical-align: center; letter-spacing: 5px; font-size: 24px; color: #15212A; font-weight: 600; line-height: 24px; margin: 0;">${otc}</h2>
|
|
134
|
+
</td>
|
|
135
|
+
</tr>
|
|
136
|
+
</tbody>
|
|
137
|
+
</table>
|
|
138
|
+
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 24px;">${t('Or, skip the code and sign in directly')}:</p>
|
|
139
|
+
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
|
140
|
+
<tbody>
|
|
141
|
+
<tr>
|
|
142
|
+
<td align="center" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 35px;">
|
|
143
|
+
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
|
|
144
|
+
<tbody>
|
|
145
|
+
<tr>
|
|
146
|
+
<td align="center" style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: ${accentColor}; border-radius: 5px; text-align: center;"> <a href="${url}" target="_blank" style="display: inline-block; color: #ffffff; background-color: ${accentColor}; border: solid 1px ${accentColor}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: ${accentColor};">${t('Sign in now')}</a> </td>
|
|
147
|
+
</tr>
|
|
148
|
+
</tbody>
|
|
149
|
+
</table>
|
|
150
|
+
</td>
|
|
151
|
+
</tr>
|
|
152
|
+
</tbody>
|
|
153
|
+
</table>
|
|
154
|
+
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}:</p>`
|
|
155
|
+
:
|
|
156
|
+
`<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px; margin-bottom: 32px;">${t('Welcome back! Use this link to securely sign in to your {siteTitle} account:', {siteTitle, interpolation: {escapeValue: false}})}</p>
|
|
121
157
|
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
|
|
122
158
|
<tbody>
|
|
123
159
|
<tr>
|
|
@@ -136,7 +172,8 @@ module.exports = ({t, siteTitle, email, url, accentColor = '#15212A', siteDomain
|
|
|
136
172
|
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 11px;">${t('For your security, the link will expire in 24 hours time.')}</p>
|
|
137
173
|
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 30px;">${t('See you soon!')}</p>
|
|
138
174
|
<hr/>
|
|
139
|
-
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}</p
|
|
175
|
+
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; margin: 0; line-height: 24px;">${t('You can also copy & paste this URL into your browser:')}</p>`
|
|
176
|
+
}
|
|
140
177
|
<p style="word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 22px; margin-top:0; color: #3A464C;">${url}</p>
|
|
141
178
|
</td>
|
|
142
179
|
</tr>
|
|
@@ -25,9 +25,31 @@ const messages = {
|
|
|
25
25
|
invalidType: 'Invalid checkout type.',
|
|
26
26
|
notConfigured: 'This site is not accepting payments at the moment.',
|
|
27
27
|
invalidNewsletters: 'Cannot subscribe to invalid newsletters {newsletters}',
|
|
28
|
-
archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}'
|
|
28
|
+
archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}',
|
|
29
|
+
otcNotSupported: 'OTC verification not supported.',
|
|
30
|
+
invalidCode: 'Invalid verification code.',
|
|
31
|
+
failedToVerifyCode: 'Failed to verify code, please try again.'
|
|
29
32
|
};
|
|
30
33
|
|
|
34
|
+
// helper utility for logic shared between sendMagicLink and verifyOTC
|
|
35
|
+
function extractRefererOrRedirect(req) {
|
|
36
|
+
const {autoRedirect, redirect} = req.body;
|
|
37
|
+
|
|
38
|
+
if (autoRedirect === false) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (redirect) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(redirect).href;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
logging.warn(e);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return req.get('referer') || null;
|
|
51
|
+
}
|
|
52
|
+
|
|
31
53
|
module.exports = class RouterController {
|
|
32
54
|
/**
|
|
33
55
|
* RouterController
|
|
@@ -558,21 +580,10 @@ module.exports = class RouterController {
|
|
|
558
580
|
}
|
|
559
581
|
|
|
560
582
|
async sendMagicLink(req, res) {
|
|
561
|
-
const {email, honeypot
|
|
562
|
-
let {emailType
|
|
583
|
+
const {email, honeypot} = req.body;
|
|
584
|
+
let {emailType} = req.body;
|
|
563
585
|
|
|
564
|
-
|
|
565
|
-
if (autoRedirect === false){
|
|
566
|
-
referrer = null;
|
|
567
|
-
}
|
|
568
|
-
if (redirect) {
|
|
569
|
-
try {
|
|
570
|
-
// Validate URL
|
|
571
|
-
referrer = new URL(redirect).href;
|
|
572
|
-
} catch (e) {
|
|
573
|
-
logging.warn(e);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
586
|
+
const referrer = extractRefererOrRedirect(req);
|
|
576
587
|
|
|
577
588
|
if (!email) {
|
|
578
589
|
throw new errors.BadRequestError({
|
|
@@ -626,7 +637,12 @@ module.exports = class RouterController {
|
|
|
626
637
|
if (emailType === 'signup' || emailType === 'subscribe') {
|
|
627
638
|
await this._handleSignup(req, normalizedEmail, referrer);
|
|
628
639
|
} else {
|
|
629
|
-
await this._handleSignin(req, normalizedEmail, referrer);
|
|
640
|
+
const signIn = await this._handleSignin(req, normalizedEmail, referrer);
|
|
641
|
+
|
|
642
|
+
if (this.labsService.isSet('membersSigninOTC') && signIn.otcRef) {
|
|
643
|
+
res.writeHead(201, {'Content-Type': 'application/json'});
|
|
644
|
+
return res.end(JSON.stringify({otc_ref: signIn.otcRef}));
|
|
645
|
+
}
|
|
630
646
|
}
|
|
631
647
|
|
|
632
648
|
res.writeHead(201);
|
|
@@ -644,6 +660,71 @@ module.exports = class RouterController {
|
|
|
644
660
|
}
|
|
645
661
|
}
|
|
646
662
|
|
|
663
|
+
async verifyOTC(req, res) {
|
|
664
|
+
const {otc, otcRef} = req.body;
|
|
665
|
+
|
|
666
|
+
if (!otc || !otcRef) {
|
|
667
|
+
throw new errors.BadRequestError({
|
|
668
|
+
message: tpl(messages.badRequest),
|
|
669
|
+
context: 'otc and otcRef are required',
|
|
670
|
+
code: 'OTC_VERIFICATION_MISSING_PARAMS'
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const tokenProvider = this._magicLinkService.tokenProvider;
|
|
675
|
+
if (!tokenProvider || typeof tokenProvider.verifyOTC !== 'function') {
|
|
676
|
+
throw new errors.BadRequestError({
|
|
677
|
+
message: tpl(messages.otcNotSupported),
|
|
678
|
+
code: 'OTC_NOT_SUPPORTED'
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const isValidOTC = await tokenProvider.verifyOTC(otcRef, otc);
|
|
683
|
+
if (!isValidOTC) {
|
|
684
|
+
throw new errors.BadRequestError({
|
|
685
|
+
message: tpl(messages.invalidCode),
|
|
686
|
+
code: 'INVALID_OTC'
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const tokenValue = await tokenProvider.getTokenByRef(otcRef);
|
|
691
|
+
if (!tokenValue) {
|
|
692
|
+
throw new errors.BadRequestError({
|
|
693
|
+
message: tpl(messages.invalidCode),
|
|
694
|
+
code: 'INVALID_OTC_REF'
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const otcVerificationHash = await this._createHashFromOTCAndToken(otc, tokenValue);
|
|
699
|
+
if (!otcVerificationHash) {
|
|
700
|
+
throw new errors.BadRequestError({
|
|
701
|
+
message: tpl(messages.failedToVerifyCode),
|
|
702
|
+
code: 'OTC_VERIFICATION_FAILED'
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const referrer = extractRefererOrRedirect(req);
|
|
707
|
+
|
|
708
|
+
const redirectUrl = this._magicLinkService.getSigninURL(tokenValue, 'signin', referrer, otcVerificationHash);
|
|
709
|
+
if (!redirectUrl) {
|
|
710
|
+
throw new errors.BadRequestError({
|
|
711
|
+
message: tpl(messages.failedToVerifyCode),
|
|
712
|
+
code: 'OTC_VERIFICATION_FAILED'
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return res.json({redirectUrl});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
async _createHashFromOTCAndToken(otc, token) {
|
|
720
|
+
// timestamp for anti-replay protection (5 minute window)
|
|
721
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
722
|
+
|
|
723
|
+
const hash = this._magicLinkService.tokenProvider.createOTCVerificationHash(otc, token, timestamp);
|
|
724
|
+
|
|
725
|
+
return `${timestamp}:${hash}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
647
728
|
async _handleSignup(req, normalizedEmail, referrer = null) {
|
|
648
729
|
if (!this._allowSelfSignup()) {
|
|
649
730
|
if (this._settingsCache.get('members_signup_access') === 'paid') {
|
|
@@ -679,7 +760,13 @@ module.exports = class RouterController {
|
|
|
679
760
|
}
|
|
680
761
|
|
|
681
762
|
async _handleSignin(req, normalizedEmail, referrer = null) {
|
|
682
|
-
const {emailType} = req.body;
|
|
763
|
+
const {emailType, includeOTC: reqIncludeOTC} = req.body;
|
|
764
|
+
|
|
765
|
+
let includeOTC = false;
|
|
766
|
+
|
|
767
|
+
if (this.labsService.isSet('membersSigninOTC') && (reqIncludeOTC === true || reqIncludeOTC === 'true')) {
|
|
768
|
+
includeOTC = true;
|
|
769
|
+
}
|
|
683
770
|
|
|
684
771
|
const member = await this._memberRepository.get({email: normalizedEmail});
|
|
685
772
|
|
|
@@ -690,7 +777,7 @@ module.exports = class RouterController {
|
|
|
690
777
|
}
|
|
691
778
|
|
|
692
779
|
const tokenData = {};
|
|
693
|
-
return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer});
|
|
780
|
+
return await this._sendEmailWithMagicLink({email: normalizedEmail, tokenData, requestedType: emailType, referrer, includeOTC});
|
|
694
781
|
}
|
|
695
782
|
|
|
696
783
|
/**
|
|
@@ -158,7 +158,8 @@ module.exports = function MembersAPI({
|
|
|
158
158
|
getText,
|
|
159
159
|
getHTML,
|
|
160
160
|
getSubject,
|
|
161
|
-
sentry
|
|
161
|
+
sentry,
|
|
162
|
+
labsService
|
|
162
163
|
});
|
|
163
164
|
|
|
164
165
|
const paymentsService = new PaymentsService({
|
|
@@ -207,7 +208,7 @@ module.exports = function MembersAPI({
|
|
|
207
208
|
|
|
208
209
|
const users = memberRepository;
|
|
209
210
|
|
|
210
|
-
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null}) {
|
|
211
|
+
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null, includeOTC = false}) {
|
|
211
212
|
let type = requestedType;
|
|
212
213
|
if (!options.forceEmailType) {
|
|
213
214
|
const member = await users.get({email});
|
|
@@ -217,7 +218,7 @@ module.exports = function MembersAPI({
|
|
|
217
218
|
type = 'signup';
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
|
-
return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer});
|
|
221
|
+
return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer, includeOTC});
|
|
221
222
|
}
|
|
222
223
|
|
|
223
224
|
/**
|
|
@@ -234,12 +235,12 @@ module.exports = function MembersAPI({
|
|
|
234
235
|
});
|
|
235
236
|
}
|
|
236
237
|
|
|
237
|
-
async function getTokenDataFromMagicLinkToken(token) {
|
|
238
|
-
return await magicLinkService.getDataFromToken(token);
|
|
238
|
+
async function getTokenDataFromMagicLinkToken(token, otcVerification) {
|
|
239
|
+
return await magicLinkService.getDataFromToken(token, otcVerification);
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
async function getMemberDataFromMagicLinkToken(token) {
|
|
242
|
-
const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token);
|
|
242
|
+
async function getMemberDataFromMagicLinkToken(token, otcVerification) {
|
|
243
|
+
const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token, otcVerification);
|
|
243
244
|
if (!email) {
|
|
244
245
|
return null;
|
|
245
246
|
}
|
|
@@ -339,6 +340,10 @@ module.exports = function MembersAPI({
|
|
|
339
340
|
body.json(),
|
|
340
341
|
forwardError((req, res) => routerController.sendMagicLink(req, res))
|
|
341
342
|
),
|
|
343
|
+
verifyOTC: Router().use(
|
|
344
|
+
body.json(),
|
|
345
|
+
forwardError((req, res) => routerController.verifyOTC(req, res))
|
|
346
|
+
),
|
|
342
347
|
createCheckoutSession: Router().use(
|
|
343
348
|
body.json(),
|
|
344
349
|
forwardError((req, res) => routerController.createCheckoutSession(req, res))
|