feat(api): allow redirects with a returnTo param (#40161)
This commit is contained in:
committed by
GitHub
parent
8fd00afd9c
commit
b2e2f33cf1
@ -2,12 +2,15 @@ import passport from 'passport';
|
|||||||
import dedent from 'dedent';
|
import dedent from 'dedent';
|
||||||
import { check } from 'express-validator/check';
|
import { check } from 'express-validator/check';
|
||||||
import { isEmail } from 'validator';
|
import { isEmail } from 'validator';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
import { homeLocation } from '../../../config/env';
|
import { homeLocation } from '../../../config/env';
|
||||||
|
import { jwtSecret } from '../../../config/secrets';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createPassportCallbackAuthenticator,
|
createPassportCallbackAuthenticator,
|
||||||
saveResponseAuthCookies,
|
devSaveResponseAuthCookies,
|
||||||
loginRedirect
|
devLoginRedirect
|
||||||
} from '../component-passport';
|
} from '../component-passport';
|
||||||
import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware';
|
import { ifUserRedirectTo, ifNoUserRedirectTo } from '../utils/middleware';
|
||||||
import { wrapHandledError } from '../utils/create-handled-error.js';
|
import { wrapHandledError } from '../utils/create-handled-error.js';
|
||||||
@ -37,8 +40,8 @@ module.exports = function enableAuthentication(app) {
|
|||||||
app.enableAuth();
|
app.enableAuth();
|
||||||
const ifUserRedirect = ifUserRedirectTo();
|
const ifUserRedirect = ifUserRedirectTo();
|
||||||
const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation);
|
const ifNoUserRedirectHome = ifNoUserRedirectTo(homeLocation);
|
||||||
const saveAuthCookies = saveResponseAuthCookies();
|
const devSaveAuthCookies = devSaveResponseAuthCookies();
|
||||||
const loginSuccessRedirect = loginRedirect();
|
const devLoginSuccessRedirect = devLoginRedirect();
|
||||||
const api = app.loopback.Router();
|
const api = app.loopback.Router();
|
||||||
|
|
||||||
// Use a local mock strategy for signing in if we are in dev mode.
|
// 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(
|
api.get(
|
||||||
'/signin',
|
'/signin',
|
||||||
passport.authenticate('devlogin'),
|
passport.authenticate('devlogin'),
|
||||||
saveAuthCookies,
|
devSaveAuthCookies,
|
||||||
loginSuccessRedirect
|
devLoginSuccessRedirect
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
api.get(
|
api.get('/signin', ifUserRedirect, (req, res, next) => {
|
||||||
'/signin',
|
const state = jwt.sign({ returnTo: req.query.returnTo }, jwtSecret);
|
||||||
(req, res, next) => {
|
return passport.authenticate('auth0-login', { state })(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(
|
api.get(
|
||||||
'/auth/auth0/callback',
|
'/auth/auth0/callback',
|
||||||
|
@ -11,6 +11,8 @@ import { getUserById } from './utils/user-stats';
|
|||||||
import { homeLocation } from '../../config/env';
|
import { homeLocation } from '../../config/env';
|
||||||
import passportProviders from './passport-providers';
|
import passportProviders from './passport-providers';
|
||||||
import { setAccessTokenToResponse } from './utils/getSetAccessToken';
|
import { setAccessTokenToResponse } from './utils/getSetAccessToken';
|
||||||
|
import { jwtSecret } from '../../config/secrets';
|
||||||
|
import getReturnTo from './utils/get-return-to';
|
||||||
|
|
||||||
const passportOptions = {
|
const passportOptions = {
|
||||||
emailOptional: true,
|
emailOptional: true,
|
||||||
@ -63,7 +65,7 @@ export function setupPassport(app) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const saveResponseAuthCookies = () => {
|
export const devSaveResponseAuthCookies = () => {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
|
|
||||||
@ -78,12 +80,11 @@ export const saveResponseAuthCookies = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const loginRedirect = () => {
|
export const devLoginRedirect = () => {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
const successRedirect = req => {
|
const successRedirect = req => {
|
||||||
if (!!req && req.session && req.session.returnTo) {
|
if (req && req.query && req.query.returnTo) {
|
||||||
delete req.session.returnTo;
|
return req.query.returnTo;
|
||||||
return `${homeLocation}/learn`;
|
|
||||||
}
|
}
|
||||||
return `${homeLocation}/learn`;
|
return `${homeLocation}/learn`;
|
||||||
};
|
};
|
||||||
@ -101,10 +102,14 @@ export const createPassportCallbackAuthenticator = (strategy, config) => (
|
|||||||
res,
|
res,
|
||||||
next
|
next
|
||||||
) => {
|
) => {
|
||||||
const returnTo =
|
const state = req && req.query && req.query.state;
|
||||||
req && req.query && req.query.state
|
const { returnTo } = getReturnTo(state, jwtSecret);
|
||||||
? Buffer.from(req.query.state, 'base64').toString('utf-8')
|
|
||||||
: `${homeLocation}/learn`;
|
// 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(
|
return passport.authenticate(
|
||||||
strategy,
|
strategy,
|
||||||
{ session: false },
|
{ session: false },
|
||||||
@ -116,7 +121,6 @@ export const createPassportCallbackAuthenticator = (strategy, config) => (
|
|||||||
if (!user || !userInfo) {
|
if (!user || !userInfo) {
|
||||||
return res.redirect('/signin');
|
return res.redirect('/signin');
|
||||||
}
|
}
|
||||||
const redirect = `${returnTo}`;
|
|
||||||
|
|
||||||
const { accessToken } = userInfo;
|
const { accessToken } = userInfo;
|
||||||
const { provider } = config;
|
const { provider } = config;
|
||||||
@ -140,9 +144,10 @@ we recommend using your email address: ${user.email} to sign in instead.
|
|||||||
setAccessTokenToResponse({ accessToken }, req, res);
|
setAccessTokenToResponse({ accessToken }, req, res);
|
||||||
req.login(user);
|
req.login(user);
|
||||||
}
|
}
|
||||||
// TODO: enable 'returnTo' for sign-up
|
// TODO: handle returning to /email-sign-up without relying on
|
||||||
|
// homeLocation
|
||||||
if (user.acceptedPrivacyTerms) {
|
if (user.acceptedPrivacyTerms) {
|
||||||
return res.redirectWithFlash(redirect);
|
return res.redirectWithFlash(returnTo);
|
||||||
} else {
|
} else {
|
||||||
return res.redirectWithFlash(`${homeLocation}/email-sign-up`);
|
return res.redirectWithFlash(`${homeLocation}/email-sign-up`);
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,6 @@
|
|||||||
"auth:before": {
|
"auth:before": {
|
||||||
"express-flash": {},
|
"express-flash": {},
|
||||||
"./middlewares/express-extensions": {},
|
"./middlewares/express-extensions": {},
|
||||||
"./middlewares/add-return-to": {},
|
|
||||||
"./middlewares/cookie-parser": {},
|
"./middlewares/cookie-parser": {},
|
||||||
"./middlewares/request-authorization": {}
|
"./middlewares/request-authorization": {}
|
||||||
},
|
},
|
||||||
|
@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
23
api-server/server/utils/get-return-to.js
Normal file
23
api-server/server/utils/get-return-to.js
Normal 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;
|
54
api-server/server/utils/get-return-to.test.js
Normal file
54
api-server/server/utils/get-return-to.test.js
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -5,7 +5,7 @@ import { createSelector } from 'reselect';
|
|||||||
import { Grid, Button } from '@freecodecamp/react-bootstrap';
|
import { Grid, Button } from '@freecodecamp/react-bootstrap';
|
||||||
import Helmet from 'react-helmet';
|
import Helmet from 'react-helmet';
|
||||||
|
|
||||||
import { apiLocation } from '../../config/env.json';
|
import { apiLocation, homeLocation } from '../../config/env.json';
|
||||||
import {
|
import {
|
||||||
signInLoadingSelector,
|
signInLoadingSelector,
|
||||||
userSelector,
|
userSelector,
|
||||||
@ -167,7 +167,7 @@ export function ShowSettings(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isSignedIn) {
|
if (!isSignedIn) {
|
||||||
navigate(`${apiLocation}/signin?returnTo=settings`);
|
navigate(`${apiLocation}/signin?returnTo=${homeLocation}/settings`);
|
||||||
return <Loader fullScreen={true} />;
|
return <Loader fullScreen={true} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
/* global jest, expect */
|
/* global jest, expect */
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ShallowRenderer from 'react-test-renderer/shallow';
|
import ShallowRenderer from 'react-test-renderer/shallow';
|
||||||
import { apiLocation } from '../../config/env.json';
|
import { apiLocation, homeLocation } from '../../config/env.json';
|
||||||
|
|
||||||
import { ShowSettings } from './ShowSettings';
|
import { ShowSettings } from './ShowSettings';
|
||||||
|
|
||||||
@ -23,7 +23,7 @@ describe('<ShowSettings />', () => {
|
|||||||
shallow.render(<ShowSettings {...loggedOutProps} />);
|
shallow.render(<ShowSettings {...loggedOutProps} />);
|
||||||
expect(navigate).toHaveBeenCalledTimes(1);
|
expect(navigate).toHaveBeenCalledTimes(1);
|
||||||
expect(navigate).toHaveBeenCalledWith(
|
expect(navigate).toHaveBeenCalledWith(
|
||||||
`${apiLocation}/signin?returnTo=settings`
|
`${apiLocation}/signin?returnTo=${homeLocation}/settings`
|
||||||
);
|
);
|
||||||
const result = shallow.getRenderOutput();
|
const result = shallow.getRenderOutput();
|
||||||
// Renders Loader rather than ShowSettings
|
// Renders Loader rather than ShowSettings
|
||||||
|
Reference in New Issue
Block a user