Added new member signup flow behind labs flag (#19986)

ref https://linear.app/tryghost/issue/KTLO-1/members-spam-signups

- Some customers are seeing many spammy signups ("hundreds a day") — our
hypothesis is that bots and/or email link checkers are able to signup by
simply following the link in the email without even loading the page in
a browser.
- Currently new members signup by clicking a magic link in an email,
which is a simple GET request. When the user (or a bot) clicks that link, Ghost
creates the member and signs them in for the first time.
- This change, behind an alpha flag, requires a new member to click the
link in the email, which takes them to a new frontend route `/confirm_signup/`, then submit a form on the page which sends a POST request to the
server. If JavaScript is enabled, the form will be submitted
automatically so the only change to the user is an extra flash/redirect
before being signed in and redirected to the homepage.
- This change is behind the alpha flag `membersSpamPrevention` so we can
test it out on a few customer's sites and see if it helps reduce the
spam signups. With the flag off, the signup flow remains the same as
before.
This commit is contained in:
Chris Raible 2024-04-04 15:25:41 -07:00 committed by GitHub
parent a262a64eea
commit 01d0b2b304
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 730 additions and 6 deletions

View File

@ -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 = () => {

View File

@ -81,6 +81,7 @@ export default class FeatureService extends Service {
@feature('adminXDemo') adminXDemo;
@feature('portalImprovements') portalImprovements;
@feature('onboardingChecklist') onboardingChecklist;
@feature('membersSpamPrevention') membersSpamPrevention;
_user = null;

View File

@ -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());

View File

@ -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;

View File

@ -29,5 +29,9 @@ module.exports = {
get unsubscribe() {
return require('./unsubscribe');
},
get subscribe() {
return require('./subscribe');
}
};

View File

@ -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));
};

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{{title}}</title>
<link rel="stylesheet" href="{{asset "public/ghost.css" hasMinFile="true"}}" />
</head>
<body>
<div class="gh-app">
<div class="gh-viewport">
<main class="gh-main" role="main" id="main">
<div class="gh-flow">
<div class="gh-flow-content-wrap">
<h1>Subscribing to {{meta_title}}</h1>
<p>If you are not redirected automatically, please click the "Subscribe" button below.</p>
<form id="gh-subscribe-form" action="/members/api/member" method="POST">
<input type="hidden" name="token" value="{{token}}" />
<input type="hidden" name="action" value="{{action}}" />
<input type="hidden" name="r" value="{{r}}" />
<button class="gh-btn" style="background-color: {{accent_color}}; color: #FFFFFF" type="submit"><span>Subscribe</span></button>
</form>
</div>
</div>
</main>
</div>
</div>
<script>
const main = document.getElementById('main');
main.style.display = 'none';
const form = document.getElementById('gh-subscribe-form');
form.submit();
</script>
</body>
</html>

View File

@ -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);

View File

@ -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);

View File

@ -50,7 +50,8 @@ const ALPHA_FEATURES = [
'lexicalIndicators',
// 'adminXOffers',
'adminXDemo',
'onboardingChecklist'
'onboardingChecklist',
'membersSpamPrevention'
];
module.exports.GA_KEYS = [...GA_FEATURES];

View File

@ -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<String>,
"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`] = `
"
<!doctype html>
<html>
<head>
<meta name=\\"viewport\\" content=\\"width=device-width\\">
<meta http-equiv=\\"Content-Type\\" content=\\"text/html; charset=UTF-8\\">
<title>🙌 Complete your sign up to Ghost's Test Site!</title>
<style>
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
table[class=body] p[class=small],
table[class=body] a[class=small] {
font-size: 11px !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.recipient-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
}
hr {
border-width: 0;
height: 0;
margin-top: 34px;
margin-bottom: 34px;
border-bottom-width: 1px;
border-bottom-color: #EEF5F8;
}
a {
color: #3A464C;
}
</style>
</head>
<body style=\\"background-color: #FFFFFF; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.5em; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;\\">
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"body\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #FFFFFF;\\">
<tr>
<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;\\">&nbsp;</td>
<td class=\\"container\\" 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; display: block; margin: 0 auto; max-width: 540px; padding: 10px; width: 540px;\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px; padding: 30px 20px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<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;\\">Complete signup for Ghost's Test Site!</span>
<table class=\\"main\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 8px;\\">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class=\\"wrapper\\" 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; box-sizing: border-box;\\">
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
<tr>
<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;\\">
<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;\\">Hey there!</p>
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 32px;\\">Tap the link below to complete the signup process for Ghost's Test Site, and be automatically signed in:</p>
<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;\\">
<tbody>
<tr>
<td align=\\"left\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; padding-bottom: 35px;\\">
<table border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;\\">
<tbody>
<tr>
<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: 16px; vertical-align: top; background-color: #FF1A75; border-radius: 5px; text-align: center;\\"> <a href=\\"http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup\\" target=\\"_blank\\" style=\\"display: inline-block; color: #ffffff; background-color: #FF1A75; border: solid 1px #FF1A75; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: #FF1A75;\\">Confirm signup</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 11px;\\">For your security, the link will expire in 24 hours time.</p>
<p style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0; margin-bottom: 30px;\\">See you soon!</p>
<hr/>
<p style=\\"word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; color: #3A464C; font-weight: normal; line-height: 24px; margin: 0;\\">You can also copy & paste this URL into your browser:</p>
<p style=\\"word-break: break-all; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 15px; line-height: 22px; margin-top: 0; color: #3A464C;\\">http://127.0.0.1:2369/confirm_signup/?token=REPLACED_TOKEN&action=signup</p>
</td>
</tr>
</table>
</td>
</tr>
<!-- START FOOTER -->
<tr>
<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; padding-top: 80px;\\">
<p class=\\"small\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 16px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0;\\">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.</p>
</td>
</tr>
<tr>
<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; padding-top: 2px;\\">
<p class=\\"small\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 16px; font-size: 11px; color: #738A94; font-weight: normal; margin: 0;\\">This message was sent from <a class=\\"small\\" href=\\"http://127.0.0.1:2369/\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">127.0.0.1</a> to <a class=\\"small\\" href=\\"mailto:member_getting_confirmation@test.com\\" style=\\"text-decoration: underline; color: #738A94; font-size: 11px;\\">member_getting_confirmation@test.com</a></p>
</td>
</tr>
<!-- END FOOTER -->
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<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;\\">&nbsp;</td>
</tr>
</table>
</body>
</html>
"
`;
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\\" <noreply@127.0.0.1>",
"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 [

View File

@ -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',

View File

@ -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,

View File

@ -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: {

View File

@ -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');
});
});
});

View File

@ -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);
}
};

View File

@ -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,