feat(api): allow redirects with a returnTo param (#40161)

This commit is contained in:
Oliver Eyton-Williams
2020-11-07 09:05:25 +01:00
committed by GitHub
parent 8fd00afd9c
commit b2e2f33cf1
8 changed files with 111 additions and 76 deletions

View File

@ -2,12 +2,15 @@ import passport from 'passport';
import dedent from 'dedent';
import { check } from 'express-validator/check';
import { isEmail } from 'validator';
import jwt from 'jsonwebtoken';
import { homeLocation } from '../../../config/env';
import { jwtSecret } from '../../../config/secrets';
import {
createPassportCallbackAuthenticator,
saveResponseAuthCookies,
loginRedirect
devSaveResponseAuthCookies,
devLoginRedirect
} from '../component-passport';
import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware';
import { wrapHandledError } from '../utils/create-handled-error.js';
@ -37,8 +40,8 @@ module.exports = function enableAuthentication(app) {
app.enableAuth();
const ifUserRedirect = ifUserRedirectTo();
const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation);
const saveAuthCookies = saveResponseAuthCookies();
const loginSuccessRedirect = loginRedirect();
const devSaveAuthCookies = devSaveResponseAuthCookies();
const devLoginSuccessRedirect = devLoginRedirect();
const api = app.loopback.Router();
// Use a local mock strategy for signing in if we are in dev mode.
@ -48,26 +51,14 @@ module.exports = function enableAuthentication(app) {
api.get(
'/signin',
passport.authenticate('devlogin'),
saveAuthCookies,
loginSuccessRedirect
devSaveAuthCookies,
devLoginSuccessRedirect
);
} else {
api.get(
'/signin',
(req, res, next) => {
if (req && req.query && req.query.returnTo) {
req.query.returnTo = `${homeLocation}/${req.query.returnTo}`;
}
return next();
},
ifUserRedirect,
(req, res, next) => {
const state = req.query.returnTo
? Buffer.from(req.query.returnTo).toString('base64')
: null;
return passport.authenticate('auth0-login', { state })(req, res, next);
}
);
api.get('/signin', ifUserRedirect, (req, res, next) => {
const state = jwt.sign({ returnTo: req.query.returnTo }, jwtSecret);
return passport.authenticate('auth0-login', { state })(req, res, next);
});
api.get(
'/auth/auth0/callback',

View File

@ -11,6 +11,8 @@ 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';
const passportOptions = {
emailOptional: true,
@ -63,7 +65,7 @@ export function setupPassport(app) {
});
}
export const saveResponseAuthCookies = () => {
export const devSaveResponseAuthCookies = () => {
return (req, res, next) => {
const user = req.user;
@ -78,12 +80,11 @@ export const saveResponseAuthCookies = () => {
};
};
export const loginRedirect = () => {
export const devLoginRedirect = () => {
return (req, res) => {
const successRedirect = req => {
if (!!req && req.session && req.session.returnTo) {
delete req.session.returnTo;
return `${homeLocation}/learn`;
if (req && req.query && req.query.returnTo) {
return req.query.returnTo;
}
return `${homeLocation}/learn`;
};
@ -101,10 +102,14 @@ export const createPassportCallbackAuthenticator = (strategy, config) => (
res,
next
) => {
const returnTo =
req && req.query && req.query.state
? Buffer.from(req.query.state, 'base64').toString('utf-8')
: `${homeLocation}/learn`;
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 },
@ -116,7 +121,6 @@ export const createPassportCallbackAuthenticator = (strategy, config) => (
if (!user || !userInfo) {
return res.redirect('/signin');
}
const redirect = `${returnTo}`;
const { accessToken } = userInfo;
const { provider } = config;
@ -140,9 +144,10 @@ we recommend using your email address: ${user.email} to sign in instead.
setAccessTokenToResponse({ accessToken }, req, res);
req.login(user);
}
// TODO: enable 'returnTo' for sign-up
// TODO: handle returning to /email-sign-up without relying on
// homeLocation
if (user.acceptedPrivacyTerms) {
return res.redirectWithFlash(redirect);
return res.redirectWithFlash(returnTo);
} else {
return res.redirectWithFlash(`${homeLocation}/email-sign-up`);
}

View File

@ -29,7 +29,6 @@
"auth:before": {
"express-flash": {},
"./middlewares/express-extensions": {},
"./middlewares/add-return-to": {},
"./middlewares/cookie-parser": {},
"./middlewares/request-authorization": {}
},

View File

@ -1,37 +0,0 @@
const pathsOfNoReturn = [
'link',
'auth',
'login',
'logout',
'signin',
'signup',
'fonts',
'favicon',
'js',
'css'
];
const pathsAllowedList = ['challenges', 'map', 'commit'];
const pathsOfNoReturnRegex = new RegExp(pathsOfNoReturn.join('|'), 'i');
const pathsAllowedRegex = new RegExp(pathsAllowedList.join('|'), 'i');
export default function addReturnToUrl() {
return function(req, res, next) {
// Remember original destination before login.
var path = req.path.split('/')[1];
if (
req.method !== 'GET' ||
pathsOfNoReturnRegex.test(path) ||
!pathsAllowedRegex.test(path) ||
/hot/i.test(req.path)
) {
return next();
}
req.session.returnTo = req.originalUrl.includes('/map')
? '/'
: req.originalUrl;
return next();
};
}

View File

@ -0,0 +1,23 @@
const jwt = require('jsonwebtoken');
const { allowedOrigins } = require('../../../config/cors-settings');
const { homeLocation } = require('../../../config/env.json');
function getReturnTo(encryptedReturnTo, secret) {
let returnTo;
let success = false;
try {
returnTo = jwt.verify(encryptedReturnTo, secret).returnTo;
// 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 {
returnTo = `${homeLocation}/learn`;
}
return { returnTo, success };
}
module.exports = getReturnTo;

View File

@ -0,0 +1,54 @@
/* global describe expect it */
const { homeLocation } = require('../../../config/env.json');
const jwt = require('jsonwebtoken');
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`;
describe('get-return-to', () => {
describe('getReturnTo', () => {
it('should extract returnTo from a jwt', () => {
expect.assertions(1);
const encryptedReturnTo = jwt.sign(
{ returnTo: validReturnTo },
validJWTSecret
);
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual({
returnTo: validReturnTo,
success: true
});
});
it('should return a default url if the secrets do not match', () => {
expect.assertions(1);
const encryptedReturnTo = jwt.sign(
{ returnTo: validReturnTo },
invalidJWTSecret
);
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual({
returnTo: defaultReturnTo,
success: false
});
});
it('should return a default url for unknown origins', () => {
expect.assertions(1);
const encryptedReturnTo = jwt.sign(
{ returnTo: invalidReturnTo },
validJWTSecret
);
expect(getReturnTo(encryptedReturnTo, validJWTSecret)).toStrictEqual({
returnTo: defaultReturnTo,
success: false
});
});
});
});

View File

@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
import { Grid, Button } from '@freecodecamp/react-bootstrap';
import Helmet from 'react-helmet';
import { apiLocation } from '../../config/env.json';
import { apiLocation, homeLocation } from '../../config/env.json';
import {
signInLoadingSelector,
userSelector,
@ -167,7 +167,7 @@ export function ShowSettings(props) {
}
if (!isSignedIn) {
navigate(`${apiLocation}/signin?returnTo=settings`);
navigate(`${apiLocation}/signin?returnTo=${homeLocation}/settings`);
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 } from '../../config/env.json';
import { apiLocation, homeLocation } from '../../config/env.json';
import { ShowSettings } from './ShowSettings';
@ -23,7 +23,7 @@ describe('<ShowSettings />', () => {
shallow.render(<ShowSettings {...loggedOutProps} />);
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`${apiLocation}/signin?returnTo=settings`
`${apiLocation}/signin?returnTo=${homeLocation}/settings`
);
const result = shallow.getRenderOutput();
// Renders Loader rather than ShowSettings