01d0b2b304
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.
448 lines
14 KiB
JavaScript
448 lines
14 KiB
JavaScript
const {Router} = require('express');
|
|
const body = require('body-parser');
|
|
const MagicLink = require('@tryghost/magic-link');
|
|
const errors = require('@tryghost/errors');
|
|
const logging = require('@tryghost/logging');
|
|
|
|
const PaymentsService = require('@tryghost/members-payments');
|
|
|
|
const TokenService = require('./services/TokenService');
|
|
const GeolocationService = require('./services/GeolocationService');
|
|
const MemberBREADService = require('./services/MemberBREADService');
|
|
const MemberRepository = require('./repositories/MemberRepository');
|
|
const EventRepository = require('./repositories/EventRepository');
|
|
const ProductRepository = require('./repositories/ProductRepository');
|
|
const RouterController = require('./controllers/RouterController');
|
|
const MemberController = require('./controllers/MemberController');
|
|
const WellKnownController = require('./controllers/WellKnownController');
|
|
|
|
const {EmailSuppressedEvent} = require('@tryghost/email-suppression-list');
|
|
const DomainEvents = require('@tryghost/domain-events');
|
|
|
|
module.exports = function MembersAPI({
|
|
tokenConfig: {
|
|
issuer,
|
|
privateKey,
|
|
publicKey
|
|
},
|
|
auth: {
|
|
allowSelfSignup = () => true,
|
|
getSigninURL,
|
|
tokenProvider
|
|
},
|
|
mail: {
|
|
transporter,
|
|
getText,
|
|
getHTML,
|
|
getSubject
|
|
},
|
|
models: {
|
|
DonationPaymentEvent,
|
|
EmailRecipient,
|
|
StripeCustomer,
|
|
StripeCustomerSubscription,
|
|
Member,
|
|
MemberNewsletter,
|
|
MemberCancelEvent,
|
|
MemberSubscribeEvent,
|
|
MemberLoginEvent,
|
|
MemberPaidSubscriptionEvent,
|
|
MemberPaymentEvent,
|
|
MemberStatusEvent,
|
|
MemberProductEvent,
|
|
MemberEmailChangeEvent,
|
|
MemberCreatedEvent,
|
|
SubscriptionCreatedEvent,
|
|
MemberLinkClickEvent,
|
|
EmailSpamComplaintEvent,
|
|
Offer,
|
|
OfferRedemption,
|
|
StripeProduct,
|
|
StripePrice,
|
|
Product,
|
|
Settings,
|
|
Comment,
|
|
MemberFeedback
|
|
},
|
|
tiersService,
|
|
stripeAPIService,
|
|
offersAPI,
|
|
labsService,
|
|
newslettersService,
|
|
memberAttributionService,
|
|
emailSuppressionList,
|
|
settingsCache
|
|
}) {
|
|
const tokenService = new TokenService({
|
|
privateKey,
|
|
publicKey,
|
|
issuer
|
|
});
|
|
|
|
const productRepository = new ProductRepository({
|
|
Product,
|
|
Settings,
|
|
StripeProduct,
|
|
StripePrice,
|
|
stripeAPIService
|
|
});
|
|
|
|
const memberRepository = new MemberRepository({
|
|
stripeAPIService,
|
|
tokenService,
|
|
newslettersService,
|
|
labsService,
|
|
productRepository,
|
|
Member,
|
|
MemberNewsletter,
|
|
MemberCancelEvent,
|
|
MemberSubscribeEventModel: MemberSubscribeEvent,
|
|
MemberPaidSubscriptionEvent,
|
|
MemberEmailChangeEvent,
|
|
MemberStatusEvent,
|
|
MemberProductEvent,
|
|
OfferRedemption,
|
|
StripeCustomer,
|
|
StripeCustomerSubscription,
|
|
offerRepository: offersAPI.repository
|
|
});
|
|
|
|
const eventRepository = new EventRepository({
|
|
DonationPaymentEvent,
|
|
EmailRecipient,
|
|
MemberSubscribeEvent,
|
|
MemberPaidSubscriptionEvent,
|
|
MemberPaymentEvent,
|
|
MemberStatusEvent,
|
|
MemberLoginEvent,
|
|
MemberCreatedEvent,
|
|
SubscriptionCreatedEvent,
|
|
MemberLinkClickEvent,
|
|
MemberFeedback,
|
|
EmailSpamComplaintEvent,
|
|
Comment,
|
|
labsService,
|
|
memberAttributionService
|
|
});
|
|
|
|
const memberBREADService = new MemberBREADService({
|
|
offersAPI,
|
|
memberRepository,
|
|
emailService: {
|
|
async sendEmailWithMagicLink({email, requestedType}) {
|
|
return sendEmailWithMagicLink({
|
|
email,
|
|
requestedType,
|
|
options: {
|
|
forceEmailType: true
|
|
}
|
|
});
|
|
}
|
|
},
|
|
labsService,
|
|
stripeService: stripeAPIService,
|
|
memberAttributionService,
|
|
emailSuppressionList
|
|
});
|
|
|
|
const geolocationService = new GeolocationService();
|
|
|
|
const magicLinkService = new MagicLink({
|
|
transporter,
|
|
tokenProvider,
|
|
getSigninURL,
|
|
getText,
|
|
getHTML,
|
|
getSubject
|
|
});
|
|
|
|
const paymentsService = new PaymentsService({
|
|
StripeProduct,
|
|
StripePrice,
|
|
StripeCustomer,
|
|
Offer,
|
|
offersAPI,
|
|
stripeAPIService,
|
|
settingsCache
|
|
});
|
|
|
|
const memberController = new MemberController({
|
|
memberRepository,
|
|
productRepository,
|
|
paymentsService,
|
|
tiersService,
|
|
StripePrice,
|
|
tokenService,
|
|
sendEmailWithMagicLink
|
|
});
|
|
|
|
const routerController = new RouterController({
|
|
offersAPI,
|
|
paymentsService,
|
|
tiersService,
|
|
memberRepository,
|
|
StripePrice,
|
|
allowSelfSignup,
|
|
magicLinkService,
|
|
stripeAPIService,
|
|
tokenService,
|
|
sendEmailWithMagicLink,
|
|
createMemberFromToken,
|
|
memberAttributionService,
|
|
labsService,
|
|
newslettersService
|
|
});
|
|
|
|
const wellKnownController = new WellKnownController({
|
|
tokenService
|
|
});
|
|
|
|
const users = memberRepository;
|
|
|
|
async function sendEmailWithMagicLink({email, requestedType, tokenData, options = {forceEmailType: false}, referrer = null}) {
|
|
let type = requestedType;
|
|
if (!options.forceEmailType) {
|
|
const member = await users.get({email});
|
|
if (member) {
|
|
type = 'signin';
|
|
} else if (type !== 'subscribe') {
|
|
type = 'signup';
|
|
}
|
|
}
|
|
return magicLinkService.sendMagicLink({email, type, tokenData: Object.assign({email, type}, tokenData), referrer});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} email
|
|
* @param {'signin'|'signup'} type When you specify 'signin' this will prevent the creation of a new member if no member is found with the provided email
|
|
* @param {*} [tokenData] Optional token data to add to the token
|
|
* @returns
|
|
*/
|
|
function getMagicLink(email, type, tokenData = {}) {
|
|
return magicLinkService.getMagicLink({
|
|
tokenData: {email, ...tokenData},
|
|
type
|
|
});
|
|
}
|
|
|
|
async function getTokenDataFromMagicLinkToken(token) {
|
|
return await magicLinkService.getDataFromToken(token);
|
|
}
|
|
|
|
async function getMemberDataFromMagicLinkToken(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) {
|
|
await MemberLoginEvent.add({member_id: member.id});
|
|
if (oldEmail && (!type || type === 'updateEmail')) {
|
|
// user exists but wants to change their email address
|
|
await users.update({email}, {id: member.id});
|
|
return getMemberIdentityData(email);
|
|
}
|
|
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
|
|
// 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});
|
|
|
|
await MemberLoginEvent.add({member_id: newMember.id});
|
|
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});
|
|
}
|
|
|
|
async function getMemberIdentityDataFromTransientId(transientId) {
|
|
return memberBREADService.read({transient_id: transientId});
|
|
}
|
|
|
|
async function cycleTransientId(memberId) {
|
|
await users.cycleTransientId({id: memberId});
|
|
}
|
|
|
|
async function getMemberIdentityToken(transientId) {
|
|
const member = await getMemberIdentityDataFromTransientId(transientId);
|
|
if (!member) {
|
|
return null;
|
|
}
|
|
return tokenService.encodeIdentityToken({sub: member.email});
|
|
}
|
|
|
|
async function setMemberGeolocationFromIp(email, ip) {
|
|
if (!email || !ip) {
|
|
throw new errors.IncorrectUsageError({
|
|
message: 'setMemberGeolocationFromIp() expects email and ip arguments to be present'
|
|
});
|
|
}
|
|
|
|
// toJSON() is needed here otherwise users.update() will pick methods off
|
|
// the model object rather than data and fail to edit correctly
|
|
const member = (await users.get({email})).toJSON();
|
|
|
|
if (!member) {
|
|
throw new errors.NotFoundError({
|
|
message: `Member with email address ${email} does not exist`
|
|
});
|
|
}
|
|
|
|
// max request time is 500ms so shouldn't slow requests down too much
|
|
let geolocation = JSON.stringify(await geolocationService.getGeolocationFromIP(ip));
|
|
if (geolocation) {
|
|
await users.update({geolocation}, {id: member.id});
|
|
}
|
|
|
|
return getMemberIdentityData(email);
|
|
}
|
|
|
|
const forwardError = fn => async (req, res, next) => {
|
|
try {
|
|
await fn(req, res, next);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
};
|
|
|
|
const middleware = {
|
|
sendMagicLink: Router().use(
|
|
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))
|
|
),
|
|
createCheckoutSetupSession: Router().use(
|
|
body.json(),
|
|
forwardError((req, res) => routerController.createCheckoutSetupSession(req, res))
|
|
),
|
|
updateEmailAddress: Router().use(
|
|
body.json(),
|
|
forwardError((req, res) => memberController.updateEmailAddress(req, res))
|
|
),
|
|
updateSubscription: Router({mergeParams: true}).use(
|
|
body.json(),
|
|
forwardError((req, res) => memberController.updateSubscription(req, res))
|
|
),
|
|
wellKnown: Router()
|
|
.get('/jwks.json',
|
|
(req, res) => wellKnownController.getPublicKeys(req, res)
|
|
)
|
|
};
|
|
|
|
const getPublicConfig = function () {
|
|
return Promise.resolve({
|
|
publicKey,
|
|
issuer
|
|
});
|
|
};
|
|
|
|
const bus = new (require('events').EventEmitter)();
|
|
|
|
bus.emit('ready');
|
|
|
|
DomainEvents.subscribe(EmailSuppressedEvent, async function (event) {
|
|
const member = await memberRepository.get({email: event.data.emailAddress});
|
|
if (!member) {
|
|
return;
|
|
}
|
|
await memberRepository.update({email_disabled: true}, {id: member.id});
|
|
});
|
|
|
|
return {
|
|
middleware,
|
|
getMemberDataFromMagicLinkToken,
|
|
getMemberIdentityToken,
|
|
getMemberIdentityDataFromTransientId,
|
|
getMemberIdentityData,
|
|
createMemberFromToken,
|
|
cycleTransientId,
|
|
setMemberGeolocationFromIp,
|
|
getPublicConfig,
|
|
bus,
|
|
sendEmailWithMagicLink,
|
|
getMagicLink,
|
|
members: users,
|
|
memberBREADService,
|
|
events: eventRepository,
|
|
productRepository,
|
|
|
|
// Test helpers
|
|
getTokenDataFromMagicLinkToken,
|
|
paymentsService
|
|
};
|
|
};
|