ghost 6.0.6 → 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.
Files changed (43) hide show
  1. package/components/tryghost-i18n-6.0.7.tgz +0 -0
  2. package/core/built/admin/assets/admin-x-activitypub/admin-x-activitypub.js +1 -1
  3. package/core/built/admin/assets/admin-x-activitypub/{index-BRzGrD-C.mjs → index-B-ckGCDl.mjs} +19762 -16495
  4. package/core/built/admin/assets/admin-x-activitypub/{index-Co80faUx.mjs → index-C81KQoIh.mjs} +2 -2
  5. package/core/built/admin/assets/admin-x-settings/{CodeEditorView-B4W7CQcA.mjs → CodeEditorView-BfL5FINN.mjs} +2 -2
  6. package/core/built/admin/assets/admin-x-settings/admin-x-settings.js +1 -1
  7. package/core/built/admin/assets/admin-x-settings/{index-jv9DN3ZO.mjs → index-C_ZS-INP.mjs} +2 -2
  8. package/core/built/admin/assets/admin-x-settings/{index-CuwMM9FM.mjs → index-DiNZ3HQD.mjs} +12 -4
  9. package/core/built/admin/assets/admin-x-settings/{modals-CUGEPPYA.mjs → modals-BEiWgHsk.mjs} +8807 -8799
  10. package/core/built/admin/assets/{chunk.524.56bb70d3e8660d34aef1.js → chunk.524.9f989b3664418d6271a7.js} +6 -6
  11. package/core/built/admin/assets/{chunk.582.ae0341229e71a85d0b2d.js → chunk.582.2421014e45b977b43b68.js} +10 -10
  12. package/core/built/admin/assets/{ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js → ghost-182ed60de3f37fff8a40cdd65d3bd2ef.js} +25 -23
  13. package/core/built/admin/assets/{ghost-2c537ee89c36199137eafc1768fd7de8.css → ghost-a7a53bf80dc45c37ae9c174a0d02a882.css} +1 -1
  14. package/core/built/admin/assets/{ghost-dark-ad23efc1d702e3643a8ee90d089df5d6.css → ghost-dark-6e0062029f988d8676e87f22d8e7f4a3.css} +1 -1
  15. package/core/built/admin/assets/posts/posts.js +78865 -77973
  16. package/core/built/admin/assets/stats/stats.js +15467 -15442
  17. package/core/built/admin/index.html +4 -4
  18. package/core/server/data/tinybird/endpoints/api_top_utm_campaigns.pipe +31 -0
  19. package/core/server/data/tinybird/endpoints/api_top_utm_contents.pipe +31 -0
  20. package/core/server/data/tinybird/endpoints/api_top_utm_mediums.pipe +31 -0
  21. package/core/server/data/tinybird/endpoints/api_top_utm_sources.pipe +31 -0
  22. package/core/server/data/tinybird/endpoints/api_top_utm_terms.pipe +31 -0
  23. package/core/server/data/tinybird/tests/api_top_utm_campaigns.yaml +108 -0
  24. package/core/server/data/tinybird/tests/api_top_utm_contents.yaml +108 -0
  25. package/core/server/data/tinybird/tests/api_top_utm_mediums.yaml +108 -0
  26. package/core/server/data/tinybird/tests/api_top_utm_sources.yaml +108 -0
  27. package/core/server/data/tinybird/tests/api_top_utm_terms.yaml +108 -0
  28. package/core/server/services/lib/magic-link/MagicLink.js +17 -11
  29. package/core/server/services/members/MembersConfigProvider.js +11 -1
  30. package/core/server/services/members/SingleUseTokenProvider.js +159 -6
  31. package/core/server/services/members/api.js +1 -1
  32. package/core/server/services/members/emails/signin.js +8 -5
  33. package/core/server/services/members/members-api/controllers/RouterController.js +95 -19
  34. package/core/server/services/members/members-api/members-api.js +8 -4
  35. package/core/server/services/members/members-ssr.js +5 -3
  36. package/core/server/services/members/middleware.js +2 -2
  37. package/core/server/web/members/app.js +12 -3
  38. package/core/shared/config/defaults.json +1 -1
  39. package/core/shared/labs.js +3 -1
  40. package/package.json +6 -6
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/yarn.lock +57 -63
  43. package/components/tryghost-i18n-6.0.6.tgz +0 -0
