diff --git a/api-server/server/boot/authentication.js b/api-server/server/boot/authentication.js index 7ae758e94f..eac3f0b8d3 100644 --- a/api-server/server/boot/authentication.js +++ b/api-server/server/boot/authentication.js @@ -1,12 +1,13 @@ import _ from 'lodash'; import { Observable } from 'rx'; import dedent from 'dedent'; -// import debugFactory from 'debug'; +import passport from 'passport'; import { isEmail } from 'validator'; import { check } from 'express-validator/check'; import { homeLocation } from '../../../config/env'; - +import { createCookieConfig } from '../utils/cookieConfig'; +import { createPassportCallbackAuthenticator } from '../component-passport'; import { ifUserRedirectTo, ifNoUserRedirectTo, @@ -15,7 +16,6 @@ import { import { wrapHandledError } from '../utils/create-handled-error.js'; const isSignUpDisabled = !!process.env.DISABLE_SIGNUP; -// const debug = debugFactory('fcc:boot:auth'); if (isSignUpDisabled) { console.log('fcc:boot:auth - Sign up is disabled'); } @@ -29,7 +29,11 @@ module.exports = function enableAuthentication(app) { const api = app.loopback.Router(); const { AuthToken, User } = app.models; - api.get('/signin', ifUserRedirect, (req, res) => res.redirect('/auth/auth0')); + api.get('/signin', ifUserRedirect, passport.authenticate('auth0-login', {})); + api.get( + '/auth/auth0/callback', + createPassportCallbackAuthenticator('auth0-login', { provider: 'auth0' }) + ); api.get('/signout', (req, res) => { req.logout(); @@ -41,10 +45,7 @@ module.exports = function enableAuthentication(app) { redirectTo: homeLocation }); } - const config = { - signed: !!req.signedCookies, - domain: process.env.COOKIE_DOMAIN || 'localhost' - }; + const config = createCookieConfig(req); res.clearCookie('jwt_access_token', config); res.clearCookie('access_token', config); res.clearCookie('userId', config); @@ -216,5 +217,4 @@ module.exports = function enableAuthentication(app) { ); app.use(api); - app.use('/internal', api); }; diff --git a/api-server/server/boot/z-not-found.js b/api-server/server/boot/z-not-found.js index 0f517f6e67..7f6034e0a6 100644 --- a/api-server/server/boot/z-not-found.js +++ b/api-server/server/boot/z-not-found.js @@ -1,15 +1,16 @@ import accepts from 'accepts'; +import { homeLocation } from '../../../config/env.json'; + export default function fourOhFour(app) { app.all('*', function(req, res) { const accept = accepts(req); const type = accept.type('html', 'json', 'text'); const { path } = req; - if (type === 'html') { - req.flash('danger', `We couldn't find path ${ path }`); - return res.render('404', { title: '404'}); + req.flash('danger', `We couldn't find path ${path}`); + return res.redirectWithFlash(`${homeLocation}/404`); } if (type === 'json') { diff --git a/api-server/server/component-passport.js b/api-server/server/component-passport.js index 25548e1ff9..c9de0e6c18 100644 --- a/api-server/server/component-passport.js +++ b/api-server/server/component-passport.js @@ -1,11 +1,16 @@ import passport from 'passport'; -import { PassportConfigurator } from - '@freecodecamp/loopback-component-passport'; -import passportProviders from './passport-providers'; +import { + PassportConfigurator +} from '@freecodecamp/loopback-component-passport'; import url from 'url'; import jwt from 'jsonwebtoken'; import dedent from 'dedent'; +import { homeLocation } from '../../config/env.json'; +import { jwtSecret } from '../../config/secrets'; +import passportProviders from './passport-providers'; +import { createCookieConfig } from './utils/cookieConfig'; + const passportOptions = { emailOptional: true, profileToUser: null @@ -23,15 +28,14 @@ function getCompletedCertCount(user) { 'isInfosecQaCert', 'isJsAlgoDataStructCert', 'isRespWebDesignCert' - ].reduce((sum, key) => user[key] ? sum + 1 : sum, 0); + ].reduce((sum, key) => (user[key] ? sum + 1 : sum), 0); } function getLegacyCertCount(user) { - return [ - 'isFrontEndCert', - 'isBackEndCert', - 'isDataVisCert' - ].reduce((sum, key) => user[key] ? sum + 1 : sum, 0); + return ['isFrontEndCert', 'isBackEndCert', 'isDataVisCert'].reduce( + (sum, key) => (user[key] ? sum + 1 : sum), + 0 + ); } PassportConfigurator.prototype.init = function passportInit(noSession) { @@ -51,7 +55,6 @@ PassportConfigurator.prototype.init = function passportInit(noSession) { }); passport.deserializeUser((id, done) => { - this.userModel.findById(id, { fields }, (err, user) => { if (err || !user) { return done(err, user); @@ -62,8 +65,12 @@ PassportConfigurator.prototype.init = function passportInit(noSession) { .aggregate([ { $match: { _id: user.id } }, { $project: { points: { $size: '$progressTimestamps' } } } - ]).get(function(err, [{ points = 1 } = {}]) { - if (err) { console.error(err); return done(err); } + ]) + .get(function(err, [{ points = 1 } = {}]) { + if (err) { + console.error(err); + return done(err); + } user.points = points; let completedChallengeCount = 0; let completedProjectCount = 0; @@ -90,6 +97,15 @@ PassportConfigurator.prototype.init = function passportInit(noSession) { }; export function setupPassport(app) { + // NOTE(Bouncey): Not sure this is doing much now + // Loopback complains about userCredentialModle when this + // setup is remoed from server/server.js + // + // I have split the custom callback in to it's own export that we can use both + // here and in boot:auth + // + // Needs more investigation... + const configurator = new PassportConfigurator(app); configurator.setupModels({ @@ -104,78 +120,77 @@ export function setupPassport(app) { let config = passportProviders[strategy]; config.session = config.session !== false; - // https://stackoverflow.com/q/37430452 - let successRedirect = (req) => { - if (!!req && req.session && req.session.returnTo) { - delete req.session.returnTo; - return '/'; - } - return config.successRedirect || ''; - }; - config.customCallback = !config.useCustomCallback ? null - : (req, res, next) => { + : createPassportCallbackAuthenticator(strategy, config); - passport.authenticate( - strategy, - { session: false }, - (err, user, userInfo) => { - - if (err) { - return next(err); - } - - if (!user || !userInfo) { - return res.redirect(config.failureRedirect); - } - let redirect = url.parse(successRedirect(req), true); - - delete redirect.search; - - const { accessToken } = userInfo; - const { provider } = config; - if (accessToken && accessToken.id) { - if (provider === 'auth0') { - req.flash( - 'success', - dedent` - Success! You have signed in to your account. Happy Coding! - ` - ); - } else if (user.email) { - req.flash( - 'info', - dedent` - We are moving away from social authentication for privacy reasons. Next time - we recommend using your email address: ${user.email} to sign in instead. - ` - ); - } - const cookieConfig = { - signed: !!req.signedCookies, - maxAge: accessToken.ttl, - domain: process.env.COOKIE_DOMAIN || 'localhost' - }; - const jwtAccess = jwt.sign({accessToken}, process.env.JWT_SECRET); - res.cookie('jwt_access_token', jwtAccess, cookieConfig); - res.cookie('access_token', accessToken.id, cookieConfig); - res.cookie('userId', accessToken.userId, cookieConfig); - req.login(user); - } - - redirect = url.format(redirect); - return res.redirect(redirect); - } - )(req, res, next); - }; - - configurator.configureProvider( - strategy, - { - ...config, - ...passportOptions - } - ); + configurator.configureProvider(strategy, { + ...config, + ...passportOptions + }); }); } + +export const createPassportCallbackAuthenticator = (strategy, config) => ( + req, + res, + next +) => { + // https://stackoverflow.com/q/37430452 + const successRedirect = req => { + if (!!req && req.session && req.session.returnTo) { + delete req.session.returnTo; + return `${homeLocation}/welcome`; + } + return config.successRedirect || `${homeLocation}/welcome`; + }; + return passport.authenticate( + strategy, + { session: false }, + (err, user, userInfo) => { + if (err) { + return next(err); + } + + if (!user || !userInfo) { + return res.redirect('/signin'); + } + let redirect = url.parse(successRedirect(req), true); + + delete redirect.search; + + const { accessToken } = userInfo; + const { provider } = config; + if (accessToken && accessToken.id) { + if (provider === 'auth0') { + req.flash( + 'success', + dedent` + Success! You have signed in to your account. Happy Coding! + ` + ); + } else if (user.email) { + req.flash( + 'info', + dedent` +We are moving away from social authentication for privacy reasons. Next time +we recommend using your email address: ${user.email} to sign in instead. + ` + ); + } + const cookieConfig = { + ...createCookieConfig(req), + maxAge: accessToken.ttl + }; + const jwtAccess = jwt.sign({ accessToken }, jwtSecret); + res.cookie('jwt_access_token', jwtAccess, cookieConfig); + res.cookie('access_token', accessToken.id, cookieConfig); + res.cookie('userId', accessToken.userId, cookieConfig); + req.login(user); + } + + redirect = url.format(redirect); + return res.redirect(redirect); + } + )(req, res, next); +}; diff --git a/api-server/server/middlewares/passport-login.js b/api-server/server/middlewares/passport-login.js index ff9c72e23f..0bb4e1fa42 100644 --- a/api-server/server/middlewares/passport-login.js +++ b/api-server/server/middlewares/passport-login.js @@ -7,12 +7,12 @@ import { login } from 'passport/lib/http/request'; // if called without callback it returns an observable // login(user, options?, cb?) => Void|Observable function login$(...args) { - console.log('args'); if (_.isFunction(_.last(args))) { return login.apply(this, args); } return Observable.fromNodeCallback(login).apply(this, args); } + export default function passportLogin() { return (req, res, next) => { req.login = req.logIn = login$; diff --git a/api-server/server/passport-providers.js b/api-server/server/passport-providers.js index af300b5279..57b987f82b 100644 --- a/api-server/server/passport-providers.js +++ b/api-server/server/passport-providers.js @@ -1,5 +1,10 @@ -const successRedirect = '/'; -const failureRedirect = '/'; +import { auth0 } from '../../config/secrets'; +import { homeLocation } from '../../config/env.json'; + +const { clientID, clientSecret, domain } = auth0; + +const successRedirect = `${homeLocation}/welcome`; +const failureRedirect = '/signin'; export default { local: { @@ -16,9 +21,9 @@ export default { 'auth0-login': { provider: 'auth0', module: 'passport-auth0', - clientID: process.env.AUTH0_CLIENT_ID, - clientSecret: process.env.AUTH0_CLIENT_SECRET, - domain: process.env.AUTH0_DOMAIN, + clientID, + clientSecret, + domain, cookieDomain: 'freeCodeCamp.org', callbackURL: '/auth/auth0/callback', authPath: '/auth/auth0', diff --git a/api-server/server/server.js b/api-server/server/server.js index a66870bdc9..6e97c7e623 100755 --- a/api-server/server/server.js +++ b/api-server/server/server.js @@ -1,5 +1,5 @@ const path = require('path'); -require('dotenv').config({ path: path.resolve(__dirname, '../../.env')}); +require('dotenv').config({ path: path.resolve(__dirname, '../../.env') }); const _ = require('lodash'); const Rx = require('rx'); @@ -17,7 +17,6 @@ log.enabled = true; Rx.config.longStackSupport = process.env.NODE_DEBUG !== 'production'; const app = loopback(); -const isBeta = !!process.env.BETA; expressState.extend(app); app.set('state namespace', '__fcc__'); @@ -27,9 +26,25 @@ app.set('view engine', 'jade'); app.use(loopback.token()); app.disable('x-powered-by'); -boot(app, { - appRootDir: __dirname, - dev: process.env.NODE_ENV +const createLogOnce = () => { + let called = false; + return str => { + if (called) { + return null; + } + called = true; + return log(str); + }; +}; +const logOnce = createLogOnce(); + +boot(app, __dirname, err => { + if (err) { + // rethrowing the error here bacause any error thrown in the boot stage + // is silent + logOnce('The below error was thrown in the boot stage'); + throw err; + } }); setupPassport(app); @@ -44,9 +59,6 @@ app.start = _.once(function() { app.get('port'), app.get('env') ); - if (isBeta) { - log('freeCodeCamp is in beta mode'); - } log(`connecting to db at ${db.settings.url}`); }); diff --git a/api-server/server/utils/cookieConfig.js b/api-server/server/utils/cookieConfig.js new file mode 100644 index 0000000000..fbb12e7d5f --- /dev/null +++ b/api-server/server/utils/cookieConfig.js @@ -0,0 +1,6 @@ +export function createCookieConfig(req) { + return { + signed: !!req.signedCookies, + domain: process.env.COOKIE_DOMAIN || 'localhost' + }; +} diff --git a/api-server/server/utils/middleware.js b/api-server/server/utils/middleware.js index 7371824190..dff8138cb1 100644 --- a/api-server/server/utils/middleware.js +++ b/api-server/server/utils/middleware.js @@ -2,6 +2,7 @@ import dedent from 'dedent'; import { validationResult } from 'express-validator/check'; import { createValidatorErrorFormatter } from './create-handled-error.js'; +import { homeLocation } from '../../../config/env.json'; export function ifNoUserRedirectTo(url, message, type = 'errors') { return function(req, res, next) { @@ -50,7 +51,7 @@ export function ifNotVerifiedRedirectToUpdateEmail(req, res, next) { return next(); } -export function ifUserRedirectTo(path = '/', status) { +export function ifUserRedirectTo(path = `${homeLocation}/welcome`, status) { status = status === 302 ? 302 : 301; return (req, res, next) => { if (req.user) { @@ -62,8 +63,9 @@ export function ifUserRedirectTo(path = '/', status) { // for use with express-validator error formatter export const createValidatorErrorHandler = (...args) => (req, res, next) => { - const validation = validationResult(req) - .formatWith(createValidatorErrorFormatter(...args)); + const validation = validationResult(req).formatWith( + createValidatorErrorFormatter(...args) + ); if (!validation.isEmpty()) { const errors = validation.array(); diff --git a/client/src/client-only-routes/ShowSettings.js b/client/src/client-only-routes/ShowSettings.js index 90ba916753..680fd26fa5 100644 --- a/client/src/client-only-routes/ShowSettings.js +++ b/client/src/client-only-routes/ShowSettings.js @@ -6,10 +6,12 @@ import { createSelector } from 'reselect'; import { Grid, Button } from '@freecodecamp/react-bootstrap'; import Helmet from 'react-helmet'; +import { apiLocation } from '../../config/env.json'; import { signInLoadingSelector, userSelector, - isSignedInSelector + isSignedInSelector, + hardGoTo } from '../redux'; import { submitNewAbout, updateUserFlag, verifyCert } from '../redux/settings'; import { createFlashMessage } from '../components/Flash/redux'; @@ -29,6 +31,7 @@ import RedirectHome from '../components/RedirectHome'; const propTypes = { createFlashMessage: PropTypes.func.isRequired, + hardGoTo: PropTypes.func.isRequired, isSignedIn: PropTypes.bool, showLoading: PropTypes.bool, submitNewAbout: PropTypes.func.isRequired, @@ -101,6 +104,7 @@ const mapDispatchToProps = dispatch => bindActionCreators( { createFlashMessage, + hardGoTo, submitNewAbout, toggleNightMode: theme => updateUserFlag({ theme }), updateInternetSettings: updateUserFlag, @@ -112,9 +116,15 @@ const mapDispatchToProps = dispatch => dispatch ); +const createHandleSignoutClick = hardGoTo => e => { + e.preventDefault(); + return hardGoTo(`${apiLocation}/signout`); +}; + function ShowSettings(props) { const { createFlashMessage, + hardGoTo, isSignedIn, submitNewAbout, toggleNightMode, @@ -193,6 +203,7 @@ function ShowSettings(props) { bsStyle='primary' className='btn-invert' href={'/signout'} + onClick={createHandleSignoutClick(hardGoTo)} > Sign me out of freeCodeCamp diff --git a/client/src/components/Header/components/Login.js b/client/src/components/Header/components/Login.js index 51e0904d48..82150a32b3 100644 --- a/client/src/components/Header/components/Login.js +++ b/client/src/components/Header/components/Login.js @@ -1,13 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import { Button } from '@freecodecamp/react-bootstrap'; -import { Link } from 'gatsby'; + +import { hardGoTo } from '../../../redux'; +import { apiLocation } from '../../../../config/env.json'; import './login.css'; -function Login({ children, ...restProps }) { +const mapStateToProps = () => ({}); +const mapDispatchToProps = dispatch => ({ + navigate: location => dispatch(hardGoTo(location)) +}); + +const createOnClick = navigate => e => { + e.preventDefault(); + return navigate(`${apiLocation}/signin`); +}; + +function Login(props) { + const { children, navigate, ...restProps } = props; return ( - + - + ); } Login.displayName = 'Login'; Login.propTypes = { - children: PropTypes.any + children: PropTypes.any, + navigate: PropTypes.func.isRequired }; -export default Login; +export default connect( + mapStateToProps, + mapDispatchToProps +)(Login); diff --git a/client/src/components/Header/index.js b/client/src/components/Header/index.js index d9a7a17457..f9e9e87f5c 100644 --- a/client/src/components/Header/index.js +++ b/client/src/components/Header/index.js @@ -12,9 +12,9 @@ function Header({ disableSettings }) { return (