Ghost/ghost/magic-link/lib/MagicLink.js

160 lines
5.5 KiB
JavaScript
Raw Normal View History

const {IncorrectUsageError, BadRequestError} = require('@tryghost/errors');
const {isEmail} = require('@tryghost/validator');
const tpl = require('@tryghost/tpl');
const messages = {
invalidEmail: 'Email is not valid'
};
/**
* @typedef { import('nodemailer').Transporter } MailTransporter
* @typedef { import('nodemailer').SentMessageInfo } SentMessageInfo
* @typedef { string } URL
*/
/**
* @template T
* @template D
* @typedef {Object} TokenProvider<T, D>
* @prop {(data: D) => Promise<T>} create
* @prop {(token: T) => Promise<D>} validate
*/
/**
* MagicLink
* @template Token
* @template TokenData
*/
class MagicLink {
/**
* @param {object} options
* @param {MailTransporter} options.transporter
* @param {TokenProvider<Token, TokenData>} options.tokenProvider
* @param {(token: Token, type: string, referrer?: string) => URL} options.getSigninURL
* @param {typeof defaultGetText} [options.getText]
* @param {typeof defaultGetHTML} [options.getHTML]
* @param {typeof defaultGetSubject} [options.getSubject]
* @param {object} [options.sentry]
*/
constructor(options) {
if (!options || !options.transporter || !options.tokenProvider || !options.getSigninURL) {
throw new IncorrectUsageError({message: 'Missing options. Expects {transporter, tokenProvider, getSigninURL}'});
}
this.transporter = options.transporter;
this.tokenProvider = options.tokenProvider;
this.getSigninURL = options.getSigninURL;
this.getText = options.getText || defaultGetText;
this.getHTML = options.getHTML || defaultGetHTML;
this.getSubject = options.getSubject || defaultGetSubject;
this.sentry = options.sentry || undefined;
}
/**
* sendMagicLink
*
* @param {object} options
* @param {string} options.email - The email to send magic link to
* @param {TokenData} options.tokenData - The data for token
* @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions
* @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
* @returns {Promise<{token: Token, info: SentMessageInfo}>}
*/
async sendMagicLink(options) {
this.sentry?.captureMessage?.(`[Magic Link] Generating magic link`, {extra: options});
if (!isEmail(options.email)) {
throw new BadRequestError({
message: tpl(messages.invalidEmail)
});
}
const token = await this.tokenProvider.create(options.tokenData);
const type = options.type || 'signin';
const url = this.getSigninURL(token, type, options.referrer);
const info = await this.transporter.sendMail({
to: options.email,
subject: this.getSubject(type),
text: this.getText(url, type, options.email),
html: this.getHTML(url, type, options.email)
});
return {token, info};
}
/**
* getMagicLink
*
* @param {object} options
* @param {TokenData} options.tokenData - The data for token
* @param {string} [options.type='signin'] - The type to be passed to the url and content generator functions. This type will also get stored in the token data.
* @param {string} [options.referrer=null] - The referrer of the request, if exists. The member will be redirected back to this URL after signin.
* @returns {Promise<URL>} - signin URL
*/
async getMagicLink(options) {
🐛 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 type = options.type ?? 'signin';
const token = await this.tokenProvider.create({...options.tokenData, type});
return this.getSigninURL(token, type, options.referrer);
}
/**
* getDataFromToken
*
* @param {Token} token - The token to decode
* @returns {Promise<TokenData>} data - The data object associated with the magic link
*/
async getDataFromToken(token) {
const tokenData = await this.tokenProvider.validate(token);
return tokenData;
}
}
/**
* defaultGetText
*
* @param {URL} url - The url which will trigger sign in flow
* @param {string} type - The type of email to send e.g. signin, signup
* @param {string} email - The recipient of the email to send
* @returns {string} text - The text content of an email to send
*/
function defaultGetText(url, type, email) {
let msg = 'sign in';
if (type === 'signup') {
msg = 'confirm your email address';
}
return `Click here to ${msg} ${url}. This msg was sent to ${email}`;
}
/**
* defaultGetHTML
*
* @param {URL} url - The url which will trigger sign in flow
* @param {string} type - The type of email to send e.g. signin, signup
* @param {string} email - The recipient of the email to send
* @returns {string} HTML - The HTML content of an email to send
*/
function defaultGetHTML(url, type, email) {
let msg = 'sign in';
if (type === 'signup') {
msg = 'confirm your email address';
}
return `<a href="${url}">Click here to ${msg}</a> This msg was sent to ${email}`;
}
/**
* defaultGetSubject
*
* @param {string} type - The type of email to send e.g. signin, signup
* @returns {string} subject - The subject of an email to send
*/
function defaultGetSubject(type) {
if (type === 'signup') {
return `Signup!`;
}
return `Signin!`;
}
module.exports = MagicLink;
module.exports.JWTTokenProvider = require('./JWTTokenProvider');