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.
- 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-BRzGrD-C.mjs → index-B-ckGCDl.mjs} +19762 -16495
- package/core/built/admin/assets/admin-x-activitypub/{index-Co80faUx.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.56bb70d3e8660d34aef1.js → chunk.524.9f989b3664418d6271a7.js} +6 -6
- package/core/built/admin/assets/{chunk.582.ae0341229e71a85d0b2d.js → chunk.582.2421014e45b977b43b68.js} +10 -10
- package/core/built/admin/assets/{ghost-2bcbd118a8ad45fed5401e84a7e87c9a.js → ghost-182ed60de3f37fff8a40cdd65d3bd2ef.js} +25 -23
- 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 +78865 -77973
- package/core/built/admin/assets/stats/stats.js +15467 -15442
- package/core/built/admin/index.html +4 -4
- 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/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 +17 -11
- package/core/server/services/members/MembersConfigProvider.js +11 -1
- package/core/server/services/members/SingleUseTokenProvider.js +159 -6
- package/core/server/services/members/api.js +1 -1
- package/core/server/services/members/emails/signin.js +8 -5
- package/core/server/services/members/members-api/controllers/RouterController.js +95 -19
- package/core/server/services/members/members-api/members-api.js +8 -4
- package/core/server/services/members/members-ssr.js +5 -3
- package/core/server/services/members/middleware.js +2 -2
- 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 +6 -6
- package/tsconfig.tsbuildinfo +1 -1
- package/yarn.lock +57 -63
- 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 {(
|
|
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,
|
|
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
|
|
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
|
|
108
|
+
let otcRef = null;
|
|
104
109
|
if (this.labsService?.isSet('membersSigninOTC') && otc) {
|
|
105
110
|
try {
|
|
106
|
-
|
|
111
|
+
otcRef = await this.getIdFromToken(token);
|
|
107
112
|
} catch (err) {
|
|
108
113
|
this.sentry?.captureException?.(err);
|
|
109
|
-
|
|
114
|
+
otcRef = null;
|
|
110
115
|
}
|
|
111
116
|
}
|
|
112
117
|
|
|
113
|
-
return {token,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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('
|
|
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('
|
|
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
|
+
|
|
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
|
|
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('
|
|
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
|
|
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({
|
|
@@ -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.
|
|
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.
|
|
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,
|
|
763
|
+
const {emailType, includeOTC: reqIncludeOTC} = req.body;
|
|
688
764
|
|
|
689
765
|
let includeOTC = false;
|
|
690
766
|
|
|
691
|
-
if (this.labsService.isSet('membersSigninOTC') && (
|
|
767
|
+
if (this.labsService.isSet('membersSigninOTC') && (reqIncludeOTC === true || reqIncludeOTC === 'true')) {
|
|
692
768
|
includeOTC = true;
|
|
693
769
|
}
|
|
694
770
|
|