fix(i18n): redirect based on Referer header (#40512)

Co-authored-by: Oliver Eyton-Williams <ojeytonwilliams@gmail.com>
This commit is contained in:
Mrugesh Mohapatra
2020-12-21 22:23:06 +05:30
committed by Mrugesh Mohapatra
parent a3b1b52cdd
commit 77c46dba2c
10 changed files with 162 additions and 78 deletions

View File

@ -16,6 +16,7 @@ import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware';
import { wrapHandledError } from '../utils/create-handled-error.js';
import { removeCookies } from '../utils/getSetAccessToken';
import { decodeEmail } from '../../common/utils';
import { getParamsFromReq } from '../utils/get-return-to';
const isSignUpDisabled = !!process.env.DISABLE_SIGNUP;
if (isSignUpDisabled) {
@ -56,7 +57,8 @@ module.exports = function enableAuthentication(app) {
);
} else {
api.get('/signin', ifUserRedirect, (req, res, next) => {
const state = jwt.sign({ returnTo: req.query.returnTo }, jwtSecret);
const { returnTo, origin, pathPrefix } = getParamsFromReq(req);
const state = jwt.sign({ returnTo, origin, pathPrefix }, jwtSecret);
return passport.authenticate('auth0-login', { state })(req, res, next);
});

View File

@ -12,12 +12,15 @@ import { ObjectID } from 'mongodb';
import isNumeric from 'validator/lib/isNumeric';
import isURL from 'validator/lib/isURL';
import { homeLocation } from '../../../config/env';
import { ifNoUserSend } from '../utils/middleware';
import { dasherize } from '../../../utils/slugs';
import { fixCompletedChallengeItem } from '../../common/utils';
import { getChallenges } from '../utils/get-curriculum';
import {
getParamsFromReq,
getRedirectBase,
normalizeParams
} from '../utils/get-return-to';
const log = debug('fcc:boot:challenges');
@ -29,7 +32,9 @@ export default async function bootChallenge(app, done) {
await getChallenges()
);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver
challengeUrlResolver,
normalizeParams,
getParamsFromReq
);
api.post(
@ -59,7 +64,6 @@ export default async function bootChallenge(app, done) {
app.use(router);
done();
}
const learnURL = `${homeLocation}/learn`;
const jsProjects = [
'aaa48de84e1ecc7c742e1124',
@ -327,15 +331,22 @@ function backendChallengeCompleted(req, res, next) {
.subscribe(() => {}, next);
}
// TODO: extend tests to cover www.freecodecamp.org/language and
// chinese.freecodecamp.org
export function createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation = homeLocation, _learnUrl = learnURL } = {}
normalizeParams,
getParamsFromReq
) {
return async function redirectToCurrentChallenge(req, res, next) {
const { user } = req;
const { origin, pathPrefix } = normalizeParams(getParamsFromReq(req));
const redirectBase = getRedirectBase(origin, pathPrefix);
if (!user) {
return res.redirect(_learnUrl);
return res.redirect(redirectBase + '/learn');
}
const challengeId = user && user.currentChallengeId;
const challengeUrl = await challengeUrlResolver(challengeId).catch(next);
if (challengeUrl === '/learn') {
@ -346,6 +357,6 @@ export function createRedirectToCurrentChallenge(
db may not be properly seeded.
`);
}
return res.redirect(`${_homeLocation}${challengeUrl}`);
return res.redirect(`${redirectBase}${challengeUrl}`);
};
}

View File

@ -330,11 +330,18 @@ describe('boot/challenge', () => {
describe('redirectToCurrentChallenge', () => {
const mockHomeLocation = 'https://www.example.com';
const mockLearnUrl = `${mockHomeLocation}/learn`;
const mockgetParamsFromReq = () => ({
returnTo: mockLearnUrl,
origin: mockHomeLocation,
pathPrefix: ''
});
const mockNormalizeParams = params => params;
it('redirects to the learn base url for non-users', async done => {
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
() => {},
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq();
const res = mockRes();
@ -356,7 +363,8 @@ describe('boot/challenge', () => {
const expectedUrl = `${mockHomeLocation}${requestedChallengeUrl}`;
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq({
user: mockUser
@ -379,7 +387,8 @@ describe('boot/challenge', () => {
);
const redirectToCurrentChallenge = createRedirectToCurrentChallenge(
challengeUrlResolver,
{ _homeLocation: mockHomeLocation, _learnUrl: mockLearnUrl }
mockNormalizeParams,
mockgetParamsFromReq
);
const req = mockReq({
user: { ...mockUser, currentChallengeId: '' }

View File

@ -4,15 +4,18 @@ import {
// prettier ignore
PassportConfigurator
} from '@freecodecamp/loopback-component-passport';
import url from 'url';
import dedent from 'dedent';
import { getUserById } from './utils/user-stats';
import { homeLocation } from '../../config/env';
import passportProviders from './passport-providers';
import { setAccessTokenToResponse } from './utils/getSetAccessToken';
import { jwtSecret } from '../../config/secrets';
import getReturnTo from './utils/get-return-to';
import {
getReturnTo,
getRedirectBase,
getParamsFromReq,
isRootPath
} from './utils/get-return-to';
const passportOptions = {
emailOptional: true,
@ -82,18 +85,12 @@ export const devSaveResponseAuthCookies = () => {
export const devLoginRedirect = () => {
return (req, res) => {
const successRedirect = req => {
if (req && req.query && req.query.returnTo) {
return req.query.returnTo;
}
return `${homeLocation}/learn`;
};
let redirect = url.parse(successRedirect(req), true);
delete redirect.search;
redirect = url.format(redirect);
return res.redirect(redirect);
// this mirrors the production approach, but without any validation
let { returnTo, origin, pathPrefix } = getParamsFromReq(req);
returnTo += isRootPath(getRedirectBase(origin, pathPrefix), returnTo)
? '/learn'
: '';
return res.redirect(returnTo);
};
};
@ -102,14 +99,6 @@ export const createPassportCallbackAuthenticator = (strategy, config) => (
res,
next
) => {
const state = req && req.query && req.query.state;
const { returnTo } = getReturnTo(state, jwtSecret);
// TODO: getReturnTo returns a {returnTo, success} object, so we can use
// 'success' to show a flash message, but currently it immediately gets
// overwritten by a second message. We should either change the message if
// !success or allow multiple messages to appear at once.
return passport.authenticate(
strategy,
{ session: false },
@ -144,12 +133,22 @@ we recommend using your email address: ${user.email} to sign in instead.
setAccessTokenToResponse({ accessToken }, req, res);
req.login(user);
}
// TODO: handle returning to /email-sign-up without relying on
// homeLocation
const state = req && req.query && req.query.state;
// returnTo, origin and pathPrefix are audited by getReturnTo
let { returnTo, origin, pathPrefix } = getReturnTo(state, jwtSecret);
const redirectBase = getRedirectBase(origin, pathPrefix);
// TODO: getReturnTo could return a success flag to show a flash message,
// but currently it immediately gets overwritten by a second message. We
// should either change the message if the flag is present or allow
// multiple messages to appear at once.
if (user.acceptedPrivacyTerms) {
returnTo += isRootPath(redirectBase, returnTo) ? '/learn' : '';
return res.redirectWithFlash(returnTo);
} else {
return res.redirectWithFlash(`${homeLocation}/email-sign-up`);
return res.redirectWithFlash(`${redirectBase}/email-sign-up`);
}
}
)(req, res, next);

View File

@ -1,23 +1,84 @@
const jwt = require('jsonwebtoken');
const { availableLangs } = require('../../../client/i18n/allLangs');
const { allowedOrigins } = require('../../../config/cors-settings');
// homeLocation is being used as a fallback, here. If the one provided by the
// client is invalid we default to this.
const { homeLocation } = require('../../../config/env.json');
function getReturnTo(encryptedReturnTo, secret) {
let returnTo;
let success = false;
let params;
try {
returnTo = jwt.verify(encryptedReturnTo, secret).returnTo;
params = jwt.verify(encryptedReturnTo, secret);
} catch (e) {
// TODO: report to Sentry? Probably not. Remove entirely?
console.log(e);
// something went wrong, use default params
params = {
returnTo: `${homeLocation}/learn`,
origin: homeLocation,
pathPrefix: ''
};
}
return normalizeParams(params);
}
// TODO: tests!
function normalizeParams({ returnTo, origin, pathPrefix }) {
// coerce to strings, just in case something weird and nefarious is happening
returnTo = '' + returnTo;
origin = '' + origin;
pathPrefix = '' + pathPrefix;
// we add the '/' to prevent returns to
// www.freecodecamp.org.somewhere.else.com
if (!allowedOrigins.some(origin => returnTo.startsWith(origin + '/'))) {
throw Error();
}
success = true;
} catch {
if (
!returnTo ||
!allowedOrigins.some(allowed => returnTo.startsWith(allowed + '/'))
) {
returnTo = `${homeLocation}/learn`;
}
return { returnTo, success };
// this can be strict equality.
if (!origin || !allowedOrigins.includes(origin)) {
origin = homeLocation;
}
// default to '' if the locale isn't recognised
pathPrefix = availableLangs.client.includes(pathPrefix) ? pathPrefix : '';
return { returnTo, origin, pathPrefix };
}
module.exports = getReturnTo;
// TODO: use this to redirect to current challenge
// TODO: tests!
// TODO: ensure origin and pathPrefix validation happens first
// (it needs a dedicated function that can be called from here and getReturnTo)
function getRedirectBase(origin, pathPrefix) {
const redirectPathSegment = pathPrefix ? `/${pathPrefix}` : '';
return `${origin}${redirectPathSegment}`;
}
// TODO: this might be cleaner if we just use a URL for returnTo (call it
// returnURL for clarity) rather than pulling out origin and returning it
// separately
function getParamsFromReq(req) {
const url = req.header('Referer');
// since we do not always redirect the user back to the page they were on
// we need client locale and origin to construct the redirect url.
const returnUrl = new URL(url);
const origin = returnUrl.origin;
// if this is not one of the client languages, validation will convert
// this to '' before it is used.
const pathPrefix = returnUrl.pathname.split('/')[0];
return { returnTo: returnUrl.href, origin, pathPrefix };
}
function isRootPath(redirectBase, returnUrl) {
const base = new URL(redirectBase);
const url = new URL(returnUrl);
return base.pathname === url.pathname;
}
module.exports.getReturnTo = getReturnTo;
module.exports.getRedirectBase = getRedirectBase;
module.exports.normalizeParams = normalizeParams;
module.exports.getParamsFromReq = getParamsFromReq;
module.exports.isRootPath = isRootPath;

View File

@ -3,13 +3,21 @@
const { homeLocation } = require('../../../config/env.json');
const jwt = require('jsonwebtoken');
const getReturnTo = require('./get-return-to');
const { getReturnTo } = require('./get-return-to');
const validJWTSecret = 'this is a super secret string';
const invalidJWTSecret = 'This is not correct secret';
const validReturnTo = 'https://www.freecodecamp.org/settings';
const invalidReturnTo = 'https://www.freecodecamp.org.fake/settings';
const defaultReturnTo = `${homeLocation}/learn`;
const defaultOrigin = homeLocation;
const defaultPrefix = '';
const defaultObject = {
returnTo: defaultReturnTo,
origin: defaultOrigin,
pathPrefix: defaultPrefix
};
describe('get-return-to', () => {
describe('getReturnTo', () => {
@ -21,8 +29,8 @@ describe('get-return-to', () => {
validJWTSecret
);
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual({
returnTo: validReturnTo,
success: true
...defaultObject,
returnTo: validReturnTo
});
});
@ -33,10 +41,9 @@ describe('get-return-to', () => {
{ returnTo: validReturnTo },
invalidJWTSecret
);
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual({
returnTo: defaultReturnTo,
success: false
});
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual(
defaultObject
);
});
it('should return a default url for unknown origins', () => {
@ -45,10 +52,9 @@ describe('get-return-to', () => {
{ returnTo: invalidReturnTo },
validJWTSecret
);
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual({
returnTo: defaultReturnTo,
success: false
});
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual(
defaultObject
);
});
});
});

View File

@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
import { Grid, Button } from '@freecodecamp/react-bootstrap';
import Helmet from 'react-helmet';
import { apiLocation, homeLocation } from '../../config/env.json';
import { apiLocation } from '../../config/env.json';
import {
signInLoadingSelector,
userSelector,
@ -169,7 +169,7 @@ export function ShowSettings(props) {
}
if (!isSignedIn) {
navigate(`${apiLocation}/signin?returnTo=${homeLocation}/settings`);
navigate(`${apiLocation}/signin`);
return <Loader fullScreen={true} />;
}

View File

@ -1,7 +1,7 @@
/* global jest, expect */
import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import { apiLocation, homeLocation } from '../../config/env.json';
import { apiLocation } from '../../config/env.json';
import { ShowSettings } from './ShowSettings';
@ -22,9 +22,7 @@ describe('<ShowSettings />', () => {
const shallow = new ShallowRenderer();
shallow.render(<ShowSettings {...loggedOutProps} />);
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`${apiLocation}/signin?returnTo=${homeLocation}/settings`
);
expect(navigate).toHaveBeenCalledWith(`${apiLocation}/signin`);
const result = shallow.getRenderOutput();
// Renders Loader rather than ShowSettings
expect(result.type.displayName).toBe('Loader');

View File

@ -27,9 +27,7 @@ function Login(props) {
children,
isSignedIn
} = props;
const href = isSignedIn
? `${homeLocation}/learn`
: `${apiLocation}/signin?returnTo=${homeLocation}/learn`;
const href = isSignedIn ? `${homeLocation}/learn` : `${apiLocation}/signin`;
return (
<Button
bsStyle='default'

View File

@ -11,10 +11,10 @@ if (process.env.PIPELINE_ENV !== 'true') {
}
const {
HOME_LOCATION: home,
API_LOCATION: api,
FORUM_LOCATION: forum,
NEWS_LOCATION: news,
HOME_LOCATION: homeLocation,
API_LOCATION: apiLocation,
FORUM_LOCATION: forumLocation,
NEWS_LOCATION: newsLocation,
CLIENT_LOCALE: clientLocale,
CURRICULUM_LOCALE: curriculumLocale,
SHOW_LOCALE_DROPDOWN_MENU: showLocaleDropdownMenu,
@ -27,10 +27,10 @@ const {
} = process.env;
const locations = {
homeLocation: home,
apiLocation: api,
forumLocation: forum,
newsLocation: news
homeLocation,
apiLocation,
forumLocation,
newsLocation
};
module.exports = Object.assign(locations, {