@@ -0,0 +1,108 @@
1
+
2
+ - name: Date range
3
+ description: All fixture data
4
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC
5
+ expected_result: |
6
+ {"utm_term":"ghost cms","visits":6}
7
+ {"utm_term":"blog software","visits":3}
8
+ {"utm_term":"content management","visits":3}
9
+ {"utm_term":"newsletter platform","visits":3}
10
+ {"utm_term":"membership site","visits":1}
11
+
12
+ - name: Filtered by browser - Chrome
13
+ description: Filtered by browser - Chrome
14
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&browser=chrome
15
+ expected_result: |
16
+ {"utm_term":"ghost cms","visits":4}
17
+ {"utm_term":"blog software","visits":1}
18
+ {"utm_term":"content management","visits":1}
19
+ {"utm_term":"newsletter platform","visits":1}
20
+
21
+ - name: Filtered by device - desktop
22
+ description: Filtered by device - desktop
23
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop
24
+ expected_result: |
25
+ {"utm_term":"ghost cms","visits":6}
26
+ {"utm_term":"blog software","visits":3}
27
+ {"utm_term":"content management","visits":3}
28
+ {"utm_term":"newsletter platform","visits":2}
29
+ {"utm_term":"membership site","visits":1}
30
+
31
+ - name: Filtered by location - UK
32
+ description: Filtered by location - UK
33
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&location=GB
34
+ expected_result: |
35
+ {"utm_term":"ghost cms","visits":3}
36
+ {"utm_term":"blog software","visits":2}
37
+ {"utm_term":"newsletter platform","visits":2}
38
+ {"utm_term":"content management","visits":1}
39
+
40
+ - name: Filtered by OS - Windows
41
+ description: Filtered by OS - Windows
42
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&os=windows
43
+ expected_result: |
44
+ {"utm_term":"ghost cms","visits":5}
45
+ {"utm_term":"blog software","visits":3}
46
+ {"utm_term":"content management","visits":3}
47
+ {"utm_term":"newsletter platform","visits":2}
48
+ {"utm_term":"membership site","visits":1}
49
+
50
+ - name: Filtered by pathname - /about/
51
+ description: Filtered by pathname - /about/
52
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&pathname=%2Fabout%2F
53
+ expected_result: |
54
+ {"utm_term":"ghost cms","visits":4}
55
+ {"utm_term":"content management","visits":2}
56
+ {"utm_term":"blog software","visits":1}
57
+ {"utm_term":"newsletter platform","visits":1}
58
+
59
+ - name: Filtered by post_uuid - 06b1b0c9-fb53-4a15-a060-3db3fde7b1fc (/about/)
60
+ description: Filtered by post_uuid - 06b1b0c9-fb53-4a15-a060-3db3fde7b1fc (/about/)
61
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&post_uuid=06b1b0c9-fb53-4a15-a060-3db3fde7b1fc
62
+ expected_result: |
63
+ {"utm_term":"ghost cms","visits":4}
64
+ {"utm_term":"content management","visits":2}
65
+ {"utm_term":"blog software","visits":1}
66
+ {"utm_term":"newsletter platform","visits":1}
67
+
68
+ - name: Filtered by source - bing.com
69
+ description: Filtered by source - bing.com
70
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&source=bing.com
71
+ expected_result: |
72
+ {"utm_term":"content management","visits":2}
73
+
74
+ - name: Filtered by member status - paid
75
+ description: Filtered by member status - paid
76
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&member_status=paid
77
+ expected_result: |
78
+ {"utm_term":"blog software","visits":2}
79
+ {"utm_term":"ghost cms","visits":2}
80
+ {"utm_term":"content management","visits":1}
81
+
82
+ - name: Filtered by member status - undefined
83
+ description: Filtered by member status - undefined
84
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&member_status=undefined
85
+ expected_result: |
86
+ {"utm_term":"newsletter platform","visits":3}
87
+ {"utm_term":"blog software","visits":1}
88
+ {"utm_term":"membership site","visits":1}
89
+ {"utm_term":"ghost cms","visits":1}
90
+
91
+ - name: Filtered by timezone - America/Los_Angeles
92
+ description: Filtered by timezone - America/Los_Angeles
93
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=America/Los_Angeles
94
+ expected_result: |
95
+ {"utm_term":"ghost cms","visits":5}
96
+ {"utm_term":"blog software","visits":3}
97
+ {"utm_term":"content management","visits":2}
98
+ {"utm_term":"newsletter platform","visits":2}
99
+ {"utm_term":"membership site","visits":1}
100
+
101
+ - name: Test with multiple filters combined
102
+ description: Test with multiple filters combined
103
+ parameters: site_uuid=mock_site_uuid&date_from=2100-01-01&date_to=2100-01-07&timezone=Etc/UTC&device=desktop&browser=firefox
104
+ expected_result: |
105
+ {"utm_term":"blog software","visits":2}
106
+ {"utm_term":"membership site","visits":1}
107
+ {"utm_term":"content management","visits":1}
108
+ {"utm_term":"newsletter platform","visits":1}
@@ -12,14 +12,19 @@ const messages = {
12
12
  * @typedef { string } URL
13
13
  */
14
14
 
15
+ /**
16
+ * @typedef {Object} TokenValidateOptions
17
+ * @prop {string} [otcVerification] - "timestamp:hash" string used to verify an OTC-bound token
18
+ */
19
+
15
20
  /**
16
21
  * @template T
17
22
  * @template D
18
23
  * @typedef {Object} TokenProvider<T, D>
19
24
  * @prop {(data: D) => Promise<T>} create
20
- * @prop {(token: T) => Promise<D>} validate
25
+ * @prop {(token: T, options?: TokenValidateOptions) => Promise<D>} validate
21
26
  * @prop {(token: T) => Promise<string | null>} [getIdByToken]
22
- * @prop {(tokenId: string, tokenValue: T) => string} [deriveOTC]
27
+ * @prop {(otcRef: string, tokenValue: T) => string} [deriveOTC]
23
28
  */
24
29
 
25
30
  /**
@@ -32,7 +37,7 @@ class MagicLink {
32
37
  * @param {object} options
33
38
  * @param {MailTransporter} options.transporter
34
39
  * @param {TokenProvider<Token, TokenData>} options.tokenProvider
35
- * @param {(token: Token, type: string, referrer?: string) => URL} options.getSigninURL
40
+ * @param {(token: Token, type: string, referrer?: string, otcVerification?: string) => URL} options.getSigninURL
36
41
  * @param {typeof defaultGetText} [options.getText]
37
42
  * @param {typeof defaultGetHTML} [options.getHTML]
38
43
  * @param {typeof defaultGetSubject} [options.getSubject]
@@ -62,7 +67,7 @@ class MagicLink {
62
67
  * @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
63
68
  * @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
64
69
  * @param {boolean} [options.includeOTC=false] - Whether to send a one-time-code in the email.
65
- * @returns {Promise<{token: Token, tokenId: string | null, info: SentMessageInfo}>}
70
+ * @returns {Promise<{token: Token, otcRef: string | null, info: SentMessageInfo}>}
66
71
  */
67
72
  async sendMagicLink(options) {
68
73
  this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options});
