diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx
index 1340e93b61..5645b3d8b1 100644
--- a/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx
+++ b/apps/admin-x-settings/src/components/settings/advanced/labs/AlphaFeatures.tsx
@@ -59,6 +59,10 @@ const features = [{
title: 'NestJS Playground',
description: 'Wires up the Ghost NestJS App to the Admin API',
flag: 'NestPlayground'
+},{
+ title: 'Prevent Member Spam Signups',
+ description: 'Enables features to help combat spam member signups',
+ flag: 'membersSpamPrevention'
}];
const AlphaFeatures: React.FC = () => {
diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js
index d508b8adc7..19db382793 100644
--- a/ghost/admin/app/services/feature.js
+++ b/ghost/admin/app/services/feature.js
@@ -81,6 +81,7 @@ export default class FeatureService extends Service {
@feature('adminXDemo') adminXDemo;
@feature('portalImprovements') portalImprovements;
@feature('onboardingChecklist') onboardingChecklist;
+ @feature('membersSpamPrevention') membersSpamPrevention;
_user = null;
diff --git a/ghost/core/core/frontend/services/routing/RouterManager.js b/ghost/core/core/frontend/services/routing/RouterManager.js
index 7960be6117..90086d2fcd 100644
--- a/ghost/core/core/frontend/services/routing/RouterManager.js
+++ b/ghost/core/core/frontend/services/routing/RouterManager.js
@@ -8,6 +8,7 @@ const PreviewRouter = require('./PreviewRouter');
const ParentRouter = require('./ParentRouter');
const EmailRouter = require('./EmailRouter');
const UnsubscribeRouter = require('./UnsubscribeRouter');
+const SubscribeRouter = require('./SubscribeRouter');
// This emits its own routing events
const events = require('../../../server/lib/common/events');
@@ -109,6 +110,10 @@ class RouterManager {
this.siteRouter.mountRouter(unsubscribeRouter.router());
this.registry.setRouter('unsubscribeRouter', unsubscribeRouter);
+ const subscribeRouter = new SubscribeRouter();
+ this.siteRouter.mountRouter(subscribeRouter.router());
+ this.registry.setRouter('subscribeRouter', subscribeRouter);
+
if (RESOURCE_CONFIG.QUERY.email) {
const emailRouter = new EmailRouter(RESOURCE_CONFIG);
this.siteRouter.mountRouter(emailRouter.router());
diff --git a/ghost/core/core/frontend/services/routing/SubscribeRouter.js b/ghost/core/core/frontend/services/routing/SubscribeRouter.js
new file mode 100644
index 0000000000..523897703f
--- /dev/null
+++ b/ghost/core/core/frontend/services/routing/SubscribeRouter.js
@@ -0,0 +1,27 @@
+const ParentRouter = require('./ParentRouter');
+const controllers = require('./controllers');
+
+/**
+ * @description Subscribe Router.
+ *
+ * "/subscribe/" -> Subscribe Router
+ */
+class SubscribeRouter extends ParentRouter {
+ constructor() {
+ super('SubscribeRouter');
+
+ // @NOTE: hardcoded, not configurable
+ this.route = {value: '/confirm_signup/'};
+ this._registerRoutes();
+ }
+
+ /**
+ * @description Register all routes of this router.
+ * @private
+ */
+ _registerRoutes() {
+ this.mountRoute(this.route.value, controllers.subscribe);
+ }
+}
+
+module.exports = SubscribeRouter;
diff --git a/ghost/core/core/frontend/services/routing/controllers/index.js b/ghost/core/core/frontend/services/routing/controllers/index.js
index a60a9c48d4..53d7b0d0c5 100644
--- a/ghost/core/core/frontend/services/routing/controllers/index.js
+++ b/ghost/core/core/frontend/services/routing/controllers/index.js
@@ -29,5 +29,9 @@ module.exports = {
get unsubscribe() {
return require('./unsubscribe');
+ },
+
+ get subscribe() {
+ return require('./subscribe');
}
};
diff --git a/ghost/core/core/frontend/services/routing/controllers/subscribe.js b/ghost/core/core/frontend/services/routing/controllers/subscribe.js
new file mode 100644
index 0000000000..6ee1d007de
--- /dev/null
+++ b/ghost/core/core/frontend/services/routing/controllers/subscribe.js
@@ -0,0 +1,35 @@
+const debug = require('@tryghost/debug')('services:routing:controllers:subscribe');
+const path = require('path');
+const fs = require('fs');
+const handlebars = require('handlebars');
+const assetHelper = require('../../../helpers/asset');
+const {settingsCache} = require('../../../services/proxy');
+
+handlebars.registerHelper('asset', assetHelper);
+
+module.exports = async function subscribeController(req, res) {
+ debug('subscribeController');
+
+ // Get the query params
+ const {query} = req;
+ const token = query.token || null;
+ const action = query.action || null;
+ const ref = query.r || null;
+
+ if (!token || !action) {
+ return res.send(404);
+ }
+
+ // Prepare context for rendering template
+ const context = {
+ token,
+ action,
+ r: ref,
+ meta_title: settingsCache.get('title'),
+ accent_color: settingsCache.get('accent_color')
+ };
+ // Compile and render the template
+ const rawTemplate = fs.readFileSync(path.resolve(path.join(__dirname, '../../../views/subscribe.hbs'))).toString();
+ const template = handlebars.compile(rawTemplate);
+ return res.send(template(context));
+};
diff --git a/ghost/core/core/frontend/views/subscribe.hbs b/ghost/core/core/frontend/views/subscribe.hbs
new file mode 100644
index 0000000000..c59cce5dee
--- /dev/null
+++ b/ghost/core/core/frontend/views/subscribe.hbs
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+ {{title}}
+
+
+
+
+
+
+
+
+
Subscribing to {{meta_title}}
+
If you are not redirected automatically, please click the "Subscribe" button below.
+
+
+
+
+
+
+
+
+
diff --git a/ghost/core/core/server/services/members/MembersConfigProvider.js b/ghost/core/core/server/services/members/MembersConfigProvider.js
index 7d592e3652..eda3d0303a 100644
--- a/ghost/core/core/server/services/members/MembersConfigProvider.js
+++ b/ghost/core/core/server/services/members/MembersConfigProvider.js
@@ -2,6 +2,7 @@ const logging = require('@tryghost/logging');
const {URL} = require('url');
const crypto = require('crypto');
const createKeypair = require('keypair');
+const labs = require('../../../shared/labs');
class MembersConfigProvider {
/**
@@ -87,7 +88,8 @@ class MembersConfigProvider {
}
getSigninURL(token, type, referrer) {
- const siteUrl = this._urlUtils.urlFor({relativeUrl: '/members/'}, true);
+ const relativeUrl = ['signup', 'subscribe'].includes(type) && labs.isSet('membersSpamPrevention') ? '/confirm_signup/' : '/members/';
+ const siteUrl = this._urlUtils.urlFor({relativeUrl}, true);
const signinURL = new URL(siteUrl);
signinURL.searchParams.set('token', token);
signinURL.searchParams.set('action', type);
diff --git a/ghost/core/core/server/web/members/app.js b/ghost/core/core/server/web/members/app.js
index 52bb8ac18e..723b361ab5 100644
--- a/ghost/core/core/server/web/members/app.js
+++ b/ghost/core/core/server/web/members/app.js
@@ -37,6 +37,9 @@ module.exports = function setupMembersApp() {
// Initializes members specific routes as well as assigns members specific data to the req/res objects
// We don't want to add global bodyParser middleware as that interferes with stripe webhook requests on - `/webhooks`.
+ // Double opt-in subscription handling
+ membersApp.post('/api/member', membersService.api.middleware.createMemberFromToken);
+
// Manage newsletter subscription via unsubscribe link
membersApp.get('/api/member/newsletters', middleware.getMemberNewsletters);
membersApp.put('/api/member/newsletters', bodyParser.json({limit: '50mb'}), middleware.updateMemberNewsletters);
diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js
index 3a708485b3..9e181a3377 100644
--- a/ghost/core/core/shared/labs.js
+++ b/ghost/core/core/shared/labs.js
@@ -50,7 +50,8 @@ const ALPHA_FEATURES = [
'lexicalIndicators',
// 'adminXOffers',
'adminXDemo',
- 'onboardingChecklist'
+ 'onboardingChecklist',
+ 'membersSpamPrevention'
];
module.exports.GA_KEYS = [...GA_FEATURES];
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap
index 7f19d6eb5a..55a9d4561b 100644
--- a/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap
+++ b/ghost/core/test/e2e-api/admin/__snapshots__/members.test.js.snap
@@ -1829,6 +1829,291 @@ Object {
}
`;
+exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 1: [body] 1`] = `
+Object {
+ "members": Array [
+ Object {
+ "attribution": Object {
+ "id": null,
+ "referrer_medium": "Ghost Admin",
+ "referrer_source": "Created manually",
+ "referrer_url": null,
+ "title": null,
+ "type": null,
+ "url": null,
+ },
+ "avatar_image": null,
+ "comped": false,
+ "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "email": "member_getting_confirmation@test.com",
+ "email_count": 0,
+ "email_open_rate": null,
+ "email_opened_count": 0,
+ "email_suppression": Object {
+ "info": null,
+ "suppressed": false,
+ },
+ "geolocation": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "labels": Array [],
+ "last_seen_at": null,
+ "name": "Send Me Confirmation",
+ "newsletters": Array [
+ Object {
+ "description": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "name": "Default Newsletter",
+ "status": "active",
+ },
+ Object {
+ "description": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "name": "Daily newsletter",
+ "status": "active",
+ },
+ ],
+ "note": null,
+ "status": "free",
+ "subscribed": true,
+ "subscriptions": Array [],
+ "tiers": Array [],
+ "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
+ "uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
+ },
+ ],
+}
+`;
+
+exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 2: [headers] 1`] = `
+Object {
+ "access-control-allow-origin": "http://127.0.0.1:2369",
+ "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
+ "content-length": "890",
+ "content-type": "application/json; charset=utf-8",
+ "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
+ "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
+ "location": Any,
+ "vary": "Accept-Version, Origin, Accept-Encoding",
+ "x-powered-by": "Express",
+}
+`;
+
+exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 3: [html 1] 1`] = `
+"
+
+
+
+
+
+ 🙌 Complete your sign up to Ghost's Test Site!
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Hey there!
+ Tap the link below to complete the signup process for Ghost's Test Site, and be automatically signed in:
+
+ For your security, the link will expire in 24 hours time.
+ See you soon!
+
+ You can also copy & paste this URL into your browser:
+ http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup
+ |
+
+
+ |
+
+
+
+
+
+
+ If you did not make this request, you can simply delete this message. You will not be signed up, and no account will be created for you.
+ |
+
+
+
+ This message was sent from 127.0.0.1 to member_getting_confirmation@test.com
+ |
+
+
+
+
+
+
+
+
+
+ |
+ |
+
+
+
+
+"
+`;
+
+exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 4: [text 1] 1`] = `
+"Hey there,
+
+Tap the link below to complete the signup process for Ghost's Test Site, and be automatically signed in:
+
+http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup
+
+For your security, the link will expire in 24 hours time.
+
+See you soon!
+
+---
+
+Sent to member_getting_confirmation@test.com
+If you did not make this request, you can simply delete this message. You will not be signed up, and no account will be created for you."
+`;
+
+exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 5: [metadata 1] 1`] = `
+Object {
+ "encoding": "base64",
+ "forceTextContent": true,
+ "from": "\\"Ghost's Test Site\\" ",
+ "generateTextFromHTML": false,
+ "replyTo": null,
+ "subject": "🙌 Complete your sign up to Ghost's Test Site!",
+ "to": "member_getting_confirmation@test.com",
+}
+`;
+
+exports[`Members API Can add and send a signup confirmation email with membersSpamPrevention enabled 6: [headers] 1`] = `
+Object {
+ "access-control-allow-origin": "http://127.0.0.1:2369",
+ "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
+ "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
+ "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
+ "vary": "Accept-Version, Origin",
+ "x-powered-by": "Express",
+}
+`;
+
exports[`Members API Can add complimentary subscription (out of date) 1: [body] 1`] = `
Object {
"members": Array [
diff --git a/ghost/core/test/e2e-api/admin/members.test.js b/ghost/core/test/e2e-api/admin/members.test.js
index c43d180f0c..0b79f36ef6 100644
--- a/ghost/core/test/e2e-api/admin/members.test.js
+++ b/ghost/core/test/e2e-api/admin/members.test.js
@@ -907,6 +907,118 @@ describe('Members API', function () {
});
it('Can add and send a signup confirmation email', async function () {
+ mockLabsDisabled('membersSpamPrevention');
+ const member = {
+ name: 'Send Me Confirmation',
+ email: 'member_getting_confirmation@test.com',
+ newsletters: [
+ newsletters[0],
+ newsletters[1]
+ ]
+ };
+
+ // Set site title to something with a special character to ensure subject line doesn't get escaped
+ // Refs https://github.com/TryGhost/Team/issues/2895
+ await agent.put('/settings/')
+ .body({
+ settings: [
+ {
+ key: 'title',
+ value: 'Ghost\'s Test Site'
+ }
+ ]
+ })
+ .expectStatus(200);
+
+ const {body} = await agent
+ .post('/members/?send_email=true&email_type=signup')
+ .body({members: [member]})
+ .expectStatus(201)
+ .matchBodySnapshot({
+ members: [
+ buildMemberWithoutIncludesSnapshot({
+ newsletters: 2
+ })
+ ]
+ })
+ .matchHeaderSnapshot({
+ 'content-version': anyContentVersion,
+ etag: anyEtag,
+ location: anyString
+ });
+
+ const newMember = body.members[0];
+
+ emailMockReceiver
+ .assertSentEmailCount(1)
+ .matchHTMLSnapshot([{
+ pattern: queryStringToken('token'),
+ replacement: 'token=REPLACED_TOKEN'
+ }])
+ .matchPlaintextSnapshot([{
+ pattern: queryStringToken('token'),
+ replacement: 'token=REPLACED_TOKEN'
+ }])
+ .matchMetadataSnapshot();
+
+ await assertMemberEvents({
+ eventType: 'MemberStatusEvent',
+ memberId: newMember.id,
+ asserts: [
+ {
+ from_status: null,
+ to_status: 'free'
+ }
+ ]
+ });
+
+ await assertMemberEvents({
+ eventType: 'MemberSubscribeEvent',
+ memberId: newMember.id,
+ asserts: [
+ {
+ subscribed: true,
+ source: 'admin',
+ newsletter_id: newsletters[0].id
+ },
+ {
+ subscribed: true,
+ source: 'admin',
+ newsletter_id: newsletters[1].id
+ }
+ ]
+ });
+
+ // @TODO: do we really need to delete this member here?
+ await agent
+ .delete(`members/${body.members[0].id}/`)
+ .matchHeaderSnapshot({
+ 'content-version': anyContentVersion,
+ etag: anyEtag
+ })
+ .expectStatus(204);
+
+ // There should be no MemberSubscribeEvent remaining.
+ await assertMemberEvents({
+ eventType: 'MemberSubscribeEvent',
+ memberId: newMember.id,
+ asserts: []
+ });
+
+ // Reset the site title to the default
+ await agent.put('/settings/')
+ .body({
+ settings: [
+ {
+ key: 'title',
+ value: 'Ghost'
+ }
+ ]
+ })
+ .expectStatus(200);
+ });
+
+ it('Can add and send a signup confirmation email with membersSpamPrevention enabled', async function () {
const member = {
name: 'Send Me Confirmation',
email: 'member_getting_confirmation@test.com',
diff --git a/ghost/core/test/e2e-api/members/send-magic-link.test.js b/ghost/core/test/e2e-api/members/send-magic-link.test.js
index 5484f3f465..877317768e 100644
--- a/ghost/core/test/e2e-api/members/send-magic-link.test.js
+++ b/ghost/core/test/e2e-api/members/send-magic-link.test.js
@@ -1,4 +1,5 @@
-const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
+const {agentProvider, mockManager, fixtureManager, matchers, resetRateLimits} = require('../../utils/e2e-framework');
+const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
const should = require('should');
const settingsCache = require('../../../core/shared/settings-cache');
const DomainEvents = require('@tryghost/domain-events');
@@ -18,7 +19,7 @@ describe('sendMagicLink', function () {
beforeEach(function () {
mockManager.mockMail();
-
+ resetRateLimits();
// Reset settings
settingsCache.set('members_signup_access', {value: 'all'});
});
@@ -174,6 +175,7 @@ describe('sendMagicLink', function () {
});
it('triggers email alert for free member signup', async function () {
+ mockLabsDisabled('membersSpamPrevention');
const email = 'newly-created-user-magic-link-test@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
@@ -211,8 +213,48 @@ describe('sendMagicLink', function () {
});
});
+ it('triggers email alert for free member signup with membersSpamPrevention enabled', async function () {
+ const email = 'newly-created-user-magic-link-test-spam@test.com';
+ await membersAgent.post('/api/send-magic-link')
+ .body({
+ email,
+ emailType: 'signup'
+ })
+ .expectEmptyBody()
+ .expectStatus(201);
+
+ // Check email is sent
+ const mail = mockManager.assert.sentEmail({
+ to: email,
+ subject: /Complete your sign up to Ghost!/
+ });
+
+ // Get link from email
+ const [url] = mail.text.match(/https?:\/\/[^\s]+/);
+ const parsed = new URL(url);
+ const token = parsed.searchParams.get('token');
+
+ // Get member data from token
+ const signinLink = await membersService.api.createMemberFromToken(token);
+
+ // Wait for the dispatched events (because this happens async)
+ await DomainEvents.allSettled();
+ // Check member alert is sent to site owners
+ mockManager.assert.sentEmail({
+ to: 'jbloggs@example.com',
+ subject: /🥳 Free member signup: newly-created-user-magic-link-test-spam@test.com/
+ });
+
+ // Check the signin link is returned correctly
+ const parsedSigninLink = new URL(signinLink);
+ const signinToken = parsedSigninLink.searchParams.get('token');
+ const action = parsedSigninLink.searchParams.get('action');
+ should(action).equal('signin');
+ should(signinToken.length).equal(32);
+ });
+
it('Converts the urlHistory to the attribution and stores it in the token', async function () {
- const email = 'newly-created-user-magic-link-test-2@test.com';
+ const email = 'newly-created-user-magic-link-test-10@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
diff --git a/ghost/core/test/e2e-api/members/signin.test.js b/ghost/core/test/e2e-api/members/signin.test.js
index aaa238bbec..34787e3b51 100644
--- a/ghost/core/test/e2e-api/members/signin.test.js
+++ b/ghost/core/test/e2e-api/members/signin.test.js
@@ -1,4 +1,5 @@
const {agentProvider, mockManager, fixtureManager, configUtils, resetRateLimits, dbUtils} = require('../../utils/e2e-framework');
+const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
const models = require('../../../core/server/models');
const assert = require('assert/strict');
require('should');
@@ -118,6 +119,7 @@ describe('Members Signin', function () {
});
it('Will create a new member on signup', async function () {
+ mockLabsDisabled('membersSpamPrevention');
const email = 'not-existent-member@test.com';
const magicLink = await membersService.api.getMagicLink(email, 'signup');
const magicLinkUrl = new URL(magicLink);
@@ -146,7 +148,23 @@ describe('Members Signin', function () {
});
});
+ it('Will not create a new member on signup if membersSpamPrevention is enabled', async function () {
+ const email = 'not-existent-member-spam@test.com';
+ const magicLink = await membersService.api.getMagicLink(email, 'signup');
+ const magicLinkUrl = new URL(magicLink);
+ const token = magicLinkUrl.searchParams.get('token');
+
+ await membersAgent.get(`/?token=${token}&action=signup`)
+ .expectStatus(302)
+ .expectHeader('Location', /success=false/);
+
+ const member = await getMemberByEmail(email, false);
+
+ assert(!member, 'Member should not have been created');
+ });
+
it('Allows a signin via a signup link', async function () {
+ mockLabsDisabled('membersSpamPrevention');
// This member should be created by the previous test
const email = 'not-existent-member@test.com';
@@ -160,6 +178,19 @@ describe('Members Signin', function () {
.expectHeader('Set-Cookie', /members-ssr.*/);
});
+ it('Allows a signin via a signup link with membersSpamPrevention enabled', async function () {
+ // This member should be created by the previous test
+ const email = 'not-existent-member@test.com';
+
+ const magicLink = await membersService.api.getMagicLink(email, 'signup');
+ const magicLinkUrl = new URL(magicLink);
+ const token = magicLinkUrl.searchParams.get('token');
+
+ await membersAgent.get(`/?token=${token}&action=signup`)
+ .expectStatus(302)
+ .expectHeader('Location', /\/welcome-free\/\?success=true&action=signup$/);
+ });
+
it('Will not create a new member on signin', async function () {
const email = 'not-existent-member-2@test.com';
const magicLink = await membersService.api.getMagicLink(email, 'signin');
@@ -193,6 +224,7 @@ describe('Members Signin', function () {
});
it('Expires a token after 10 minutes of first usage', async function () {
+ mockLabsDisabled('membersSpamPrevention');
const magicLink = await membersService.api.getMagicLink(email, 'signup');
const magicLinkUrl = new URL(magicLink);
const token = magicLinkUrl.searchParams.get('token');
@@ -246,6 +278,7 @@ describe('Members Signin', function () {
});
it('Expires a token after 3 uses', async function () {
+ mockLabsDisabled('membersSpamPrevention');
const magicLink = await membersService.api.getMagicLink(email, 'signup');
const magicLinkUrl = new URL(magicLink);
const token = magicLinkUrl.searchParams.get('token');
@@ -459,6 +492,7 @@ describe('Members Signin', function () {
});
it('Will clear rate limits for members auth', async function () {
+ mockLabsDisabled('membersSpamPrevention');
// Temporary increase the member_login rate limits to a higher number
// because other wise we would hit user enumeration rate limits (this won't get reset after a succeeded login)
// We need to do this here otherwise the middlewares are not setup correctly
@@ -548,6 +582,7 @@ describe('Members Signin', function () {
describe('Member attribution', function () {
it('Will create a member attribution if magic link contains an attribution source', async function () {
+ mockLabsDisabled('membersSpamPrevention');
const email = 'non-existent-member@test.com';
const magicLink = await membersService.api.getMagicLink(email, 'signup', {
attribution: {
diff --git a/ghost/core/test/e2e-frontend/subscribe_routes.test.js b/ghost/core/test/e2e-frontend/subscribe_routes.test.js
new file mode 100644
index 0000000000..1c90ab76ec
--- /dev/null
+++ b/ghost/core/test/e2e-frontend/subscribe_routes.test.js
@@ -0,0 +1,62 @@
+// # Frontend Route tests
+// As it stands, these tests depend on the database, and as such are integration tests.
+// Mocking out the models to not touch the DB would turn these into unit tests, and should probably be done in future,
+// But then again testing real code, rather than mock code, might be more useful...
+const should = require('should');
+
+const sinon = require('sinon');
+const supertest = require('supertest');
+const cheerio = require('cheerio');
+const testUtils = require('../utils');
+const config = require('../../core/shared/config');
+let request;
+
+function assertCorrectFrontendHeaders(res) {
+ should.not.exist(res.headers['x-cache-invalidate']);
+ should.not.exist(res.headers['X-CSRF-Token']);
+ should.not.exist(res.headers['set-cookie']);
+ should.exist(res.headers.date);
+}
+
+describe('Frontend Routing: Subscribe Routes', function () {
+ afterEach(function () {
+ sinon.restore();
+ });
+
+ before(async function () {
+ await testUtils.startGhost();
+ request = supertest.agent(config.get('url'));
+ });
+
+ after(async function () {
+ await testUtils.stopGhost();
+ });
+
+ it('should return 404 if no token or action parameter is provided', async function () {
+ await request.get('/confirm_signup/?action=signup')
+ .expect(404);
+
+ await request.get('/confirm_signup/?token=123')
+ .expect(404);
+ });
+
+ it('should render the subscribe template if a token and action parameter is provided', async function () {
+ await request.get('/confirm_signup/?token=123&action=signup')
+ .expect('Content-Type', /html/)
+ .expect(200)
+ .expect(assertCorrectFrontendHeaders)
+ .expect((res) => {
+ const $ = cheerio.load(res.text);
+
+ should.not.exist(res.headers['x-cache-invalidate']);
+ should.not.exist(res.headers['X-CSRF-Token']);
+ should.not.exist(res.headers['set-cookie']);
+ should.exist(res.headers.date);
+
+ $('#gh-subscribe-form').should.exist;
+ $('#gh-subscribe-form').attr('action').should.eql('/members/api/member');
+ $('input[name="token"]').val().should.eql('123');
+ $('input[name="action"]').val().should.eql('signup');
+ });
+ });
+});
diff --git a/ghost/members-api/lib/controllers/RouterController.js b/ghost/members-api/lib/controllers/RouterController.js
index 6230607e8a..9f91ec2e78 100644
--- a/ghost/members-api/lib/controllers/RouterController.js
+++ b/ghost/members-api/lib/controllers/RouterController.js
@@ -52,7 +52,8 @@ module.exports = class RouterController {
memberAttributionService,
sendEmailWithMagicLink,
labsService,
- newslettersService
+ newslettersService,
+ createMemberFromToken
}) {
this._offersAPI = offersAPI;
this._paymentsService = paymentsService;
@@ -67,6 +68,7 @@ module.exports = class RouterController {
this._memberAttributionService = memberAttributionService;
this.labsService = labsService;
this._newslettersService = newslettersService;
+ this._createMemberFromToken = createMemberFromToken;
}
async ensureStripe(_req, res, next) {
@@ -555,4 +557,20 @@ module.exports = class RouterController {
throw err;
}
}
+
+ async createMemberFromToken(req, res) {
+ const {token} = req.body;
+
+ // If successful, creating the member will return a signin link we can redirect the member to
+ // This will sign them in automatically
+ const signinLink = await this._createMemberFromToken(token);
+
+ if (!signinLink) {
+ res.writeHead(400);
+ return res.end('Bad Request.');
+ }
+
+ // If the member exists, redirect to the members page with the token in the URL to create a session
+ res.redirect(signinLink);
+ }
};
diff --git a/ghost/members-api/lib/members-api.js b/ghost/members-api/lib/members-api.js
index 808c0f4263..d04c9b8d28 100644
--- a/ghost/members-api/lib/members-api.js
+++ b/ghost/members-api/lib/members-api.js
@@ -187,6 +187,7 @@ module.exports = function MembersAPI({
stripeAPIService,
tokenService,
sendEmailWithMagicLink,
+ createMemberFromToken,
memberAttributionService,
labsService,
newslettersService
@@ -247,6 +248,12 @@ module.exports = function MembersAPI({
return member;
}
+ // If spam prevention is enabled, we don't create the member via middleware upon clicking the magic link
+ // The member can only be created by following the link to /subscribe and sending a POST request to explicitly confirm
+ if (labsService.isSet('membersSpamPrevention')) {
+ return null;
+ }
+
// Note: old tokens can still have a missing type (we can remove this after a couple of weeks)
if (type && !['signup', 'subscribe'].includes(type)) {
// Don't allow sign up
@@ -271,6 +278,45 @@ module.exports = function MembersAPI({
return getMemberIdentityData(email);
}
+ async function createMemberFromToken(token) {
+ const {email, labels = [], name = '', oldEmail, newsletters, attribution, reqIp, type} = await getTokenDataFromMagicLinkToken(token);
+ if (!email) {
+ return null;
+ }
+
+ const member = oldEmail ? await getMemberIdentityData(oldEmail) : await getMemberIdentityData(email);
+
+ if (member) {
+ const magicLink = getMagicLink(email, 'signin');
+ return magicLink;
+ }
+
+ // Note: old tokens can still have a missing type (we can remove this after a couple of weeks)
+ if (type && !['signup', 'subscribe'].includes(type)) {
+ // Don't allow sign up
+ // Note that we use the type from inside the magic token so this behaviour can't be changed
+ return null;
+ }
+
+ let geolocation;
+ if (reqIp) {
+ try {
+ geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(reqIp));
+ } catch (err) {
+ logging.warn(err);
+ // no-op, we don't want to stop anything working due to
+ // geolocation lookup failing
+ }
+ }
+
+ const newMember = await users.create({name, email, labels, newsletters, attribution, geolocation});
+ if (newMember) {
+ const magicLink = getMagicLink(email, 'signin');
+ return magicLink;
+ }
+ return null;
+ }
+
async function getMemberIdentityData(email) {
return memberBREADService.read({email});
}
@@ -330,6 +376,11 @@ module.exports = function MembersAPI({
body.json(),
forwardError((req, res) => routerController.sendMagicLink(req, res))
),
+ createMemberFromToken: Router().use(
+ body.urlencoded({extended: true}),
+ body.json(),
+ forwardError((req, res) => routerController.createMemberFromToken(req, res))
+ ),
createCheckoutSession: Router().use(
body.json(),
forwardError((req, res) => routerController.createCheckoutSession(req, res))
@@ -377,6 +428,7 @@ module.exports = function MembersAPI({
getMemberIdentityToken,
getMemberIdentityDataFromTransientId,
getMemberIdentityData,
+ createMemberFromToken,
cycleTransientId,
setMemberGeolocationFromIp,
getPublicConfig,