Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: store spring csrf to bootstrap page #10577

Merged
merged 19 commits into from
Apr 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { MiddlewareClass, MiddlewareContext, MiddlewareNext } from './Connect';

const $wnd = window as any;

export interface LoginResult {
error: boolean;
token?: string;
Expand Down Expand Up @@ -31,7 +33,8 @@ export async function login(username: string, password: string, options?: LoginO
data.append('password', password);

const loginProcessingUrl = options && options.loginProcessingUrl ? options.loginProcessingUrl : '/login';
const response = await fetch(loginProcessingUrl, { method: 'POST', body: data });
const headers = getSpringCsrfTokenHeadersFromDocument(document);
const response = await fetch(loginProcessingUrl, { method: 'POST', body: data, headers });

const failureUrl = options && options.failureUrl ? options.failureUrl : '/login?error';
const defaultSuccessUrl = options && options.defaultSuccessUrl ? options.defaultSuccessUrl : '/';
Expand All @@ -43,17 +46,13 @@ export async function login(username: string, password: string, options?: LoginO
errorMessage: 'Check that you have entered the correct username and password and try again.'
};
} else if (response.ok && response.redirected && response.url.endsWith(defaultSuccessUrl)) {
// TODO: find a more efficient way to get a new CSRF token
// parsing the full response body just to get a token may be wasteful
const token = getCsrfTokenFromResponseBody(await response.text());
if (token) {
(window as any).Vaadin.TypeScript = (window as any).Vaadin.TypeScript || {};
(window as any).Vaadin.TypeScript.csrfToken = token;
const vaadinCsrfToken = await updateCsrfTokensBasedOnResponse(response);
if (vaadinCsrfToken) {
result = {
error: false,
errorTitle: '',
errorMessage: '',
token
token: vaadinCsrfToken
};
}
}
Expand Down Expand Up @@ -81,25 +80,72 @@ export async function login(username: string, password: string, options?: LoginO
export async function logout(options?: LogoutOptions) {
// this assumes the default Spring Security logout configuration (handler URL)
const logoutUrl = options && options.logoutUrl ? options.logoutUrl : '/logout';

try {
const response = await fetch(logoutUrl);
// TODO: find a more efficient way to get a new CSRF token
// parsing the full response body just to get a token may be wasteful
const token = getCsrfTokenFromResponseBody(await response.text());
(window as any).Vaadin.TypeScript.csrfToken = token;
} catch (error) {
// clear the token if the call fails
delete (window as any).Vaadin.TypeScript.csrfToken;
throw error;
const headers = getSpringCsrfTokenHeadersFromDocument(document);
await doLogout(logoutUrl, headers);
} catch {
try {
const response = await fetch('?nocache');
const responseText = await response.text();
const doc = new DOMParser().parseFromString(responseText, 'text/html');
const headers = getSpringCsrfTokenHeadersFromDocument(doc);
await doLogout(logoutUrl, headers);
} catch (error) {
// clear the token if the call fails
delete $wnd.Vaadin?.TypeScript?.csrfToken;
clearSpringCsrfMetaTags();
throw error;
}
}
}

async function doLogout(logoutUrl: string, headers: Record<string, string>) {
const response = await fetch(logoutUrl, { method: 'POST', headers });
if (!response.ok) {
throw new Error(`failed to logout with response ${response.status}`);
}

await updateCsrfTokensBasedOnResponse(response);
}

function updateSpringCsrfMetaTag(body: string) {
const doc = new DOMParser().parseFromString(body, 'text/html');
clearSpringCsrfMetaTags();
Array.from(doc.head.querySelectorAll('meta[name="_csrf"], meta[name="_csrf_header"]')).forEach((el) =>
document.head.appendChild(document.importNode(el, true))
);
}

function clearSpringCsrfMetaTags() {
Array.from(document.head.querySelectorAll('meta[name="_csrf"], meta[name="_csrf_header"]')).forEach((el) =>
el.remove()
);
}

const getCsrfTokenFromResponseBody = (body: string): string | undefined => {
const match = body.match(/window\.Vaadin = \{TypeScript: \{"csrfToken":"([0-9a-zA-Z\\-]{36})"}};/i);
return match ? match[1] : undefined;
};

const getSpringCsrfTokenHeadersFromDocument = (doc: Document): Record<string, string> => {
const csrf = doc.head.querySelector('meta[name="_csrf"]');
const csrfHeader = doc.head.querySelector('meta[name="_csrf_header"]');
const headers: Record<string, string> = {};
if (csrf !== null && csrfHeader !== null) {
headers[(csrfHeader as HTMLMetaElement).content] = (csrf as HTMLMetaElement).content;
}
return headers;
};

async function updateCsrfTokensBasedOnResponse(response: Response): Promise<string | undefined> {
const responseText = await response.text();
const token = getCsrfTokenFromResponseBody(responseText);
$wnd.Vaadin.TypeScript = $wnd.Vaadin.TypeScript || {};
$wnd.Vaadin.TypeScript.csrfToken = token;
updateSpringCsrfMetaTag(responseText);
return token;
}

/**
* It defines what to do when it detects a session is invalid. E.g.,
* show a login view.
Expand Down
154 changes: 134 additions & 20 deletions flow-client/src/test/frontend/AuthenticationTests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const {describe, it, beforeEach, afterEach, after} = intern.getPlugin('interface.bdd');
const {describe, it, beforeEach, afterEach} = intern.getPlugin('interface.bdd');
const {expect} = intern.getPlugin('chai');
const {fetchMock} = intern.getPlugin('fetchMock');
const {sinon} = intern.getPlugin('sinon');
Expand All @@ -8,24 +8,55 @@ import { ConnectClient, InvalidSessionMiddleware, login, logout } from "../../ma
// `connectClient.call` adds the host and context to the endpoint request.
// we need to add this origin when configuring fetch-mock
const base = window.location.origin;
const $wnd = window as any;


/* global btoa localStorage setTimeout URLSearchParams Request Response */
describe('Authentication', () => {
const csrf = 'spring-csrf-token';
const headerName = 'X-CSRF-TOKEN';
const headers: Record<string, string> = {};
const vaadinCsrfToken = '6a60700e-852b-420f-a126-a1c61b73d1ba';
const happyCaseResponseText = '<head><meta name="_csrf" content="spring-csrf-token"></meta><meta name="_csrf_header" content="X-CSRF-TOKEN"></meta></head><script>window.Vaadin = {TypeScript: {"csrfToken":"'+vaadinCsrfToken+'"}};</script>';
function clearSpringCsrfMetaTags() {
Array.from(document.head.querySelectorAll('meta[name="_csrf"], meta[name="_csrf_header"]'))
.forEach(el => el.remove());
}
function setupSpringCsrfMetaTags(csrfToken = csrf) {
let csrfMetaTag = document.head.querySelector('meta[name="_csrf"]') as HTMLMetaElement | null;
let csrfHeaderNameMetaTag = document.head.querySelector('meta[name="_csrf_header"]') as HTMLMetaElement | null;

if (!csrfMetaTag) {
csrfMetaTag = document.createElement('meta');
csrfMetaTag.name = '_csrf';
document.head.appendChild(csrfMetaTag);
}
csrfMetaTag.content = csrfToken;

after(() => {
if (!csrfHeaderNameMetaTag) {
csrfHeaderNameMetaTag = document.createElement('meta');
csrfHeaderNameMetaTag.name = '_csrf_header';
document.head.appendChild(csrfHeaderNameMetaTag);
}
csrfHeaderNameMetaTag.content = headerName;
}
beforeEach( ()=> {
setupSpringCsrfMetaTags();
headers[headerName]=csrf;
});
afterEach(() => {
// @ts-ignore
delete window.Vaadin.TypeScript;
clearSpringCsrfMetaTags();
});

describe('login', () => {
beforeEach(() => fetchMock
.post(base + '/connect/FooEndpoint/fooMethod', {fooData: 'foo'})
);

afterEach(() => fetchMock.restore());
afterEach(() => {
fetchMock.restore();
});

it('should return an error on invalid credentials', async () => {
fetchMock.post('/login', { redirectUrl: '/login?error' });
fetchMock.post('/login', { redirectUrl: '/login?error' }, { headers });
const result = await login('invalid-username', 'invalid-password');
const expectedResult = {
error: true,
Expand All @@ -39,15 +70,15 @@ describe('Authentication', () => {

it('should return a CSRF token on valid credentials', async () => {
fetchMock.post('/login', {
body: 'window.Vaadin = {TypeScript: {"csrfToken":"6a60700e-852b-420f-a126-a1c61b73d1ba"}};',
body: happyCaseResponseText,
redirectUrl: '/'
});
}, { headers });
const result = await login('valid-username', 'valid-password');
const expectedResult = {
error: false,
errorTitle: '',
errorMessage: '',
token: '6a60700e-852b-420f-a126-a1c61b73d1ba'
token: vaadinCsrfToken
};

expect(fetchMock.calls()).to.have.lengthOf(1);
Expand All @@ -63,7 +94,7 @@ describe('Authentication', () => {
statusText: 'Internal Server Error'
}
);
fetchMock.post('/login', errorResponse);
fetchMock.post('/login', errorResponse, { headers });
const result = await login('valid-username', 'valid-password');
const expectedResult = {
error: true,
Expand All @@ -77,30 +108,113 @@ describe('Authentication', () => {
});

describe("logout", () => {
beforeEach(() => {
$wnd.Vaadin.TypeScript = {};
$wnd.Vaadin.TypeScript.csrfToken = vaadinCsrfToken;
});
afterEach(() => fetchMock.restore());

function verifySpringCsrfTokenIsCleared() {
expect(document.head.querySelector('meta[name="_csrf"]')).to.be.null;
expect(document.head.querySelector('meta[name="_csrf_header"]')).to.be.null;
}

it('should set the csrf token on logout', async () => {
fetchMock.get('/logout', {
body: 'window.Vaadin = {TypeScript: {"csrfToken":"6a60700e-852b-420f-a126-a1c61b73d1ba"}};',
fetchMock.post('/logout', {
body: happyCaseResponseText,
redirectUrl: '/logout?login'
});
}, { headers });
await logout();
expect(fetchMock.calls()).to.have.lengthOf(1);
expect((window as any).Vaadin.TypeScript.csrfToken).to.equal("6a60700e-852b-420f-a126-a1c61b73d1ba");
expect($wnd.Vaadin.TypeScript.csrfToken).to.equal(vaadinCsrfToken);
});

it('should clear the csrf token on failed server logout', async () => {
it('should clear the csrf tokens on failed server logout', async () => {
const fakeError = new Error('unable to connect');
fetchMock.get('/logout', () => {
fetchMock.post('/logout', () => {
throw fakeError;
}, { headers });
fetchMock.get('?nocache', {
body: happyCaseResponseText
});
try {
await logout();
} catch (err) {
expect(err).to.equal(fakeError);
}
expect(fetchMock.calls()).to.have.lengthOf(1);
expect((window as any).Vaadin.TypeScript.csrfToken).to.be.undefined
expect(fetchMock.calls()).to.have.lengthOf(3);
expect($wnd.Vaadin.TypeScript.csrfToken).to.be.undefined
verifySpringCsrfTokenIsCleared();
});

// when started the app offline, the spring csrf meta tags are not available
it('should retry when no spring csrf metas in the doc', async () => {
clearSpringCsrfMetaTags();

verifySpringCsrfTokenIsCleared();
fetchMock.post('/logout', 403, {repeat: 1});
fetchMock.get('?nocache', {
body: happyCaseResponseText
});
fetchMock.post('/logout', {
body: happyCaseResponseText,
redirectUrl: '/logout?login'
}, { headers, overwriteRoutes: false, repeat: 1});
await logout();
expect(fetchMock.calls()).to.have.lengthOf(3);
expect($wnd.Vaadin.TypeScript.csrfToken).to.equal(vaadinCsrfToken);
expect(document.head.querySelector('meta[name="_csrf"]')?.getAttribute('content')).to.equal(csrf);
expect(document.head.querySelector('meta[name="_csrf_header"]')?.getAttribute('content')).to.equal(headerName);
});

// when started the app offline, the spring csrf meta tags are not available
it('should retry when no spring csrf metas in the doc and clear the csrf token on failed server logout with the retry', async () => {
clearSpringCsrfMetaTags();

expect(document.head.querySelector('meta[name="_csrf"]')).to.be.null;
expect(document.head.querySelector('meta[name="_csrf_header"]')).to.be.null;
fetchMock.post('/logout', 403, {repeat: 1});
fetchMock.get('?nocache', {
body: happyCaseResponseText,
});
const fakeError = new Error('server error');
fetchMock.post('/logout', () => {
throw fakeError;
}, { headers, overwriteRoutes: false, repeat: 1});

try {
await logout();
} catch (err) {
expect(err).to.equal(fakeError);
}
expect(fetchMock.calls()).to.have.lengthOf(3);
expect($wnd.Vaadin.TypeScript.csrfToken).to.be.undefined;

setupSpringCsrfMetaTags();
});

// when the page has been opend too long the session has expired
it('should retry when expired spring csrf metas in the doc', async () => {
const expiredSpringCsrfToken = 'expired-'+csrf;

setupSpringCsrfMetaTags(expiredSpringCsrfToken);

const headersWithExpiredSpringCsrfToken: Record<string, string> = {};
headersWithExpiredSpringCsrfToken[headerName] = expiredSpringCsrfToken;

fetchMock.post('/logout', 403, {headers: headersWithExpiredSpringCsrfToken, repeat: 1});
fetchMock.get('?nocache', {
body: happyCaseResponseText,
});
fetchMock.post('/logout', {
body: happyCaseResponseText,
redirectUrl: '/logout?login'
}, { headers, overwriteRoutes: false, repeat: 1});
await logout();
expect(fetchMock.calls()).to.have.lengthOf(3);
expect($wnd.Vaadin.TypeScript.csrfToken).to.equal(vaadinCsrfToken);
expect(document.head.querySelector('meta[name="_csrf"]')?.getAttribute('content')).to.equal(csrf);
expect(document.head.querySelector('meta[name="_csrf_header"]')?.getAttribute('content')).to.equal(headerName);
});
});

Expand Down
Loading