@@ -96,21 +101,21 @@ class MagicLink {
96
101
  html: this.getHTML(url, type, options.email, otc)
97
102
  });
98
103
 
99
- // return tokenId so we can pass it as a reference to the client so it
104
+ // return otcRef so we can pass it as a reference to the client so it
100
105
  // can pass it back as a reference when verifying the OTC. We only do
101
106
  // this if we've successfully generated an OTC to avoid clients showing
102
107
  // a token input field when the email doesn't contain an OTC
103
- let tokenId = null;
108
+ let otcRef = null;
104
109
  if (this.labsService?.isSet('membersSigninOTC') && otc) {
105
110
  try {
106
- tokenId = await this.getIdFromToken(token);
111
+ otcRef = await this.getIdFromToken(token);
107
112
  } catch (err) {
108
113
  this.sentry?.captureException?.(err);
109
- tokenId = null;
114
+ otcRef = null;
110
115
  }
111
116
  }
112
117
 
113
- return {token, tokenId, info};
118
+ return {token, otcRef, info};
114
119
  }
115
120
 
116
121
  /**
@@ -165,10 +170,11 @@ class MagicLink {
165
170
  * getDataFromToken
166
171
  *
167
172
  * @param {Token} token - The token to decode
173
+ * @param {string} [otcVerification] - Optional "timestamp:hash" to bind token usage to an OTC verification window
168
174
  * @returns {Promise<TokenData>} data - The data object associated with the magic link
169
175
  */
170
- async getDataFromToken(token) {
171
- const tokenData = await this.tokenProvider.validate(token);
176
+ async getDataFromToken(token, otcVerification) {
177
+ const tokenData = await this.tokenProvider.validate(token, {otcVerification});
172
178
  return tokenData;
173
179
  }
174
180
  }
@@ -82,7 +82,14 @@ class MembersConfigProvider {
82
82
  };
