mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-04-15 03:01:37 -05:00
Added integrity token API & middleware for magic link requests
ref KTLO-1 Back-end implementation of request integrity tokens. The purpose here is to prevent simple web bots from spamming the signup form.
This commit is contained in:
parent
0053939185
commit
a48b4e5cbf
4 changed files with 106 additions and 2 deletions
|
@ -0,0 +1,62 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
class RequestIntegrityTokenProvider {
|
||||
#themeSecret;
|
||||
#tokenDuration;
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string} options.themeSecret
|
||||
* @param {number} options.tokenDuration
|
||||
*/
|
||||
constructor(options) {
|
||||
this.#themeSecret = options.themeSecret;
|
||||
this.#tokenDuration = options.tokenDuration;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
create() {
|
||||
const currentTime = Date.now();
|
||||
const expiryTime = currentTime + this.#tokenDuration;
|
||||
const nonce = crypto.randomBytes(16).toString('hex');
|
||||
const hmac = crypto.createHmac('sha256', this.#themeSecret);
|
||||
hmac.update(`${expiryTime.toString()}:${nonce}`);
|
||||
return `${expiryTime.toString()}:${nonce}:${hmac.digest('hex')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} token
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate(token) {
|
||||
const parts = token.split(':');
|
||||
if (parts.length !== 3) {
|
||||
// Invalid token string
|
||||
return false;
|
||||
}
|
||||
|
||||
const nonce = parts[0];
|
||||
const timestamp = parseInt(parts[1], 10);
|
||||
const hmacDigest = parts[2];
|
||||
|
||||
const hmac = crypto.createHmac('sha256', this.#themeSecret);
|
||||
hmac.update(`${nonce}:${timestamp.toString()}`);
|
||||
const expectedHmac = hmac.digest('hex');
|
||||
|
||||
if (expectedHmac !== hmacDigest) {
|
||||
// HMAC mismatch
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() > timestamp) {
|
||||
// Token expired
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RequestIntegrityTokenProvider;
|
|
@ -35,7 +35,7 @@ const getFreeTier = async function getFreeTier() {
|
|||
* @param {import('express').Request} req - The member object
|
||||
* @param {import('express').Response} res - The express response object to set the cookies on
|
||||
* @param {Object} freeTier - The free tier object
|
||||
* @returns
|
||||
* @returns
|
||||
*/
|
||||
const setAccessCookies = function setAccessCookies(member, req, res, freeTier) {
|
||||
if (!member) {
|
||||
|
@ -157,6 +157,37 @@ const getIdentityToken = async function getIdentityToken(req, res) {
|
|||
}
|
||||
};
|
||||
|
||||
const createIntegrityToken = async function createIntegrityToken(req, res) {
|
||||
try {
|
||||
const token = membersService.requestIntegrityTokenProvider.create();
|
||||
res.writeHead(200);
|
||||
res.end(token);
|
||||
} catch (err) {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
}
|
||||
};
|
||||
|
||||
const verifyIntegrityToken = async function verifyIntegrityToken(req, res, next) {
|
||||
try {
|
||||
const token = req.query.requestIntegrityToken;
|
||||
if (!token) {
|
||||
logging.warn('Request with missing integrity token.');
|
||||
// In future this will throw an error
|
||||
return next();
|
||||
}
|
||||
if (membersService.requestIntegrityTokenProvider.validate(token)) {
|
||||
return next();
|
||||
} else {
|
||||
logging.warn('Request with invalid integrity token.');
|
||||
// In future this will throw an error
|
||||
return next();
|
||||
}
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSession = async function deleteSession(req, res) {
|
||||
try {
|
||||
await membersService.ssr.deleteSession(req, res);
|
||||
|
@ -397,5 +428,7 @@ module.exports = {
|
|||
updateMemberNewsletters,
|
||||
deleteSession,
|
||||
accessInfoSession,
|
||||
deleteSuppression
|
||||
deleteSuppression,
|
||||
createIntegrityToken,
|
||||
verifyIntegrityToken
|
||||
};
|
||||
|
|
|
@ -19,6 +19,7 @@ const tiersService = require('../tiers');
|
|||
const VerificationTrigger = require('@tryghost/verification-trigger');
|
||||
const DatabaseInfo = require('@tryghost/database-info');
|
||||
const settingsHelpers = require('../settings-helpers');
|
||||
const RequestIntegrityTokenProvider = require('./RequestIntegrityTokenProvider');
|
||||
|
||||
const messages = {
|
||||
noLiveKeysInDevelopment: 'Cannot use live stripe keys in development. Please restart in production mode.',
|
||||
|
@ -193,6 +194,11 @@ module.exports = {
|
|||
ssr: null,
|
||||
verificationTrigger: null,
|
||||
|
||||
requestIntegrityTokenProvider: new RequestIntegrityTokenProvider({
|
||||
themeSecret: settingsCache.get('theme_session_secret'),
|
||||
tokenDuration: 1000 * 60 * 5
|
||||
}),
|
||||
|
||||
stripeConnect: require('./stripe-connect'),
|
||||
|
||||
processImport: null,
|
||||
|
|
|
@ -70,10 +70,13 @@ module.exports = function setupMembersApp() {
|
|||
membersApp.get('/api/session', middleware.getIdentityToken);
|
||||
membersApp.delete('/api/session', bodyParser.json({limit: '5mb'}), middleware.deleteSession);
|
||||
|
||||
membersApp.get('/api/integrity-token', middleware.createIntegrityToken);
|
||||
|
||||
// NOTE: this is wrapped in a function to ensure we always go via the getter
|
||||
membersApp.post(
|
||||
'/api/send-magic-link',
|
||||
bodyParser.json(),
|
||||
middleware.verifyIntegrityToken,
|
||||
// Prevent brute forcing email addresses (user enumeration)
|
||||
shared.middleware.brute.membersAuthEnumeration,
|
||||
// Prevent brute forcing passwords for the same email address
|
||||
|
|
Loading…
Add table
Reference in a new issue