Added support in Portal for integrity tokens on magic link API

ref KTLO-1
These tokens should prevent untargeted attacks, as the magic link
endpoint needs a token that was generated by the server, similar to a
CSRF token, but without needing any server-side state, or a cookie to
be set for unauthenticated users.
This commit is contained in:
Sam Lord 2024-08-22 11:34:33 +01:00 committed by Sam Lord
parent a48b4e5cbf
commit ef4f79370f
7 changed files with 153 additions and 76 deletions

View File

@ -79,7 +79,8 @@ async function signout({api, state}) {
async function signin({data, api, state}) { async function signin({data, api, state}) {
try { try {
await api.member.sendMagicLink({...data, emailType: 'signin'}); const integrityToken = await api.member.getIntegrityToken();
await api.member.sendMagicLink({...data, emailType: 'signin', integrityToken});
return { return {
page: 'magiclink', page: 'magiclink',
lastPage: 'signin' lastPage: 'signin'
@ -100,7 +101,8 @@ async function signup({data, state, api}) {
let {plan, tierId, cadence, email, name, newsletters, offerId} = data; let {plan, tierId, cadence, email, name, newsletters, offerId} = data;
if (plan.toLowerCase() === 'free') { if (plan.toLowerCase() === 'free') {
await api.member.sendMagicLink({emailType: 'signup', ...data}); const integrityToken = await api.member.getIntegrityToken();
await api.member.sendMagicLink({emailType: 'signup', integrityToken, ...data});
} else { } else {
if (tierId && cadence) { if (tierId && cadence) {
await api.member.checkoutPlan({plan, tierId, cadence, email, name, newsletters, offerId}); await api.member.checkoutPlan({plan, tierId, cadence, email, name, newsletters, offerId});

View File

@ -56,12 +56,21 @@ export function formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}
} }
} }
fetch(`${siteUrl}/members/api/send-magic-link/`, { return fetch(`${siteUrl}/members/api/integrity-token/`, {
method: 'GET'
}).then((res) => {
return res.text();
}).then((integrityToken) => {
return fetch(`${siteUrl}/members/api/send-magic-link/`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify(reqBody) body: JSON.stringify({
...reqBody,
integrityToken
})
});
}).then(function (res) { }).then(function (res) {
form.addEventListener('submit', submitHandler); form.addEventListener('submit', submitHandler);
form.classList.remove('loading'); form.classList.remove('loading');

View File

@ -16,6 +16,10 @@ const setup = async ({site, member = null}) => {
return Promise.resolve('success'); return Promise.resolve('success');
}); });
ghostApi.member.getIntegrityToken = jest.fn(() => {
return Promise.resolve('testtoken');
});
ghostApi.member.checkoutPlan = jest.fn(() => { ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve(); return Promise.resolve();
}); });
@ -67,6 +71,10 @@ const multiTierSetup = async ({site, member = null}) => {
return Promise.resolve('success'); return Promise.resolve('success');
}); });
ghostApi.member.getIntegrityToken = jest.fn(() => {
return Promise.resolve(`testtoken`);
});
ghostApi.member.checkoutPlan = jest.fn(() => { ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve(); return Promise.resolve();
}); });
@ -139,13 +147,15 @@ describe('Signin', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin'
});
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i); const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument(); expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken'
});
}); });
test('without name field', async () => { test('without name field', async () => {
@ -165,13 +175,15 @@ describe('Signin', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin'
});
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i); const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument(); expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken'
});
}); });
test('with only free plan', async () => { test('with only free plan', async () => {
@ -191,13 +203,15 @@ describe('Signin', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin'
});
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i); const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument(); expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken'
});
}); });
}); });
}); });
@ -231,13 +245,15 @@ describe('Signin', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin'
});
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i); const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument(); expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken'
});
}); });
test('without name field', async () => { test('without name field', async () => {
@ -257,13 +273,15 @@ describe('Signin', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin'
});
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i); const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument(); expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken'
});
}); });
test('with only free plan available', async () => { test('with only free plan available', async () => {
@ -283,13 +301,15 @@ describe('Signin', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin'
});
const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i); const magicLink = await within(popupIframeDocument).findByText(/Now check your email/i);
expect(magicLink).toBeInTheDocument(); expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com',
emailType: 'signin',
integrityToken: 'testtoken'
});
}); });
}); });
}); });

View File