83
83
  }
84
84
 
85
- getSigninURL(token, type, referrer) {
85
+ /**
86
+ * @param {string} token
87
+ * @param {string} type - also known as "action", e.g. "signin" or "signup"
88
+ * @param {string} [referrer] - optional URL for redirecting to after signin
89
+ * @param {string} [otcVerification] - optional for verifying an OTC signin redirect
90
+ * @returns {string}
91
+ */
92
+ getSigninURL(token, type, referrer, otcVerification) {
86
93
  const siteUrl = this._urlUtils.urlFor({relativeUrl: '/members/'}, true);
87
94
  const signinURL = new URL(siteUrl);
88
95
  signinURL.searchParams.set('token', token);
@@ -90,6 +97,9 @@ class MembersConfigProvider {
90
97
  if (referrer) {
91
98
  signinURL.searchParams.set('r', referrer);
92
99
  }
100
+ if (otcVerification) {
101
+ signinURL.searchParams.set('otc_verification', otcVerification);
102
+ }
93
103
  return signinURL.toString();
94
104
  }
95
105
  }
@@ -1,8 +1,17 @@
1
1
  // @ts-check
2
2
  const {ValidationError} = require('@tryghost/errors');
3
+ const tpl = require('@tryghost/tpl');
3
4
  const crypto = require('node:crypto');
4
5
  const {hotp} = require('otplib');
5
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
+ };
14
+
6
15
  class SingleUseTokenProvider {
7
16
  /**
8
17
  * @param {Object} dependencies
@@ -43,6 +52,9 @@ class SingleUseTokenProvider {
43
52
  * If the token is invalid the returned Promise will reject.
44
53
  *
45
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
46
58
  *
47
59
  * @returns {Promise<Object<string, any>>}
48
60
  */
@@ -56,17 +68,29 @@ class SingleUseTokenProvider {
56
68
  });
57
69
  }
58
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
+
59
81
  const model = await this.model.findOne({token}, {transacting: options.transacting, forUpdate: true});
60
82
 
61
83
  if (!model) {
62
84
  throw new ValidationError({
63
- message: 'Invalid token provided'
85
+ message: tpl(messages.INVALID_TOKEN),
86
+ code: 'INVALID_TOKEN'
64
87
  });
65
88
  }
66
89
 
67
90
  if (model.get('used_count') >= this.maxUsageCount) {
68
91
  throw new ValidationError({
69
- message: 'Token expired'
92
+ message: tpl(messages.TOKEN_EXPIRED),
93
+ code: 'TOKEN_EXPIRED'
70
94
  });
71
95
  }
72
96
 
@@ -79,7 +103,8 @@ class SingleUseTokenProvider {
79
103
 
80
104
  if (timeSinceFirstUsage > this.validityPeriodAfterUsage) {
81
105
  throw new ValidationError({
82
- message: 'Token expired'
106
+ message: tpl(messages.TOKEN_EXPIRED),
107
+ code: 'TOKEN_EXPIRED'
83
108
  });
84
109
  }
85
110
  }
