Ghost/ghost/members-api/lib/controllers/RouterController.js

559 lines
20 KiB
JavaScript
Raw Normal View History

const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
const _ = require('lodash');
const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureError} = require('@tryghost/errors');
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
const errors = require('@tryghost/errors');
const messages = {
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
emailRequired: 'Email is required.',
badRequest: 'Bad Request.',
notFound: 'Not Found.',
offerArchived: 'This offer is archived.',
tierArchived: 'This tier is archived.',
existingSubscription: 'A subscription exists for this Member.',
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
unableToCheckout: 'Unable to initiate checkout session',
inviteOnly: 'This site is invite-only, contact the owner for access.',
memberNotFound: 'No member exists with this e-mail address.',
memberNotFoundSignUp: 'No member exists with this e-mail address. Please sign up first.',
invalidType: 'Invalid checkout type.',
notConfigured: 'This site is not accepting payments at the moment.',
invalidNewsletters: 'Cannot subscribe to invalid newsletters {newsletters}',
archivedNewsletters: 'Cannot subscribe to archived newsletters {newsletters}'
};
module.exports = class RouterController {
/**
* RouterController
*
* @param {object} deps
* @param {any} deps.offersAPI
* @param {any} deps.paymentsService
* @param {any} deps.memberRepository
* @param {any} deps.StripePrice
* @param {() => boolean} deps.allowSelfSignup
* @param {any} deps.magicLinkService
* @param {import('@tryghost/members-stripe-service')} deps.stripeAPIService
Added member attribution events and storage (#15243) refs https://github.com/TryGhost/Team/issues/1808 refs https://github.com/TryGhost/Team/issues/1809 refs https://github.com/TryGhost/Team/issues/1820 refs https://github.com/TryGhost/Team/issues/1814 ### Changes in `member-events` package - Added MemberCreatedEvent (event, not model) - Added SubscriptionCreatedEvent (event, not model) ### Added `member-attribution` package (new) - Added the AttributionBuilder class which is able to convert a url history to an attribution object (exposed as getAttribution on the service itself, which handles the dependencies) ``` [{ "path": "/", "time": 123 }] ``` to ``` { "url": "/", "id": null, "type": "url" } ``` - event handler listens for MemberCreatedEvent and SubscriptionCreatedEvent and creates the corresponding models in the database. ### Changes in `members-api` package - Added urlHistory to `sendMagicLink` endpoint body + convert the urlHistory to an attribution object that is stored in the tokenData of the magic link (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added urlHistory to `createCheckoutSession` endpoint + convert the urlHistory to attribution keys that are saved in the Stripe Session metadata (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added attribution data property to member repository's create method (when a member is created) - Dispatch MemberCreatedEvent with attribution ### Changes in `members-stripe-service` package (`ghost/stripe`) - Dispatch SubscriptionCreatedEvent in WebhookController on subscription checkout (with attribution from session metadata)
2022-08-18 18:38:42 +03:00
* @param {import('@tryghost/member-attribution')} deps.memberAttributionService
* @param {any} deps.tokenService
* @param {any} deps.sendEmailWithMagicLink
* @param {{isSet(name: string): boolean}} deps.labsService
* @param {any} deps.newslettersService
*/
constructor({
offersAPI,
paymentsService,
tiersService,
memberRepository,
StripePrice,
allowSelfSignup,
magicLinkService,
stripeAPIService,
tokenService,
Added member attribution events and storage (#15243) refs https://github.com/TryGhost/Team/issues/1808 refs https://github.com/TryGhost/Team/issues/1809 refs https://github.com/TryGhost/Team/issues/1820 refs https://github.com/TryGhost/Team/issues/1814 ### Changes in `member-events` package - Added MemberCreatedEvent (event, not model) - Added SubscriptionCreatedEvent (event, not model) ### Added `member-attribution` package (new) - Added the AttributionBuilder class which is able to convert a url history to an attribution object (exposed as getAttribution on the service itself, which handles the dependencies) ``` [{ "path": "/", "time": 123 }] ``` to ``` { "url": "/", "id": null, "type": "url" } ``` - event handler listens for MemberCreatedEvent and SubscriptionCreatedEvent and creates the corresponding models in the database. ### Changes in `members-api` package - Added urlHistory to `sendMagicLink` endpoint body + convert the urlHistory to an attribution object that is stored in the tokenData of the magic link (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added urlHistory to `createCheckoutSession` endpoint + convert the urlHistory to attribution keys that are saved in the Stripe Session metadata (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added attribution data property to member repository's create method (when a member is created) - Dispatch MemberCreatedEvent with attribution ### Changes in `members-stripe-service` package (`ghost/stripe`) - Dispatch SubscriptionCreatedEvent in WebhookController on subscription checkout (with attribution from session metadata)
2022-08-18 18:38:42 +03:00
memberAttributionService,
sendEmailWithMagicLink,
labsService,
newslettersService
}) {
this._offersAPI = offersAPI;
this._paymentsService = paymentsService;
this._tiersService = tiersService;
this._memberRepository = memberRepository;
this._StripePrice = StripePrice;
this._allowSelfSignup = allowSelfSignup;
this._magicLinkService = magicLinkService;
this._stripeAPIService = stripeAPIService;
this._tokenService = tokenService;
this._sendEmailWithMagicLink = sendEmailWithMagicLink;
Added member attribution events and storage (#15243) refs https://github.com/TryGhost/Team/issues/1808 refs https://github.com/TryGhost/Team/issues/1809 refs https://github.com/TryGhost/Team/issues/1820 refs https://github.com/TryGhost/Team/issues/1814 ### Changes in `member-events` package - Added MemberCreatedEvent (event, not model) - Added SubscriptionCreatedEvent (event, not model) ### Added `member-attribution` package (new) - Added the AttributionBuilder class which is able to convert a url history to an attribution object (exposed as getAttribution on the service itself, which handles the dependencies) ``` [{ "path": "/", "time": 123 }] ``` to ``` { "url": "/", "id": null, "type": "url" } ``` - event handler listens for MemberCreatedEvent and SubscriptionCreatedEvent and creates the corresponding models in the database. ### Changes in `members-api` package - Added urlHistory to `sendMagicLink` endpoint body + convert the urlHistory to an attribution object that is stored in the tokenData of the magic link (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added urlHistory to `createCheckoutSession` endpoint + convert the urlHistory to attribution keys that are saved in the Stripe Session metadata (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added attribution data property to member repository's create method (when a member is created) - Dispatch MemberCreatedEvent with attribution ### Changes in `members-stripe-service` package (`ghost/stripe`) - Dispatch SubscriptionCreatedEvent in WebhookController on subscription checkout (with attribution from session metadata)
2022-08-18 18:38:42 +03:00
this._memberAttributionService = memberAttributionService;
this.labsService = labsService;
this._newslettersService = newslettersService;
}
async ensureStripe(_req, res, next) {
if (!this._stripeAPIService.configured) {
res.writeHead(400);
return res.end('Stripe not configured');
}
try {
await this._stripeAPIService.ready();
next();
} catch (err) {
res.writeHead(500);
return res.end('There was an error configuring stripe');
}
}
async createCheckoutSetupSession(req, res) {
const identity = req.body.identity;
if (!identity) {
res.writeHead(400);
return res.end();
}
let email;
try {
if (!identity) {
email = null;
} else {
const claims = await this._tokenService.decodeToken(identity);
email = claims && claims.sub;
}
} catch (err) {
res.writeHead(401);
return res.end('Unauthorized');
}
const member = email ? await this._memberRepository.get({email}) : null;
if (!member) {
res.writeHead(403);
return res.end('Bad Request.');
}
let customer;
if (!req.body.subscription_id) {
customer = await this._stripeAPIService.getCustomerForMemberCheckoutSession(member);
} else {
const subscriptions = await member.related('stripeSubscriptions').fetch();
const subscription = subscriptions.models.find((sub) => {
return sub.get('subscription_id') === req.body.subscription_id;
});
if (!subscription) {
res.writeHead(404, {
'Content-Type': 'text/plain;charset=UTF-8'
});
return res.end(`Could not find subscription ${req.body.subscription_id}`);
}
customer = await this._stripeAPIService.getCustomer(subscription.get('customer_id'));
}
const session = await this._stripeAPIService.createCheckoutSetupSession(customer, {
successUrl: req.body.successUrl,
cancelUrl: req.body.cancelUrl,
subscription_id: req.body.subscription_id
});
const publicKey = this._stripeAPIService.getPublicKey();
const sessionInfo = {
sessionId: session.id,
publicKey
};
res.writeHead(200, {
'Content-Type': 'application/json'
});
res.end(JSON.stringify(sessionInfo));
}
async _setAttributionMetadata(metadata) {
// Don't allow to set the source manually
delete metadata.attribution_id;
delete metadata.attribution_url;
delete metadata.attribution_type;
delete metadata.referrer_source;
delete metadata.referrer_medium;
delete metadata.referrer_url;
if (metadata.urlHistory) {
// The full attribution history doesn't fit in the Stripe metadata (can't store objects + limited to 50 keys and 500 chars values)
// So we need to add top-level attributes with string values
const urlHistory = metadata.urlHistory;
delete metadata.urlHistory;
const attribution = await this._memberAttributionService.getAttribution(urlHistory);
// Don't set null properties
if (attribution.id) {
metadata.attribution_id = attribution.id;
}
if (attribution.url) {
metadata.attribution_url = attribution.url;
}
if (attribution.type) {
metadata.attribution_type = attribution.type;
}
if (attribution.referrerSource) {
metadata.referrer_source = attribution.referrerSource;
}
if (attribution.referrerMedium) {
metadata.referrer_medium = attribution.referrerMedium;
}
if (attribution.referrerUrl) {
metadata.referrer_url = attribution.referrerUrl;
}
}
}
/**
* Read the passed tier, offer and cadence from the request body and return the corresponding objects, or throws if validation fails
* @returns
*/
async _getSubscriptionCheckoutData(body) {
const ghostPriceId = body.priceId;
const tierId = body.tierId;
const offerId = body.offerId;
let cadence = body.cadence;
let tier;
let offer;
// Validate basic input
if (!ghostPriceId && !offerId && !tierId && !cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (offerId && (ghostPriceId || (tierId && cadence))) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (ghostPriceId && tierId && cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (tierId && !cadence) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
if (cadence && cadence !== 'month' && cadence !== 'year') {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
// Fetch tier and offer
if (offerId) {
offer = await this._offersAPI.getOffer({id: offerId});
tier = await this._tiersService.api.read(offer.tier.id);
cadence = offer.cadence;
} else {
offer = null;
tier = await this._tiersService.api.read(tierId);
}
if (tier.status === 'archived') {
throw new NoPermissionError({
message: tpl(messages.tierArchived)
});
}
return {
tier,
offer,
cadence
};
}
/**
*
* @param {object} options
* @param {object} options.tier
* @param {object} [options.offer]
* @param {string} options.cadence
* @param {string} options.successUrl URL to redirect to after successful checkout
* @param {string} options.cancelUrl URL to redirect to after cancelled checkout
* @param {string} [options.email] Email address of the customer
* @param {object} [options.member] Currently authenticated member OR member associated with the email address
* @param {boolean} options.isAuthenticated
* @param {object} options.metadata Metadata to be passed to Stripe
* @returns
*/
async _createSubscriptionCheckoutSession(options) {
if (options.offer) {
// Attach offer information to stripe metadata for free trial offers
// free trial offers don't have associated stripe coupons
options.metadata.offer = options.offer.id;
Added member attribution events and storage (#15243) refs https://github.com/TryGhost/Team/issues/1808 refs https://github.com/TryGhost/Team/issues/1809 refs https://github.com/TryGhost/Team/issues/1820 refs https://github.com/TryGhost/Team/issues/1814 ### Changes in `member-events` package - Added MemberCreatedEvent (event, not model) - Added SubscriptionCreatedEvent (event, not model) ### Added `member-attribution` package (new) - Added the AttributionBuilder class which is able to convert a url history to an attribution object (exposed as getAttribution on the service itself, which handles the dependencies) ``` [{ "path": "/", "time": 123 }] ``` to ``` { "url": "/", "id": null, "type": "url" } ``` - event handler listens for MemberCreatedEvent and SubscriptionCreatedEvent and creates the corresponding models in the database. ### Changes in `members-api` package - Added urlHistory to `sendMagicLink` endpoint body + convert the urlHistory to an attribution object that is stored in the tokenData of the magic link (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added urlHistory to `createCheckoutSession` endpoint + convert the urlHistory to attribution keys that are saved in the Stripe Session metadata (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added attribution data property to member repository's create method (when a member is created) - Dispatch MemberCreatedEvent with attribution ### Changes in `members-stripe-service` package (`ghost/stripe`) - Dispatch SubscriptionCreatedEvent in WebhookController on subscription checkout (with attribution from session metadata)
2022-08-18 18:38:42 +03:00
}
if (!options.member && options.email) {
// Create a signup link if there is no member with this email address
options.successUrl = await this._magicLinkService.getMagicLink({
tokenData: {
email: options.email,
attribution: {
id: options.metadata.attribution_id ?? null,
type: options.metadata.attribution_type ?? null,
url: options.metadata.attribution_url ?? null
}
},
type: 'signup',
// Redirect to the original success url after sign up
referrer: options.successUrl
});
}
const restrictCheckout = options.member?.get('status') === 'paid';
if (restrictCheckout) {
// This member is already subscribed to a paid tier
// We don't want to create a duplicate subscription
if (!options.isAuthenticated && options.email) {
try {
await this._sendEmailWithMagicLink({email: options.email, requestedType: 'signin'});
} catch (err) {
logging.warn(err);
}
}
throw new NoPermissionError({
message: messages.existingSubscription,
code: 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION'
});
}
try {
const paymentLink = await this._paymentsService.getPaymentLink(options);
return {url: paymentLink};
} catch (err) {
throw new BadRequestError({
err,
message: tpl(messages.unableToCheckout)
});
}
}
/**
*
* @param {object} options
* @param {string} options.successUrl URL to redirect to after successful checkout
* @param {string} options.cancelUrl URL to redirect to after cancelled checkout
* @param {string} [options.email] Email address of the customer
* @param {object} [options.member] Currently authenticated member OR member associated with the email address
* @param {boolean} options.isAuthenticated
* @param {object} options.metadata Metadata to be passed to Stripe
* @returns
*/
async _createDonationCheckoutSession(options) {
if (!this._paymentsService.stripeAPIService.configured) {
throw new DisabledFeatureError({
message: tpl(messages.notConfigured)
});
}
try {
const paymentLink = await this._paymentsService.getDonationPaymentLink(options);
return {url: paymentLink};
} catch (err) {
throw new BadRequestError({
err,
message: tpl(messages.unableToCheckout)
});
}
}
async createCheckoutSession(req, res) {
const type = req.body.type ?? 'subscription';
const metadata = req.body.metadata ?? {};
const identity = req.body.identity;
const membersEnabled = true;
// Check this checkout type is supported
if (typeof type !== 'string' || !['subscription', 'donation'].includes(type)) {
throw new BadRequestError({
message: tpl(messages.invalidType)
});
}
// Optional authentication
let member;
let isAuthenticated = false;
if (membersEnabled) {
if (identity) {
try {
const claims = await this._tokenService.decodeToken(identity);
const email = claims && claims.sub;
if (email) {
member = await this._memberRepository.get({
email
}, {
withRelated: ['stripeCustomers', 'products']
});
isAuthenticated = true;
}
} catch (err) {
throw new UnauthorizedError({err});
}
} else if (req.body.customerEmail) {
member = await this._memberRepository.get({
email: req.body.customerEmail
}, {
withRelated: ['stripeCustomers', 'products']
});
}
}
// Store attribution data in the metadata
await this._setAttributionMetadata(metadata);
// Build options
const options = {
successUrl: req.body.successUrl,
cancelUrl: req.body.cancelUrl,
email: req.body.customerEmail,
member,
metadata,
isAuthenticated
};
let response;
if (type === 'subscription') {
if (!membersEnabled) {
throw new BadRequestError({
message: tpl(messages.badRequest)
});
}
// Get selected tier, offer and cadence
const data = await this._getSubscriptionCheckoutData(req.body);
// Check the checkout session
response = await this._createSubscriptionCheckoutSession({
...options,
...data
});
} else if (type === 'donation') {
response = await this._createDonationCheckoutSession(options);
}
res.writeHead(200, {
'Content-Type': 'application/json'
});
return res.end(JSON.stringify(response));
}
async sendMagicLink(req, res) {
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
const {email, autoRedirect} = req.body;
let {emailType, redirect} = req.body;
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
let referer = req.get('referer');
if (autoRedirect === false){
referer = null;
}
if (redirect) {
try {
// Validate URL
referer = new URL(redirect).href;
} catch (e) {
logging.warn(e);
}
}
if (!email) {
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
throw new errors.BadRequestError({
message: tpl(messages.emailRequired)
});
}
if (!emailType) {
// Default to subscribe form that also allows to login (safe fallback for older clients)
if (!this._allowSelfSignup()) {
emailType = 'signin';
} else {
emailType = 'subscribe';
}
}
if (!['signin', 'signup', 'subscribe'].includes(emailType)) {
res.writeHead(400);
return res.end('Bad Request.');
}
try {
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
if (emailType === 'signup' || emailType === 'subscribe') {
if (!this._allowSelfSignup()) {
throw new errors.BadRequestError({
message: tpl(messages.inviteOnly)
});
}
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
// Validate requested newsletters
let {newsletters: requestedNewsletters} = req.body;
if (requestedNewsletters && requestedNewsletters.length > 0 && requestedNewsletters.every(newsletter => newsletter.name !== undefined)) {
const newsletterNames = requestedNewsletters.map(newsletter => newsletter.name);
const newsletterNamesFilter = newsletterNames.map(newsletter => `'${newsletter.replace(/("|')/g, '\\$1')}'`);
const newsletters = (await this._newslettersService.getAll({
filter: `name:[${newsletterNamesFilter}]`,
columns: ['id','name','status']
}));
if (newsletters.length !== newsletterNames.length) { //check for invalid newsletters
const validNewsletters = newsletters.map(newsletter => newsletter.name);
const invalidNewsletters = newsletterNames.filter(newsletter => !validNewsletters.includes(newsletter));
throw new errors.BadRequestError({
message: tpl(messages.invalidNewsletters, {newsletters: invalidNewsletters})
});
}
//validation for archived newsletters
const archivedNewsletters = newsletters
.filter(newsletter => newsletter.status === 'archived')
.map(newsletter => newsletter.name);
if (archivedNewsletters && archivedNewsletters.length > 0) {
throw new errors.BadRequestError({
message: tpl(messages.archivedNewsletters, {newsletters: archivedNewsletters})
});
}
requestedNewsletters = newsletters
.filter(newsletter => newsletter.status === 'active')
.map(newsletter => ({id: newsletter.id}));
}
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
// Someone tries to signup with a user that already exists
// -> doesn't really matter: we'll send a login link
const tokenData = _.pick(req.body, ['labels', 'name']);
if (req.ip) {
tokenData.reqIp = req.ip;
}
tokenData.newsletters = requestedNewsletters;
Added member attribution events and storage (#15243) refs https://github.com/TryGhost/Team/issues/1808 refs https://github.com/TryGhost/Team/issues/1809 refs https://github.com/TryGhost/Team/issues/1820 refs https://github.com/TryGhost/Team/issues/1814 ### Changes in `member-events` package - Added MemberCreatedEvent (event, not model) - Added SubscriptionCreatedEvent (event, not model) ### Added `member-attribution` package (new) - Added the AttributionBuilder class which is able to convert a url history to an attribution object (exposed as getAttribution on the service itself, which handles the dependencies) ``` [{ "path": "/", "time": 123 }] ``` to ``` { "url": "/", "id": null, "type": "url" } ``` - event handler listens for MemberCreatedEvent and SubscriptionCreatedEvent and creates the corresponding models in the database. ### Changes in `members-api` package - Added urlHistory to `sendMagicLink` endpoint body + convert the urlHistory to an attribution object that is stored in the tokenData of the magic link (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added urlHistory to `createCheckoutSession` endpoint + convert the urlHistory to attribution keys that are saved in the Stripe Session metadata (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added attribution data property to member repository's create method (when a member is created) - Dispatch MemberCreatedEvent with attribution ### Changes in `members-stripe-service` package (`ghost/stripe`) - Dispatch SubscriptionCreatedEvent in WebhookController on subscription checkout (with attribution from session metadata)
2022-08-18 18:38:42 +03:00
// Save attribution data in the tokenData
tokenData.attribution = await this._memberAttributionService.getAttribution(req.body.urlHistory);
Added member attribution events and storage (#15243) refs https://github.com/TryGhost/Team/issues/1808 refs https://github.com/TryGhost/Team/issues/1809 refs https://github.com/TryGhost/Team/issues/1820 refs https://github.com/TryGhost/Team/issues/1814 ### Changes in `member-events` package - Added MemberCreatedEvent (event, not model) - Added SubscriptionCreatedEvent (event, not model) ### Added `member-attribution` package (new) - Added the AttributionBuilder class which is able to convert a url history to an attribution object (exposed as getAttribution on the service itself, which handles the dependencies) ``` [{ "path": "/", "time": 123 }] ``` to ``` { "url": "/", "id": null, "type": "url" } ``` - event handler listens for MemberCreatedEvent and SubscriptionCreatedEvent and creates the corresponding models in the database. ### Changes in `members-api` package - Added urlHistory to `sendMagicLink` endpoint body + convert the urlHistory to an attribution object that is stored in the tokenData of the magic link (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added urlHistory to `createCheckoutSession` endpoint + convert the urlHistory to attribution keys that are saved in the Stripe Session metadata (sent by Portal in this PR: https://github.com/TryGhost/Portal/pull/256). - Added attribution data property to member repository's create method (when a member is created) - Dispatch MemberCreatedEvent with attribution ### Changes in `members-stripe-service` package (`ghost/stripe`) - Dispatch SubscriptionCreatedEvent in WebhookController on subscription checkout (with attribution from session metadata)
2022-08-18 18:38:42 +03:00
await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer: referer});
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
res.writeHead(201);
return res.end('Created.');
}
// Signin
const member = await this._memberRepository.get({email});
if (member) {
const tokenData = {};
await this._sendEmailWithMagicLink({email, tokenData, requestedType: emailType, referrer: referer});
res.writeHead(201);
return res.end('Created.');
}
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
throw new errors.BadRequestError({
message: this._allowSelfSignup() ? tpl(messages.memberNotFoundSignUp) : tpl(messages.memberNotFound)
});
} catch (err) {
if (err.code === 'EENVELOPE') {
logging.error(err);
res.writeHead(400);
return res.end('Bad Request.');
}
🔒 Prevented member creation when logging in (#15526) fixes https://github.com/TryGhost/Ghost/issues/14508 This change requires the frontend to send an explicit `emailType` when sending a magic link. We default to `subscribe` (`signin` for invite only sites) for now to remain compatible with the existing behaviour. **Problem:** When a member tries to login and that member doesn't exist, we created a new member in the past. - This caused the creation of duplicate accounts when members were guessing the email address they used. - This caused the creation of new accounts when using an old impersonation token, login link or email change link that was sent before member deletion. **Fixed:** - Trying to login with an email address that doesn't exist will throw an error now. - Added new and separate rate limiting to login (to prevent user enumeration). This rate limiting has a higher default limit of 8. I think it needs a higher default limit (because it is rate limited on every call instead of per email address. And it should be configurable independent from administrator rate limiting. It also needs a lower lifetime value because it is never reset. - Updated error responses in the `sendMagicLink` endpoint to use the default error encoding middleware. - The type (`signin`, `signup`, `updateEmail` or `subscribe`) is now stored in the magic link. This is used to prevent signups with a sign in token. **Notes:** - Between tests, we truncate the database, but this is not enough for the rate limits to be truly reset. I had to add a method to the spam prevention service to reset all the instances between tests. Not resetting them caused random failures because every login in every test was hitting those spam prevention middlewares and somehow left a trace of that in those instances (even when the brute table is reset). Maybe those instances were doing some in memory caching.
2022-10-05 13:42:42 +03:00
// Let the normal error middleware handle this error
throw err;
}
}
};