@ -16,6 +16,10 @@ const offerSetup = async ({site, member = null, offer}) => {
return Promise.resolve('success'); return Promise.resolve('success');
}); });
ghostApi.member.getIntegrityToken = jest.fn(() => {
return Promise.resolve(`testtoken`);
});
ghostApi.site.offer = jest.fn(() => { ghostApi.site.offer = jest.fn(() => {
return Promise.resolve({ return Promise.resolve({
offers: [offer] offers: [offer]
@ -80,6 +84,10 @@ const setup = async ({site, member = null}) => {
return Promise.resolve('success'); return Promise.resolve('success');
}); });
ghostApi.member.getIntegrityToken = jest.fn(() => {
return Promise.resolve(`testtoken`);
});
ghostApi.member.checkoutPlan = jest.fn(() => { ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve(); return Promise.resolve();
}); });
@ -133,6 +141,10 @@ const multiTierSetup = async ({site, member = null}) => {
return Promise.resolve('success'); return Promise.resolve('success');
}); });
ghostApi.member.getIntegrityToken = jest.fn(() => {
return Promise.resolve(`testtoken`);
});
ghostApi.member.checkoutPlan = jest.fn(() => { ghostApi.member.checkoutPlan = jest.fn(() => {
return Promise.resolve(); return Promise.resolve();
}); });
@ -205,14 +217,17 @@ describe('Signup', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen'); expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(chooseBtns[0]); fireEvent.click(chooseBtns[0]);
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({ expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
name: 'Jamie Larsen', name: 'Jamie Larsen',
plan: 'free' plan: 'free',
integrityToken: 'testtoken'
}); });
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
}); });
test('without name field', async () => { test('without name field', async () => {
@ -240,16 +255,17 @@ describe('Signup', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(chooseBtns[0]); fireEvent.click(chooseBtns[0]);
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({ expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
name: '', name: '',
plan: 'free' plan: 'free',
integrityToken: 'testtoken'
}); });
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
}); });
test('with only free plan', async () => { test('with only free plan', async () => {
@ -288,16 +304,17 @@ describe('Signup', () => {
expect(nameInput).toHaveValue('Jamie Larsen'); expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton); fireEvent.click(submitButton);
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({ expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
name: 'Jamie Larsen', name: 'Jamie Larsen',
plan: 'free' plan: 'free',
integrityToken: 'testtoken'
}); });
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
}); });
}); });
@ -570,14 +587,17 @@ describe('Signup', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
expect(nameInput).toHaveValue('Jamie Larsen'); expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(chooseBtns[0]); fireEvent.click(chooseBtns[0]);
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({ expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
name: 'Jamie Larsen', name: 'Jamie Larsen',
plan: 'free' plan: 'free',
integrityToken: 'testtoken'
}); });
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
}); });
test('without name field', async () => { test('without name field', async () => {
@ -601,16 +621,17 @@ describe('Signup', () => {
expect(emailInput).toHaveValue('jamie@example.com'); expect(emailInput).toHaveValue('jamie@example.com');
fireEvent.click(chooseBtns[0]); fireEvent.click(chooseBtns[0]);
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({ expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
name: '', name: '',
plan: 'free' plan: 'free',
integrityToken: 'testtoken'
}); });
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
}); });
test('with only free plan available', async () => { test('with only free plan available', async () => {
@ -646,16 +667,17 @@ describe('Signup', () => {
expect(nameInput).toHaveValue('Jamie Larsen'); expect(nameInput).toHaveValue('Jamie Larsen');
fireEvent.click(submitButton); fireEvent.click(submitButton);
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({ expect(ghostApi.member.sendMagicLink).toHaveBeenLastCalledWith({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
name: 'Jamie Larsen', name: 'Jamie Larsen',
plan: 'free' plan: 'free',
integrityToken: 'testtoken'
}); });
// Check if magic link page is shown
const magicLink = await within(popupIframeDocument).findByText(/now check your email/i);
expect(magicLink).toBeInTheDocument();
}); });
test('should not show free plan if it is hidden', async () => { test('should not show free plan if it is hidden', async () => {
@ -799,4 +821,3 @@ describe('Signup', () => {
}); });
}); });
}); });

View File