@@ -87,7 +112,8 @@ class SingleUseTokenProvider {
87
112
 
88
113
  if (tokenLifetimeMilliseconds > this.validityPeriod) {
89
114
  throw new ValidationError({
90
- message: 'Token expired'
115
+ message: tpl(messages.TOKEN_EXPIRED),
116
+ code: 'TOKEN_EXPIRED'
91
117
  });
92
118
  }
93
119
 
@@ -137,13 +163,15 @@ class SingleUseTokenProvider {
137
163
  deriveOTC(tokenId, tokenValue) {
138
164
  if (!this.secret) {
139
165
  throw new ValidationError({
140
- message: 'Cannot derive OTC: secret not configured'
166
+ message: tpl(messages.OTC_SECRET_NOT_CONFIGURED),
167
+ code: 'OTC_SECRET_NOT_CONFIGURED'
141
168
  });
142
169
  }
143
170
 
144
171
  if (!tokenId || !tokenValue) {
145
172
  throw new ValidationError({
146
- message: 'Cannot derive OTC: tokenId and tokenValue are required'
173
+ message: tpl(messages.DERIVE_OTC_MISSING_INPUT),
174
+ code: 'DERIVE_OTC_MISSING_INPUT'
147
175
  });
148
176
  }
149
177
 
@@ -151,6 +179,34 @@ class SingleUseTokenProvider {
151
179
  return hotp.generate(this.secret, counter);
152
180
  }
153
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
+
154
210
  /**
155
211
  * @method getIdByToken
156
212
  * Retrieves the ID associated with a given token.
@@ -166,6 +222,103 @@ class SingleUseTokenProvider {
166
222
  return null;
167
223
  }
168
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
+ }
169
322
  }
170
323
 
171
324
  module.exports = SingleUseTokenProvider;
@@ -90,7 +90,7 @@ function createApiInstance(config) {
90
90
  case 'signin':
91
91
  default:
92
92
  if (otc) {
93
- return `🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}`;
93
+ return `🔑 ${t('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, interpolation: {escapeValue: false}})}`;
94
94
  } else {
95
95
  return `🔑 ${t(`Secure sign in link for {siteTitle}`, {siteTitle, interpolation: {escapeValue: false}})}`;
96
96
  }
@@ -6,7 +6,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
6
6
  <meta name="viewport" content="width=device-width">
7
7
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
8
8
  ${otc
9
- ? `<title>🔑 ${t('Your verification code for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
9
+ ? `<title>🔑 ${t('Sign in to {siteTitle} with code {otc}', {siteTitle, otc, interpolation: {escapeValue: false}})}</title>`
10
10
  : `<title>🔑 ${t('Secure sign in link for {siteTitle}', {siteTitle, interpolation: {escapeValue: false}})}</title>`
11
11
  }
12
12
  <style>
@@ -111,7 +111,10 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
111
111
  <div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 600px; padding: 30px 20px;">
112
112
 
113
113
  <!-- START CENTERED CONTAINER -->
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;">${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
+ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
117
+ </div>
115
118
  <table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;">
116
119
 
117
120
  <!-- START MAIN CONTENT AREA -->
@@ -122,7 +125,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
122
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;">
123
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>
124
127
  ${otc ?
125
- `<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 valid for 24 hours`, {siteTitle, interpolation: {escapeValue: false}})}:</p>
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>
126
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;">
127
130
  <tbody>
128
131
  <tr>
@@ -132,7 +135,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
132
135
  </tr>
133
136
  </tbody>
134
137
  </table>
135
- <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 log in directly')}:</p>
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>
136
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;">
137
140
  <tbody>
138
141
  <tr>
@@ -148,7 +151,7 @@ module.exports = ({t, siteTitle, email, url, otc, accentColor = '#15212A', siteD
148
151
  </tr>
149
152
  </tbody>
150
153
  </table>
151
- <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('Alternatively you can copy and paste this URL to your browser')}:</p>`
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>`
152
155
  :
153
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>
154
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;">
@@ -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, autoRedirect} = req.body;
562
- let {emailType, redirect} = req.body;
583
+ const {email, honeypot} = req.body;
584
+ let {emailType} = req.body;
563
585
 
564
- let referrer = req.get('referer');
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({
@@ -628,9 +639,9 @@ module.exports = class RouterController {
628
639
  } else {
629
640
  const signIn = await this._handleSignin(req, normalizedEmail, referrer);
630
641
 
631
- if (this.labsService.isSet('membersSigninOTC') && signIn.tokenId) {
642
+ if (this.labsService.isSet('membersSigninOTC') && signIn.otcRef) {
632
643
  res.writeHead(201, {'Content-Type': 'application/json'});
633
- return res.end(JSON.stringify({otc_ref: signIn.tokenId}));
644
+ return res.end(JSON.stringify({otc_ref: signIn.otcRef}));
634
645
  }
635
646
  }
636
647
 
@@ -649,6 +660,71 @@ module.exports = class RouterController {
649
660
  }
650
661
  }
651
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
+
652
728
  async _handleSignup(req, normalizedEmail, referrer = null) {
653
729
  if (!this._allowSelfSignup()) {
654
730
  if (this._settingsCache.get('members_signup_access') === 'paid') {
@@ -684,11 +760,11 @@ module.exports = class RouterController {
684
760
  }
685
761
 
686
762
  async _handleSignin(req, normalizedEmail, referrer = null) {
687
- const {emailType, otc} = req.body;
763
+ const {emailType, includeOTC: reqIncludeOTC} = req.body;
688
764
 
689
765
  let includeOTC = false;
690
766
 
691
- if (this.labsService.isSet('membersSigninOTC') && (otc === true || otc === 'true')) {
767
+ if (this.labsService.isSet('membersSigninOTC') && (reqIncludeOTC === true || reqIncludeOTC === 'true')) {
692
768
  includeOTC = true;
693
769
  }
694
770