@ -86,6 +86,13 @@ describe('Member Data attributes:', () => {
}); });
} }
if (url.includes('api/integrity-token')) {
return Promise.resolve({
ok: true,
text: async () => 'testtoken'
});
}
if (url.includes('api/session')) { if (url.includes('api/session')) {
return Promise.resolve({ return Promise.resolve({
ok: true, ok: true,
@ -139,12 +146,12 @@ describe('Member Data attributes:', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
describe('data-members-form', () => { describe('data-members-form', () => {
test('allows free signup', () => { test('allows free signup', async () => {
const {event, form, errorEl, siteUrl, submitHandler} = getMockData(); const {event, form, errorEl, siteUrl, submitHandler} = getMockData();
formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}); await formSubmitHandler({event, form, errorEl, siteUrl, submitHandler});
expect(window.fetch).toHaveBeenCalledTimes(1); expect(window.fetch).toHaveBeenCalledTimes(2);
const expectedBody = JSON.stringify({ const expectedBody = JSON.stringify({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
@ -157,9 +164,10 @@ describe('Member Data attributes:', () => {
refSource: 'ghost-explore', refSource: 'ghost-explore',
refUrl: 'https://example.com/blog/', refUrl: 'https://example.com/blog/',
time: 1611234567890 time: 1611234567890
}] }],
integrityToken: 'testtoken'
}); });
expect(window.fetch).toHaveBeenCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'}); expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'});
}); });
}); });
@ -241,16 +249,16 @@ describe('Member Data attributes:', () => {
}); });
describe('data-members-newsletter', () => { describe('data-members-newsletter', () => {
test('includes specified newsletters in request', () => { test('includes specified newsletters in request', async () => {
const {event, form, errorEl, siteUrl, submitHandler} = getMockData({ const {event, form, errorEl, siteUrl, submitHandler} = getMockData({
newsletterQuerySelectorResult: [{ newsletterQuerySelectorResult: [{
value: 'Some Newsletter' value: 'Some Newsletter'
}] }]
}); });
formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}); await formSubmitHandler({event, form, errorEl, siteUrl, submitHandler});
expect(window.fetch).toHaveBeenCalledTimes(1); expect(window.fetch).toHaveBeenCalledTimes(2);
const expectedBody = JSON.stringify({ const expectedBody = JSON.stringify({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
@ -264,19 +272,20 @@ describe('Member Data attributes:', () => {
refUrl: 'https://example.com/blog/', refUrl: 'https://example.com/blog/',
time: 1611234567890 time: 1611234567890
}], }],
newsletters: [{name: 'Some Newsletter'}] newsletters: [{name: 'Some Newsletter'}],
integrityToken: 'testtoken'
}); });
expect(window.fetch).toHaveBeenCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'}); expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'});
}); });
test('does not include newsletters in request if there are no newsletter inputs', () => { test('does not include newsletters in request if there are no newsletter inputs', async () => {
const {event, form, errorEl, siteUrl, submitHandler} = getMockData({ const {event, form, errorEl, siteUrl, submitHandler} = getMockData({
newsletterQuerySelectorResult: [] newsletterQuerySelectorResult: []
}); });
formSubmitHandler({event, form, errorEl, siteUrl, submitHandler}); await formSubmitHandler({event, form, errorEl, siteUrl, submitHandler});
expect(window.fetch).toHaveBeenCalledTimes(1); expect(window.fetch).toHaveBeenCalledTimes(2);
const expectedBody = JSON.stringify({ const expectedBody = JSON.stringify({
email: 'jamie@example.com', email: 'jamie@example.com',
emailType: 'signup', emailType: 'signup',
@ -289,9 +298,10 @@ describe('Member Data attributes:', () => {
refSource: 'ghost-explore', refSource: 'ghost-explore',
refUrl: 'https://example.com/blog/', refUrl: 'https://example.com/blog/',
time: 1611234567890 time: 1611234567890
}] }],
integrityToken: 'testtoken'
}); });
expect(window.fetch).toHaveBeenCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'}); expect(window.fetch).toHaveBeenLastCalledWith('https://portal.localhost/members/api/send-magic-link/', {body: expectedBody, headers: {'Content-Type': 'application/json'}, method: 'POST'});
}); });
}); });
}); });

View File

@ -244,7 +244,21 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
}); });
}, },
async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters, redirect, customUrlHistory, autoRedirect = true}) { async getIntegrityToken() {
const url = endpointFor({type: 'members', resource: 'integrity-token'});
const res = await makeRequest({
url,
method: 'GET'
});
if (res.ok) {
return res.text();
} else {
throw new Error('Failed to start a members session');
}
},
async sendMagicLink({email, emailType, labels, name, oldEmail, newsletters, redirect, integrityToken, customUrlHistory, autoRedirect = true}) {
const url = endpointFor({type: 'members', resource: 'send-magic-link'}); const url = endpointFor({type: 'members', resource: 'send-magic-link'});
const body = { const body = {
name, name,
@ -255,6 +269,7 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
labels, labels,
requestSrc: 'portal', requestSrc: 'portal',
redirect, redirect,
integrityToken,
autoRedirect autoRedirect
}; };
const urlHistory = customUrlHistory ?? getUrlHistory(); const urlHistory = customUrlHistory ?? getUrlHistory();

View File

@ -170,7 +170,7 @@ const createIntegrityToken = async function createIntegrityToken(req, res) {
const verifyIntegrityToken = async function verifyIntegrityToken(req, res, next) { const verifyIntegrityToken = async function verifyIntegrityToken(req, res, next) {
try { try {
const token = req.query.requestIntegrityToken; const token = req.body.integrityToken;
if (!token) { if (!token) {
logging.warn('Request with missing integrity token.'); logging.warn('Request with missing integrity token.');
// In future this will throw an error // In future this will throw an error