diff --git a/README.md b/README.md index 09cd33114..718847d9e 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,13 @@ The Talk application looks for the following configuration values either as envi - `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database. - `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database. -- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to -secure cookies. - `TALK_ROOT_URL` (*required*) - root url of the installed application externally available in the format: `://` without the path. +- `TALK_JWT_SECRET` (*required*) - a long and cryptographical secure random string which will be used to +sign and verify tokens via a `HS256` algorithm. +- `TALK_JWT_EXPIRY` (_optional_) - the expiry duration (`exp`) for the tokens issued for logged in sessions (Default `1 day`) +- `TALK_JWT_ISSUER` (_optional_) - the issuer (`iss`) claim for login JWT tokens (Default `process.env.TALK_ROOT_URL`) +- `TALK_JWT_AUDIENCE` (_optional_) - the audience (`aud`) claim for login JWT tokens (Default `talk`) - `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the SMTP provider. - `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using. diff --git a/app.js b/app.js index a0a5b7789..f5243f81a 100644 --- a/app.js +++ b/app.js @@ -3,12 +3,11 @@ const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); const helmet = require('helmet'); +const authentication = require('./middleware/authentication'); const {passport} = require('./services/passport'); const plugins = require('./services/plugins'); const enabled = require('debug').enabled; -const csrf = require('csurf'); const errors = require('./errors'); -const session = require('./services/session'); const {createGraphOptions} = require('./graph'); const apollo = require('graphql-server-express'); @@ -37,12 +36,6 @@ app.use('/public', express.static(path.join(__dirname, 'public'))); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); -//============================================================================== -// SESSION MIDDLEWARE -//============================================================================== - -app.use(session); - //============================================================================== // PASSPORT MIDDLEWARE //============================================================================== @@ -60,7 +53,10 @@ plugins.get('server', 'passport').forEach((plugin) => { // Setup the PassportJS Middleware. app.use(passport.initialize()); -app.use(passport.session()); + +// Attach the authentication middleware, this will be responsible for decoding +// (if present) the JWT on the request. +app.use('/api', authentication); //============================================================================== // GraphQL Router @@ -84,29 +80,6 @@ if (app.get('env') !== 'production') { } -//============================================================================== -// CSRF MIDDLEWARE -//============================================================================== - -if (process.env.TEST_MODE === 'unit') { - - // Add this fake test token in the event we are in unit test mode, and don't - // include the CSRF protection. - app.locals.csrfToken = 'UNIT_TESTS'; - -} else { - - // Setup route middlewares for CSRF protection. - // Default ignore methods are GET, HEAD, OPTIONS - app.use(csrf({})); - app.use((req, res, next) => { - res.locals.csrfToken = req.csrfToken(); - - next(); - }); - -} - //============================================================================== // ROUTES //============================================================================== diff --git a/bin/cli-serve b/bin/cli-serve index eca23303b..7978c6c85 100755 --- a/bin/cli-serve +++ b/bin/cli-serve @@ -9,13 +9,15 @@ const kue = require('../services/kue'); const mongoose = require('../services/mongoose'); const util = require('./util'); const {createSubscriptionManager} = require('../graph/subscriptions'); +const { + PORT +} = require('../config'); /** * Get port from environment and store in Express. */ -const port = normalizePort(process.env.TALK_PORT || '3000'); - +const port = normalizePort(PORT); app.set('port', port); /** diff --git a/bin/commander.js b/bin/commander.js index ad346a911..328a25b29 100644 --- a/bin/commander.js +++ b/bin/commander.js @@ -3,11 +3,6 @@ const dotenv = require('dotenv'); const fs = require('fs'); const program = require('commander'); -// Perform rewrites to the runtime environment variables based on the contents -// of the process.env.REWRITE_ENV if it exists. This is done here as it is the -// entrypoint for the entire application. -require('env-rewrite').rewrite(); - //============================================================================== // Setting up the program command line arguments. //============================================================================== diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index 89b56d4dc..e50273fa6 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -1,7 +1,12 @@ import * as actions from '../constants/auth'; +import * as Storage from 'coral-framework/helpers/storage'; import coralApi from 'coral-framework/helpers/response'; +import {handleAuthToken} from 'coral-framework/actions/auth'; + +//============================================================================== +// SIGN IN +//============================================================================== -// Log In. export const handleLogin = (email, password, recaptchaResponse) => dispatch => { dispatch({type: actions.LOGIN_REQUEST}); const params = {method: 'POST', body: {email, password}}; @@ -9,27 +14,42 @@ export const handleLogin = (email, password, recaptchaResponse) => dispatch => { params.headers = {'X-Recaptcha-Response': recaptchaResponse}; } return coralApi('/auth/local', params) - .then(({user}) => { + .then(({user, token}) => { if (!user) { + Storage.removeItem('token'); return dispatch(checkLoginFailure('not logged in')); } - + dispatch(handleAuthToken(token)); const isAdmin = !!user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(user, isAdmin)); }) .catch(error => { - if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { - dispatch({type: actions.LOGIN_MAXIMUM_EXCEEDED, message: error.translation_key}); + dispatch({ + type: actions.LOGIN_MAXIMUM_EXCEEDED, + message: error.translation_key + }); } else { dispatch({type: actions.LOGIN_FAILURE, message: error.translation_key}); } }); }; -const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUEST}); -const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS}); -const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE}); +//============================================================================== +// FORGOT PASSWORD +//============================================================================== + +const forgotPassowordRequest = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_REQUEST +}); + +const forgotPassowordSuccess = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_SUCCESS +}); + +const forgotPassowordFailure = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE +}); export const requestPasswordReset = email => dispatch => { dispatch(forgotPassowordRequest(email)); @@ -38,17 +58,31 @@ export const requestPasswordReset = email => dispatch => { .catch(error => dispatch(forgotPassowordFailure(error))); }; -// Check Login +//============================================================================== +// CHECK LOGIN +//============================================================================== -const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST}); -const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin}); -const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); +const checkLoginRequest = () => ({ + type: actions.CHECK_LOGIN_REQUEST +}); + +const checkLoginSuccess = (user, isAdmin) => ({ + type: actions.CHECK_LOGIN_SUCCESS, + user, + isAdmin +}); + +const checkLoginFailure = error => ({ + type: actions.CHECK_LOGIN_FAILURE, + error +}); export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); return coralApi('/auth') .then(({user}) => { if (!user) { + Storage.removeItem('token'); return dispatch(checkLoginFailure('not logged in')); } @@ -60,16 +94,3 @@ export const checkLogin = () => dispatch => { dispatch(checkLoginFailure(`${error.translation_key}`)); }); }; - -// LogOut Actions - -const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); -const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); -const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); - -export const logout = () => dispatch => { - dispatch(logOutRequest()); - return coralApi('/auth', {method: 'DELETE'}) - .then(() => dispatch(logOutSuccess())) - .catch(error => dispatch(logOutFailure(error))); -}; diff --git a/client/coral-admin/src/constants/auth.js b/client/coral-admin/src/constants/auth.js index 93cd61544..a0c06f72e 100644 --- a/client/coral-admin/src/constants/auth.js +++ b/client/coral-admin/src/constants/auth.js @@ -2,11 +2,7 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST'; export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS'; export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE'; -export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN'; - -export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'; -export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; -export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'; +export const LOGOUT = 'LOGOUT'; export const LOGIN_REQUEST = 'LOGIN_REQUEST'; export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; diff --git a/client/coral-admin/src/containers/LayoutContainer.js b/client/coral-admin/src/containers/LayoutContainer.js index 42e2baade..04a83e39b 100644 --- a/client/coral-admin/src/containers/LayoutContainer.js +++ b/client/coral-admin/src/containers/LayoutContainer.js @@ -1,20 +1,21 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import Layout from '../components/ui/Layout'; -import {checkLogin, handleLogin, logout, requestPasswordReset} from '../actions/auth'; -import {toggleModal as toggleShortcutModal} from '../actions/moderation'; import {fetchConfig} from '../actions/config'; +import {logout} from 'coral-framework/actions/auth'; import {FullLoading} from '../components/FullLoading'; import AdminLogin from '../components/AdminLogin'; +import {toggleModal as toggleShortcutModal} from '../actions/moderation'; +import {checkLogin, handleLogin, requestPasswordReset} from '../actions/auth'; class LayoutContainer extends Component { - componentWillMount () { + componentWillMount() { const {checkLogin, fetchConfig} = this.props; checkLogin(); fetchConfig(); } - render () { + render() { const { isAdmin, loggedIn, @@ -24,19 +25,34 @@ class LayoutContainer extends Component { passwordRequestSuccess } = this.props.auth; - const {handleLogout, toggleShortcutModal, TALK_RECAPTCHA_PUBLIC} = this.props; - if (loadingUser) { return ; } + const { + handleLogout, + toggleShortcutModal, + TALK_RECAPTCHA_PUBLIC + } = this.props; + if (loadingUser) { + return ; + } if (!isAdmin) { - return ; + return ( + + ); } if (isAdmin && loggedIn) { - return ; + return ( + + ); } return ; } @@ -44,19 +60,19 @@ class LayoutContainer extends Component { const mapStateToProps = state => ({ auth: state.auth.toJS(), - TALK_RECAPTCHA_PUBLIC: state.config.get('data').get('TALK_RECAPTCHA_PUBLIC', null) + TALK_RECAPTCHA_PUBLIC: state.config + .get('data') + .get('TALK_RECAPTCHA_PUBLIC', null) }); const mapDispatchToProps = dispatch => ({ checkLogin: () => dispatch(checkLogin()), fetchConfig: () => dispatch(fetchConfig()), - handleLogin: (username, password, recaptchaResponse) => dispatch(handleLogin(username, password, recaptchaResponse)), + handleLogin: (username, password, recaptchaResponse) => + dispatch(handleLogin(username, password, recaptchaResponse)), requestPasswordReset: email => dispatch(requestPasswordReset(email)), toggleShortcutModal: toggle => dispatch(toggleShortcutModal(toggle)), handleLogout: () => dispatch(logout()) }); -export default connect( - mapStateToProps, - mapDispatchToProps -)(LayoutContainer); +export default connect(mapStateToProps, mapDispatchToProps)(LayoutContainer); diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js index a7054ddfa..1e080d37e 100644 --- a/client/coral-admin/src/reducers/auth.js +++ b/client/coral-admin/src/reducers/auth.js @@ -26,10 +26,8 @@ export default function auth (state = initialState, action) { .set('loadingUser', false) .set('isAdmin', action.isAdmin) .set('user', action.user); - case actions.LOGOUT_SUCCESS: + case actions.LOGOUT: return initialState; - case actions.LOGIN_REQUEST: - return state.set('loginError', null); case actions.LOGIN_SUCCESS: return state.set('loginMaxExceeded', false).set('loginError', null); case actions.LOGIN_FAILURE: diff --git a/client/coral-admin/src/services/PymConnection.js b/client/coral-admin/src/services/PymConnection.js index ca592b824..1ac24ec45 100644 --- a/client/coral-admin/src/services/PymConnection.js +++ b/client/coral-admin/src/services/PymConnection.js @@ -3,7 +3,7 @@ import Pym from '../../node_modules/pym.js'; const pym = new Pym.Child({polling: 100}); export default pym; -export const link = (url) => (e) => { +export const link = url => e => { e.preventDefault(); pym.sendMessage('navigate', url); }; diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js index 7d65f3f92..ff8216373 100644 --- a/client/coral-admin/src/services/client.js +++ b/client/coral-admin/src/services/client.js @@ -1,5 +1,5 @@ import ApolloClient, {addTypename} from 'apollo-client'; -import getNetworkInterface from './transport'; +import {networkInterface} from 'coral-framework/services/transport'; import fragmentMatcher from './fragmentMatcher'; export const client = new ApolloClient({ @@ -12,5 +12,5 @@ export const client = new ApolloClient({ } return null; }, - networkInterface: getNetworkInterface() + networkInterface }); diff --git a/client/coral-admin/src/services/fragmentMatcher.js b/client/coral-admin/src/services/fragmentMatcher.js index 531708f31..3b57de0b1 100644 --- a/client/coral-admin/src/services/fragmentMatcher.js +++ b/client/coral-admin/src/services/fragmentMatcher.js @@ -39,7 +39,7 @@ const fm = new IntrospectionFragmentMatcher({ {name: 'DefaultAction'}, {name: 'FlagAction'}, {name: 'DontAgreeAction'} - ], + ] }, { kind: 'INTERFACE', @@ -48,18 +48,18 @@ const fm = new IntrospectionFragmentMatcher({ {name: 'DefaultActionSummary'}, {name: 'FlagActionSummary'}, {name: 'DontAgreeActionSummary'} - ], + ] }, { kind: 'INTERFACE', name: 'AssetActionSummary', possibleTypes: [ {name: 'DefaultAssetActionSummary'}, - {name: 'FlagAssetActionSummary'}, + {name: 'FlagAssetActionSummary'} ] } - ], - }, + ] + } } }); diff --git a/client/coral-admin/src/services/transport.js b/client/coral-admin/src/services/transport.js deleted file mode 100644 index 2bd6ac636..000000000 --- a/client/coral-admin/src/services/transport.js +++ /dev/null @@ -1,11 +0,0 @@ -import {createNetworkInterface} from 'apollo-client'; - -export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) { - return new createNetworkInterface({ - uri: apiUrl, - opts: { - credentials: 'same-origin', - headers, - }, - }); -} diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index d689aeb63..98c87845f 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -1,11 +1,14 @@ import {gql} from 'react-apollo'; -import client from 'coral-framework/services/client'; -import I18n from '../../coral-framework/modules/i18n/i18n'; -import translations from './../translations'; -const lang = new I18n(translations); +import {pym} from 'coral-framework'; +import * as Storage from '../helpers/storage'; import * as actions from '../constants/auth'; import coralApi, {base} from '../helpers/response'; -import {pym} from 'coral-framework'; +import client from 'coral-framework/services/client'; +import jwtDecode from 'jwt-decode'; + +const lang = new I18n(translations); +import translations from './../translations'; +import I18n from '../../coral-framework/modules/i18n/i18n'; const ME_QUERY = gql` query Me { @@ -28,7 +31,8 @@ const ME_QUERY = gql` function fetchMe() { return client.query({ fetchPolicy: 'network-only', - query: ME_QUERY}); + query: ME_QUERY + }); } // Dialog Actions @@ -61,14 +65,29 @@ export const hideSignInDialog = () => dispatch => { window.close(); }; -export const createUsernameRequest = () => ({type: actions.CREATE_USERNAME_REQUEST}); -export const showCreateUsernameDialog = () => ({type: actions.SHOW_CREATEUSERNAME_DIALOG}); -export const hideCreateUsernameDialog = () => ({type: actions.HIDE_CREATEUSERNAME_DIALOG}); +export const createUsernameRequest = () => ({ + type: actions.CREATE_USERNAME_REQUEST +}); +export const showCreateUsernameDialog = () => ({ + type: actions.SHOW_CREATEUSERNAME_DIALOG +}); +export const hideCreateUsernameDialog = () => ({ + type: actions.HIDE_CREATEUSERNAME_DIALOG +}); -const createUsernameSuccess = () => ({type: actions.CREATE_USERNAME_SUCCESS}); -const createUsernameFailure = error => ({type: actions.CREATE_USERNAME_FAILURE, error}); +const createUsernameSuccess = () => ({ + type: actions.CREATE_USERNAME_SUCCESS +}); -export const updateUsername = ({username}) => ({type: actions.UPDATE_USERNAME, username}); +const createUsernameFailure = error => ({ + type: actions.CREATE_USERNAME_FAILURE, + error +}); + +export const updateUsername = ({username}) => ({ + type: actions.UPDATE_USERNAME, + username +}); export const createUsername = (userId, formData) => dispatch => { dispatch(createUsernameRequest()); @@ -89,7 +108,7 @@ export const changeView = view => dispatch => { view }); - switch(view) { + switch (view) { case 'SIGNUP': window.resizeTo(500, 800); break; @@ -101,26 +120,49 @@ export const changeView = view => dispatch => { } }; -export const cleanState = () => ({type: actions.CLEAN_STATE}); +export const cleanState = () => ({ + type: actions.CLEAN_STATE +}); // Sign In Actions -const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST}); +const signInRequest = () => ({ + type: actions.FETCH_SIGNIN_REQUEST +}); -// TODO: revisit login redux flow. -// const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin}); -// -const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error}); +const signInFailure = error => ({ + type: actions.FETCH_SIGNIN_FAILURE, + error +}); -export const fetchSignIn = (formData) => (dispatch) => { +//============================================================================== +// AUTH TOKEN +//============================================================================== + +export const handleAuthToken = token => dispatch => { + Storage.setItem('exp', jwtDecode(token).exp); + Storage.setItem('token', token); + dispatch({type: 'HANDLE_AUTH_TOKEN'}); +}; + +//============================================================================== +// SIGN IN +//============================================================================== + +export const fetchSignIn = formData => dispatch => { dispatch(signInRequest()); return coralApi('/auth/local', {method: 'POST', body: formData}) - .then(() => dispatch(hideSignInDialog())) + .then(({token}) => { + dispatch(handleAuthToken(token)); + dispatch(hideSignInDialog()); + }) .catch(error => { if (error.metadata) { // the user might not have a valid email. prompt the user user re-request the confirmation email - dispatch(signInFailure(lang.t('error.emailNotVerified', error.metadata))); + dispatch( + signInFailure(lang.t('error.emailNotVerified', error.metadata)) + ); } else { // invalid credentials @@ -129,11 +171,21 @@ export const fetchSignIn = (formData) => (dispatch) => { }); }; -// Sign In - Facebook +//============================================================================== +// SIGN IN - FACEBOOK +//============================================================================== -const signInFacebookRequest = () => ({type: actions.FETCH_SIGNIN_FACEBOOK_REQUEST}); -const signInFacebookSuccess = user => ({type: actions.FETCH_SIGNIN_FACEBOOK_SUCCESS, user}); -const signInFacebookFailure = error => ({type: actions.FETCH_SIGNIN_FACEBOOK_FAILURE, error}); +const signInFacebookRequest = () => ({ + type: actions.FETCH_SIGNIN_FACEBOOK_REQUEST +}); +const signInFacebookSuccess = user => ({ + type: actions.FETCH_SIGNIN_FACEBOOK_SUCCESS, + user +}); +const signInFacebookFailure = error => ({ + type: actions.FETCH_SIGNIN_FACEBOOK_FAILURE, + error +}); export const fetchSignInFacebook = () => dispatch => { dispatch(signInFacebookRequest()); @@ -144,9 +196,13 @@ export const fetchSignInFacebook = () => dispatch => { ); }; -// Sign Up Facebook +//============================================================================== +// SIGN UP - FACEBOOK +//============================================================================== -const signUpFacebookRequest = () => ({type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST}); +const signUpFacebookRequest = () => ({ + type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST +}); export const fetchSignUpFacebook = () => dispatch => { dispatch(signUpFacebookRequest()); @@ -174,16 +230,22 @@ export const facebookCallback = (err, data) => dispatch => { } }; -// Sign Up Actions +//============================================================================== +// SIGN UP +//============================================================================== const signUpRequest = () => ({type: actions.FETCH_SIGNUP_REQUEST}); const signUpSuccess = user => ({type: actions.FETCH_SIGNUP_SUCCESS, user}); const signUpFailure = error => ({type: actions.FETCH_SIGNUP_FAILURE, error}); -export const fetchSignUp = (formData, redirectUri) => (dispatch) => { +export const fetchSignUp = (formData, redirectUri) => dispatch => { dispatch(signUpRequest()); - coralApi('/users', {method: 'POST', body: formData, headers: {'X-Pym-Url': redirectUri}}) + coralApi('/users', { + method: 'POST', + body: formData, + headers: {'X-Pym-Url': redirectUri} + }) .then(({user}) => { dispatch(signUpSuccess(user)); }) @@ -198,52 +260,65 @@ export const fetchSignUp = (formData, redirectUri) => (dispatch) => { }); }; -// Forgot Password Actions +//============================================================================== +// FORGOT PASSWORD +//============================================================================== -const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUEST}); -const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS}); -const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE}); +const forgotPasswordRequest = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_REQUEST +}); -export const fetchForgotPassword = email => (dispatch) => { - dispatch(forgotPassowordRequest(email)); +const forgotPasswordSuccess = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_SUCCESS +}); + +const forgotPasswordFailure = () => ({ + type: actions.FETCH_FORGOT_PASSWORD_FAILURE +}); + +export const fetchForgotPassword = email => dispatch => { + dispatch(forgotPasswordRequest(email)); const redirectUri = pym.parentUrl || location.href; - coralApi('/account/password/reset', {method: 'POST', body: {email, loc: redirectUri}}) - .then(() => dispatch(forgotPassowordSuccess())) - .catch(error => dispatch(forgotPassowordFailure(error))); + coralApi('/account/password/reset', { + method: 'POST', + body: {email, loc: redirectUri} + }) + .then(() => dispatch(forgotPasswordSuccess())) + .catch(error => dispatch(forgotPasswordFailure(error))); }; -// LogOut Actions - -const logOutRequest = () => ({type: actions.LOGOUT_REQUEST}); -const logOutSuccess = () => ({type: actions.LOGOUT_SUCCESS}); -const logOutFailure = () => ({type: actions.LOGOUT_FAILURE}); +//============================================================================== +// LOGOUT +//============================================================================== export const logout = () => dispatch => { - dispatch(logOutRequest()); return coralApi('/auth', {method: 'DELETE'}) .then(() => { - dispatch(logOutSuccess()); + dispatch({type: actions.LOGOUT}); + Storage.removeItem('token'); fetchMe(); - }) - .catch(error => dispatch(logOutFailure(error))); + }); }; -// LogOut Actions - -export const validForm = () => ({type: actions.VALID_FORM}); -export const invalidForm = error => ({type: actions.INVALID_FORM, error}); - -// Check Login +//============================================================================== +// CHECK LOGIN +//============================================================================== const checkLoginRequest = () => ({type: actions.CHECK_LOGIN_REQUEST}); -const checkLoginSuccess = (user, isAdmin) => ({type: actions.CHECK_LOGIN_SUCCESS, user, isAdmin}); const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error}); +const checkLoginSuccess = (user, isAdmin) => ({ + type: actions.CHECK_LOGIN_SUCCESS, + user, + isAdmin +}); + export const checkLogin = () => dispatch => { dispatch(checkLoginRequest()); coralApi('/auth') - .then((result) => { + .then(result => { if (!result.user) { + Storage.removeItem('token'); throw new Error('Not logged in'); } @@ -256,13 +331,32 @@ export const checkLogin = () => dispatch => { }); }; -const verifyEmailRequest = () => ({type: actions.VERIFY_EMAIL_REQUEST}); -const verifyEmailSuccess = () => ({type: actions.VERIFY_EMAIL_SUCCESS}); -const verifyEmailFailure = () => ({type: actions.VERIFY_EMAIL_FAILURE}); +export const validForm = () => ({type: actions.VALID_FORM}); +export const invalidForm = error => ({type: actions.INVALID_FORM, error}); + +//============================================================================== +// VERIFY EMAIL +//============================================================================== + +const verifyEmailRequest = () => ({ + type: actions.VERIFY_EMAIL_REQUEST +}); + +const verifyEmailSuccess = () => ({ + type: actions.VERIFY_EMAIL_SUCCESS +}); + +const verifyEmailFailure = () => ({ + type: actions.VERIFY_EMAIL_FAILURE +}); export const requestConfirmEmail = (email, redirectUri) => dispatch => { dispatch(verifyEmailRequest()); - return coralApi('/users/resend-verify', {method: 'POST', body: {email}, headers: {'X-Pym-Url': redirectUri}}) + return coralApi('/users/resend-verify', { + method: 'POST', + body: {email}, + headers: {'X-Pym-Url': redirectUri} + }) .then(() => { dispatch(verifyEmailSuccess()); }) diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js index ce5d860b4..24599ac50 100644 --- a/client/coral-framework/constants/auth.js +++ b/client/coral-framework/constants/auth.js @@ -33,9 +33,7 @@ export const FETCH_FORGOT_PASSWORD_REQUEST = 'FETCH_FORGOT_PASSWORD_REQUEST'; export const FETCH_FORGOT_PASSWORD_SUCCESS = 'FETCH_FORGOT_PASSWORD_SUCCESS'; export const FETCH_FORGOT_PASSWORD_FAILURE = 'FETCH_FORGOT_PASSWORD_FAILURE'; -export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'; -export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; -export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'; +export const LOGOUT = 'LOGOUT'; export const INVALID_FORM = 'INVALID_FORM'; export const VALID_FORM = 'VALID_FORM'; @@ -44,8 +42,6 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST'; export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS'; export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE'; -export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN'; - export const VERIFY_EMAIL_REQUEST = 'VERIFY_EMAIL_REQUEST'; export const VERIFY_EMAIL_SUCCESS = 'VERIFY_EMAIL_SUCCESS'; export const VERIFY_EMAIL_FAILURE = 'VERIFY_EMAIL_FAILURE'; diff --git a/client/coral-framework/helpers/response.js b/client/coral-framework/helpers/response.js index 0fce878cd..342577391 100644 --- a/client/coral-framework/helpers/response.js +++ b/client/coral-framework/helpers/response.js @@ -1,31 +1,22 @@ -export const base = '/api/v1'; +import * as Storage from './storage'; const buildOptions = (inputOptions = {}) => { - - const csurfDOM = document.head.querySelector('[property=csrf]'); - const defaultOptions = { method: 'GET', headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' + Accept: 'application/json', + Authorization: `Bearer ${Storage.getItem('token')}`, + 'Content-Type': 'application/json' }, - credentials: 'same-origin', - _csrf: csurfDOM ? csurfDOM.content : false + credentials: 'same-origin' }; let options = Object.assign({}, defaultOptions, inputOptions); - options.headers = Object.assign({}, defaultOptions.headers, inputOptions.headers); - - if (options._csrf) { - switch (options.method.toLowerCase()) { - case 'post': - case 'put': - case 'delete': - options.headers['x-csrf-token'] = options._csrf; - break; - } - } + options.headers = Object.assign( + {}, + defaultOptions.headers, + inputOptions.headers + ); if (options.method.toLowerCase() !== 'get') { options.body = JSON.stringify(options.body); @@ -58,6 +49,8 @@ const handleResp = res => { } }; +export const base = '/api/v1'; + export default (url, options) => { return fetch(`${base}${url}`, buildOptions(options)).then(handleResp); }; diff --git a/client/coral-framework/helpers/storage.js b/client/coral-framework/helpers/storage.js new file mode 100644 index 000000000..fa20fa168 --- /dev/null +++ b/client/coral-framework/helpers/storage.js @@ -0,0 +1,93 @@ +let available, error; + +function storageAvailable(type) { + let storage = window[type], x = '__storage_test__'; + try { + storage.setItem(x, x); + storage.removeItem(x); + return true; + } catch (e) { + error = e; + return ( + e instanceof DOMException && + + // everything except Firefox + (e.code === 22 || + + // Firefox + + e.code === 1014 || + + // test name field too, because code might not be present + + // everything except Firefox + e.name === 'QuotaExceededError' || + + // Firefox + e.name === 'NS_ERROR_DOM_QUOTA_REACHED') && + + // acknowledge QuotaExceededError only if there's something already stored + storage.length !== 0 + ); + } +} + +function lazyCheckStorage() { + if (typeof available === 'undefined') { + available = storageAvailable('localStorage'); + } +} + +export function getItem(item = '') { + lazyCheckStorage(); + + if (available) { + return localStorage.getItem(item); + } else { + console.error( + `Cannot get from localStorage. localStorage is not available. ${error}` + ); + } +} + +export function setItem(item = '', value) { + lazyCheckStorage(); + + if (available) { + return localStorage.setItem(item, value); + } else { + console.error( + `Cannot set localStorage. localStorage is not available. ${error}` + ); + } +} + +export function removeItem(item = '') { + lazyCheckStorage(); + + if (available) { + return localStorage.removeItem(item); + } else { + console.error( + `Cannot remove item from localStorage. localStorage is not available. ${error}` + ); + } +} + +export function clear() { + lazyCheckStorage(); + + if (available) { + return localStorage.clear(); + } else { + console.error( + `Cannot clear localStorage. localStorage is not available. ${error}` + ); + } +} + +// Enable this to debug WEB Storage events +// window.addEventListener('storage', function(e) { +// const msg = `${e.key} " was changed in page ${e.url} from ${e.oldValue} to ${e.newValue}`; +// console.log(msg); +// }); diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index 83ea49ce5..34a8dbcd5 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -64,9 +64,6 @@ export default function auth (state = initialState, action) { .set('view', action.view); case actions.CLEAN_STATE: return initialState; - case actions.CHECK_CSRF_TOKEN: - return state - .set('_csrf', action._csrf); case actions.FETCH_SIGNIN_REQUEST: return state .set('isLoading', true); @@ -116,7 +113,7 @@ export default function auth (state = initialState, action) { return state .set('isLoading', false) .set('successSignUp', true); - case actions.LOGOUT_SUCCESS: + case actions.LOGOUT: return state .set('user', null) .set('isLoading', false) diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 07bd13ca1..47ea9c352 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -1,5 +1,5 @@ import ApolloClient, {addTypename} from 'apollo-client'; -import getNetworkInterface from './transport'; +import {networkInterface} from './transport'; // import {SubscriptionClient, addGraphQLSubscriptions} from 'subscriptions-transport-ws'; @@ -11,8 +11,6 @@ import getNetworkInterface from './transport'; // getNetworkInterface(), // wsClient, // ); -const networkInterface = getNetworkInterface(); - export const client = new ApolloClient({ connectToDevTools: true, queryTransformer: addTypename, diff --git a/client/coral-framework/services/transport.js b/client/coral-framework/services/transport.js index 2bd6ac636..e421ca3da 100644 --- a/client/coral-framework/services/transport.js +++ b/client/coral-framework/services/transport.js @@ -1,11 +1,31 @@ import {createNetworkInterface} from 'apollo-client'; +import * as Storage from '../helpers/storage'; -export default function getNetworkInterface(apiUrl = '/api/v1/graph/ql', headers = {}) { - return new createNetworkInterface({ - uri: apiUrl, - opts: { - credentials: 'same-origin', - headers, - }, - }); -} +//============================================================================== +// NETWORK INTERFACE +//============================================================================== + +const networkInterface = createNetworkInterface({ + uri: '/api/v1/graph/ql', + opts: { + credentials: 'same-origin' + } +}); + +//============================================================================== +// MIDDLEWARES +//============================================================================== + +networkInterface.use([{ + applyMiddleware(req, next) { + if (!req.options.headers) { + req.options.headers = {}; // Create the header object if needed. + } + req.options.headers['authorization'] = `Bearer ${Storage.getItem('token')}`; + next(); + } +}]); + +export { + networkInterface +}; diff --git a/client/coral-settings/containers/ProfileContainer.js b/client/coral-settings/containers/ProfileContainer.js index f9c8d892f..1c6be1ee9 100644 --- a/client/coral-settings/containers/ProfileContainer.js +++ b/client/coral-settings/containers/ProfileContainer.js @@ -17,71 +17,68 @@ import translations from '../translations'; const lang = new I18n(translations); class ProfileContainer extends Component { - constructor (props) { - super(props); - this.state = { - activeTab: 0, - }; + constructor() { + super(); - this.handleTabChange = this.handleTabChange.bind(this); + this.state = { + activeTab: 0 + }; } - handleTabChange(tab) { + handleTabChange = tab => { this.setState({ activeTab: tab }); - } + }; render() { - const {asset, data, showSignInDialog, myIgnoredUsersData, stopIgnoringUser} = this.props; - const {me} = this.props.data; + const { + auth, + data, + asset, + showSignInDialog, + stopIgnoringUser, + myIgnoredUsersData + } = this.props; - if (data.loading) { - return ; - } + const {me} = data; - if (!me) { + if (!auth.loggedIn) { return ; } - const localProfile = this.props.user.profiles.find(p => p.provider === 'local'); + if (data.loading) { + return ; + } + + const localProfile = this.props.user.profiles.find( + p => p.provider === 'local' + ); + const emailAddress = localProfile && localProfile.id; return (

{this.props.user.username}

- { emailAddress - ?

{ emailAddress }

- : null - } + {emailAddress ?

{emailAddress}

: null} - { - myIgnoredUsersData.myIgnoredUsers && myIgnoredUsersData.myIgnoredUsers.length - ? ( -
+ {myIgnoredUsersData.myIgnoredUsers && + myIgnoredUsersData.myIgnoredUsers.length + ?

Ignored users

- ) - : null - } + : null}

My comments

- { - me.comments.length ? - - : -

{lang.t('userNoComment')}

- } + {me.comments.length + ? + :

{lang.t('userNoComment')}

}
); } @@ -89,21 +86,25 @@ class ProfileContainer extends Component { // TODO: These currently relies on refetching (see ignoreUser and stopIgnoringUser mutations). // -const withMyIgnoredUsersQuery = graphql(gql` +const withMyIgnoredUsersQuery = graphql( + gql` query myIgnoredUsers { myIgnoredUsers { id, username, } - }`, { + }`, + { props: ({data}) => { - return ({ + return { myIgnoredUsersData: data - }); + }; } - }); + } +); -const withMyCommentHistoryQuery = graphql(gql` +const withMyCommentHistoryQuery = graphql( + gql` query myCommentHistory { me { comments { @@ -117,7 +118,8 @@ const withMyCommentHistoryQuery = graphql(gql` created_at } } - }`); + }` +); const mapStateToProps = state => ({ user: state.user.toJS(), @@ -132,5 +134,5 @@ export default compose( connect(mapStateToProps, mapDispatchToProps), withMyCommentHistoryQuery, withMyIgnoredUsersQuery, - withStopIgnoringUser, + withStopIgnoringUser )(ProfileContainer); diff --git a/config.js b/config.js new file mode 100644 index 000000000..3d75f44a8 --- /dev/null +++ b/config.js @@ -0,0 +1,141 @@ +// This file serves as the entrypoint to all configuration loaded by the +// application. All defaults are assumed here, validation should also be +// completed here. + +// Perform rewrites to the runtime environment variables based on the contents +// of the process.env.REWRITE_ENV if it exists. This is done here as it is the +// entrypoint for the entire applications configuration. +require('env-rewrite').rewrite(); + +//============================================================================== +// CONFIG INITIALIZATION +//============================================================================== + +const CONFIG = { + + //------------------------------------------------------------------------------ + // JWT based configuration + //------------------------------------------------------------------------------ + + // JWT_SECRET is the secret used to sign and verify tokens issued by this + // application. + JWT_SECRET: process.env.TALK_JWT_SECRET || null, + + // JWT_AUDIENCE is the value for the audience claim for the tokens that will be + // verified when decoding. If `JWT_AUDIENCE` is not in the environment, then it + // will default to `talk`. + JWT_AUDIENCE: process.env.TALK_JWT_AUDIENCE || 'talk', + + // JWT_ISSUER is the value for the issuer for the tokens that will be verified + // when decoding. If `JWT_ISSUER` is not in the environment, then it will try + // `TALK_ROOT_URL`, otherwise, it will be undefined. + JWT_ISSUER: process.env.TALK_JWT_ISSUER || process.env.TALK_ROOT_URL || undefined, + + // JWT_EXPIRY is the time for which a given token is valid for. + JWT_EXPIRY: process.env.TALK_JWT_EXPIRY || '1 day', + + //------------------------------------------------------------------------------ + // Installation locks + //------------------------------------------------------------------------------ + + INSTALL_LOCK: process.env.TALK_INSTALL_LOCK === 'TRUE', + + //------------------------------------------------------------------------------ + // External database url's + //------------------------------------------------------------------------------ + + MONGO_URL: process.env.TALK_MONGO_URL, + REDIS_URL: process.env.TALK_REDIS_URL, + + //------------------------------------------------------------------------------ + // Server Config + //------------------------------------------------------------------------------ + + // Port to bind to. + PORT: process.env.TALK_PORT || '3000', + + // The URL for this Talk Instance as viewable from the outside. + ROOT_URL: process.env.TALK_ROOT_URL, + + //------------------------------------------------------------------------------ + // Recaptcha configuration + //------------------------------------------------------------------------------ + + RECAPTCHA_ENABLED: false, // updated below + RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC, + RECAPTCHA_SECRET: process.env.TALK_RECAPTCHA_SECRET, + + //------------------------------------------------------------------------------ + // SMTP Server configuration + //------------------------------------------------------------------------------ + + SMTP_FROM_ADDRESS: process.env.TALK_SMTP_FROM_ADDRESS, + SMTP_HOST: process.env.TALK_SMTP_HOST, + SMTP_PASSWORD: process.env.TALK_SMTP_PASSWORD, + SMTP_PORT: process.env.TALK_SMTP_PORT, + SMTP_USERNAME: process.env.TALK_SMTP_USERNAME +}; + +//============================================================================== +// CONFIG VALIDATION +//============================================================================== + +//------------------------------------------------------------------------------ +// JWT based configuration +//------------------------------------------------------------------------------ + +if (process.env.NODE_ENV === 'test' && !CONFIG.JWT_SECRET) { + CONFIG.JWT_SECRET = 'keyboard cat'; +} else if (!CONFIG.JWT_SECRET) { + throw new Error('TALK_JWT_SECRET must be provided in the environment to sign/verify tokens'); +} + +//------------------------------------------------------------------------------ +// External database url's +//------------------------------------------------------------------------------ + +// Reset the mongo url in the event it hasn't been overrided and we are in a +// testing environment. Every new mongo instance comes with a test database by +// default, this is consistent with common testing and use case practices. +if (process.env.NODE_ENV === 'test' && !CONFIG.MONGO_URL) { + CONFIG.MONGO_URL = 'mongodb://localhost/test'; +} + +// Reset the redis url in the event it hasn't been overrided and we are in a +// testing environment. +if (process.env.NODE_ENV === 'test' && !CONFIG.REDIS_URL) { + CONFIG.REDIS_URL = 'redis://localhost'; +} + +//------------------------------------------------------------------------------ +// Recaptcha configuration +//------------------------------------------------------------------------------ + +/** + * This is true when the recaptcha secret is provided and the Recaptcha feature + * is to be enabled. + */ +CONFIG.RECAPTCHA_ENABLED = CONFIG.RECAPTCHA_SECRET && CONFIG.RECAPTCHA_SECRET.length > 0 && + CONFIG.RECAPTCHA_PUBLIC && CONFIG.RECAPTCHA_PUBLIC.length > 0; +if (!CONFIG.RECAPTCHA_ENABLED) { + console.warn('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.'); +} + +//------------------------------------------------------------------------------ +// SMTP Server configuration +//------------------------------------------------------------------------------ + +{ + const requiredProps = [ + 'SMTP_FROM_ADDRESS', + 'SMTP_USERNAME', + 'SMTP_PASSWORD', + 'SMTP_HOST' + ]; + + if (requiredProps.some((prop) => !CONFIG[prop])) { + console.warn(`${requiredProps.map((v) => `TALK_${v}`).join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`); + } +} + +module.exports = CONFIG; diff --git a/middleware/authentication.js b/middleware/authentication.js new file mode 100644 index 000000000..ca6949a5c --- /dev/null +++ b/middleware/authentication.js @@ -0,0 +1,19 @@ +const {passport} = require('../services/passport'); + +const authentication = (req, res, next) => passport.authenticate('jwt', { + session: false +}, (err, user) => { + if (err) { + return next(err); + } + + if (user) { + + // Attach the user to the request object, now that we know it exists. + req.user = user; + } + + next(); +})(req, res, next); + +module.exports = authentication; diff --git a/package.json b/package.json index 6476223b3..262dc43b4 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "inquirer": "^3.0.6", "joi": "^10.4.1", "jsonwebtoken": "^7.3.0", + "jwt-decode": "^2.2.0", "kue": "^0.11.5", "linkify-it": "^2.0.3", "lodash": "^4.16.6", @@ -93,18 +94,17 @@ "nodemailer": "^2.6.4", "parse-duration": "^0.1.1", "passport": "^0.3.2", + "passport-jwt": "^2.2.1", "passport-local": "^1.0.0", "prop-types": "^15.5.8", "react-apollo": "^1.1.0", "react-recaptcha": "^2.2.6", "recompose": "^0.23.1", "redis": "^2.7.1", - "uuid": "^3.0.1", - "simplemde": "^1.11.2", - "subscriptions-transport-ws": "^0.5.5-alpha.0", "resolve": "^1.3.2", "semver": "^5.3.0", "simplemde": "^1.11.2", + "subscriptions-transport-ws": "^0.5.5-alpha.0", "timekeeper": "^1.0.0", "uuid": "^3.0.1" }, diff --git a/plugins.js b/plugins.js index 22293c975..f0c31407f 100644 --- a/plugins.js +++ b/plugins.js @@ -5,6 +5,8 @@ const debug = require('debug')('talk:plugins'); const Joi = require('joi'); const amp = require('app-module-path'); +const PLUGINS_JSON = process.env.TALK_PLUGINS_JSON; + // Add the current path to the module root. amp.addPath(__dirname); @@ -18,7 +20,7 @@ try { let customPlugins = path.join(__dirname, 'plugins.json'); let defaultPlugins = path.join(__dirname, 'plugins.default.json'); - if (process.env.TALK_PLUGINS_JSON && process.env.TALK_PLUGINS_JSON.length > 0) { + if (PLUGINS_JSON && PLUGINS_JSON.length > 0) { debug('Now using TALK_PLUGINS_JSON environment variable for plugins'); plugins = require(envPlugins); } else if (fs.existsSync(customPlugins)) { diff --git a/plugins/coral-plugin-facebook-auth/server/router.js b/plugins/coral-plugin-facebook-auth/server/router.js index 7ef7b0ed2..56fcd2ab2 100644 --- a/plugins/coral-plugin-facebook-auth/server/router.js +++ b/plugins/coral-plugin-facebook-auth/server/router.js @@ -15,6 +15,6 @@ module.exports = (router) => { router.get('/api/v1/auth/facebook/callback', (req, res, next) => { // Perform the facebook login flow and pass the data back through the opener. - passport.authenticate('facebook', HandleAuthPopupCallback(req, res, next))(req, res, next); + passport.authenticate('facebook', {session: false}, HandleAuthPopupCallback(req, res, next))(req, res, next); }); }; diff --git a/routes/admin/index.js b/routes/admin/index.js index 1a9769591..44ce83222 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,5 +1,8 @@ const express = require('express'); const router = express.Router(); +const { + RECAPTCHA_PUBLIC +} = require('../../config'); // Get /email-confirmation expects a signed JWT in the hash router.get('/confirm-email', (req, res) => { @@ -17,7 +20,7 @@ router.get('/password-reset', (req, res) => { router.get('*', (req, res) => { const data = { - TALK_RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC + TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC }; res.render('admin', {basePath: '/client/coral-admin', data}); diff --git a/routes/api/account/index.js b/routes/api/account/index.js index e1c73d638..4c49c00a5 100644 --- a/routes/api/account/index.js +++ b/routes/api/account/index.js @@ -4,6 +4,9 @@ const UsersService = require('../../../services/users'); const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); const errors = require('../../../errors'); +const { + ROOT_URL +} = require('../../../config'); //============================================================================== // ROUTES @@ -62,7 +65,7 @@ router.post('/password/reset', (req, res, next) => { template: 'password-reset', // needed to know which template to render! locals: { // specifies the template locals. token, - rootURL: process.env.TALK_ROOT_URL + rootURL: ROOT_URL }, subject: 'Password Reset', to: email diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index 4926192de..6f610e2b0 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -1,6 +1,5 @@ const express = require('express'); -const {passport, HandleAuthCallback} = require('../../../services/passport'); -const authorization = require('../../../middleware/authorization'); +const {passport, HandleGenerateCredentials, HandleLogout} = require('../../../services/passport'); const router = express.Router(); @@ -21,13 +20,9 @@ router.get('/', (req, res, next) => { }); /** - * This destroys the session of a user, if they have one. + * This blacklists the token used to authenticate. */ -router.delete('/', authorization.needed(), (req, res) => { - delete req.session.passport; - - res.status(204).end(); -}); +router.delete('/', HandleLogout); //============================================================================== // PASSPORT ROUTES @@ -39,7 +34,7 @@ router.delete('/', authorization.needed(), (req, res) => { router.post('/local', (req, res, next) => { // Perform the local authentication. - passport.authenticate('local', HandleAuthCallback(req, res, next))(req, res, next); + passport.authenticate('local', {session: false}, HandleGenerateCredentials(req, res, next))(req, res, next); }); module.exports = router; diff --git a/routes/api/users/index.js b/routes/api/users/index.js index a01fed2c9..9d81b87b7 100644 --- a/routes/api/users/index.js +++ b/routes/api/users/index.js @@ -5,6 +5,9 @@ const CommentsService = require('../../../services/comments'); const mailer = require('../../../services/mailer'); const errors = require('../../../errors'); const authorization = require('../../../middleware/authorization'); +const { + ROOT_URL +} = require('../../../config'); // get a list of users. router.get('/', authorization.needed('ADMIN'), (req, res, next) => { @@ -108,7 +111,7 @@ const SendEmailConfirmation = (app, userID, email, referer) => UsersService template: 'email-confirm', // needed to know which template to render! locals: { // specifies the template locals. token, - rootURL: process.env.TALK_ROOT_URL, + rootURL: ROOT_URL, email }, subject: 'Email Confirmation', diff --git a/routes/embed/index.js b/routes/embed/index.js index efa563422..0aaa9ff9c 100644 --- a/routes/embed/index.js +++ b/routes/embed/index.js @@ -1,6 +1,9 @@ const express = require('express'); const router = express.Router(); const SettingsService = require('../../services/settings'); +const { + RECAPTCHA_PUBLIC +} = require('../../config'); router.use('/:embed', (req, res, next) => { switch (req.params.embed) { @@ -8,7 +11,7 @@ router.use('/:embed', (req, res, next) => { return SettingsService.retrieve() .then(({customCssUrl}) => { const data = { - TALK_RECAPTCHA_PUBLIC: process.env.TALK_RECAPTCHA_PUBLIC + TALK_RECAPTCHA_PUBLIC: RECAPTCHA_PUBLIC }; return res.render('embed/stream', {customCssUrl, data}); diff --git a/services/mailer.js b/services/mailer.js index 35741ee9f..f3d9235ba 100644 --- a/services/mailer.js +++ b/services/mailer.js @@ -5,16 +5,13 @@ const path = require('path'); const fs = require('fs'); const _ = require('lodash'); -const smtpRequiredProps = [ - 'TALK_SMTP_FROM_ADDRESS', - 'TALK_SMTP_USERNAME', - 'TALK_SMTP_PASSWORD', - 'TALK_SMTP_HOST' -]; - -if (smtpRequiredProps.some(prop => !process.env[prop])) { - console.error(`${smtpRequiredProps.join(', ')} should be defined in the environment if you would like to send password reset emails from Talk`); -} +const { + SMTP_HOST, + SMTP_USERNAME, + SMTP_PORT, + SMTP_PASSWORD, + SMTP_FROM_ADDRESS +} = require('../config'); // load all the templates as strings const templates = { @@ -56,15 +53,15 @@ templates.render = (name, format = 'txt', context) => new Promise((resolve, reje }); const options = { - host: process.env.TALK_SMTP_HOST, + host: SMTP_HOST, auth: { - user: process.env.TALK_SMTP_USERNAME, - pass: process.env.TALK_SMTP_PASSWORD + user: SMTP_USERNAME, + pass: SMTP_PASSWORD } }; -if (process.env.TALK_SMTP_PORT) { - options.port = process.env.TALK_SMTP_PORT; +if (SMTP_PORT) { + options.port = SMTP_PORT; } else { options.port = 25; } @@ -126,7 +123,7 @@ const mailer = module.exports = { debug(`Starting to send mail for Job[${id}]`); // Set the `from` field. - data.message.from = process.env.TALK_SMTP_FROM_ADDRESS; + data.message.from = SMTP_FROM_ADDRESS; // Actually send the email. defaultTransporter.sendMail(data.message, (err) => { diff --git a/services/mongoose.js b/services/mongoose.js index c7092326f..acda2f564 100644 --- a/services/mongoose.js +++ b/services/mongoose.js @@ -1,7 +1,12 @@ const mongoose = require('mongoose'); const debug = require('debug')('talk:db'); +const enabled = require('debug').enabled; const queryDebuger = require('debug')('talk:db:query'); +const { + MONGO_URL +} = require('../config'); + // Loading the formatter from Mongoose: // // https://github.com/Automattic/mongoose/blob/1a93d1f4d12e441e17ddf451e96fbc5f6e8f54b8/lib/drivers/node-mongodb-native/collection.js#L182 @@ -24,18 +29,6 @@ function debugQuery(name, i, ...args) { queryDebuger(functionCall + params); } -const enabled = require('debug').enabled; - -// Pull the mongo url out of the environment. -let url = process.env.TALK_MONGO_URL; - -// Reset the mongo url in the event it hasn't been overrided and we are in a -// testing environment. Every new mongo instance comes with a test database by -// default, this is consistent with common testing and use case practices. -if (process.env.NODE_ENV === 'test' && !url) { - url = 'mongodb://localhost/test'; -} - // Use native promises mongoose.Promise = global.Promise; @@ -48,7 +41,7 @@ if (enabled('talk:db')) { } // Connect to the Mongo instance. -mongoose.connect(url, (err) => { +mongoose.connect(MONGO_URL, (err) => { if (err) { throw err; } diff --git a/services/passport.js b/services/passport.js index 682d86ce1..c64bb2334 100644 --- a/services/passport.js +++ b/services/passport.js @@ -3,33 +3,39 @@ const UsersService = require('./users'); const SettingsService = require('./settings'); const fetch = require('node-fetch'); const FormData = require('form-data'); +const JWT = require('jsonwebtoken'); const LocalStrategy = require('passport-local').Strategy; const errors = require('../errors'); +const uuid = require('uuid'); const debug = require('debug')('talk:passport'); +const {createClient} = require('./redis'); -//============================================================================== -// SESSION SERIALIZATION -//============================================================================== +// Create a redis client to use for authentication. +const client = createClient(); -passport.serializeUser((user, done) => { - done(null, user.id); +const { + JWT_SECRET, + JWT_ISSUER, + JWT_EXPIRY, + JWT_AUDIENCE, + RECAPTCHA_SECRET, + RECAPTCHA_ENABLED +} = require('../config'); + +// GenerateToken will sign a token to include all the authorization information +// needed for the front end. +const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, { + jwtid: uuid.v4(), + expiresIn: JWT_EXPIRY, + issuer: JWT_ISSUER, + subject: user.id, + audience: JWT_AUDIENCE }); -passport.deserializeUser((id, done) => { - UsersService - .findById(id) - .then((user) => { - done(null, user); - }) - .catch((err) => { - done(err); - }); -}); - -/** - * This sends back the user data as JSON. - */ -const HandleAuthCallback = (req, res, next) => (err, user) => { +// HandleGenerateCredentials validates that an authentication scheme did indeed +// return a user, if it did, then sign and return the user and token to be used +// by the frontend to display and update the UI. +const HandleGenerateCredentials = (req, res, next) => (err, user) => { if (err) { return next(err); } @@ -38,15 +44,11 @@ const HandleAuthCallback = (req, res, next) => (err, user) => { return next(errors.ErrNotAuthorized); } - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return next(err); - } + // Generate the token to re-issue to the frontend. + const token = GenerateToken(user); - // We logged in the user! Let's send back the user data and the CSRF token. - res.json({user}); - }); + // Send back the details! + res.json({user, token}); }; /** @@ -54,22 +56,18 @@ const HandleAuthCallback = (req, res, next) => (err, user) => { */ const HandleAuthPopupCallback = (req, res, next) => (err, user) => { if (err) { - return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + return res.render('auth-callback', {auth: JSON.stringify({err, data: null})}); } if (!user) { - return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null}); + return res.render('auth-callback', {auth: JSON.stringify({err: errors.ErrNotAuthorized, data: null})}); } - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return res.render('auth-callback', {err: JSON.stringify(err), data: null}); - } + // Generate the token to re-issue to the frontend. + const token = GenerateToken(user); - // We logged in the user! Let's send back the user data. - res.render('auth-callback', {err: null, data: JSON.stringify(user)}); - }); + // We logged in the user! Let's send back the user data. + res.render('auth-callback', {auth: JSON.stringify({err: null, data: {user, token}})}); }; /** @@ -119,7 +117,91 @@ function ValidateUserLogin(loginProfile, user, done) { } //============================================================================== -// STRATEGIES +// JWT STRATEGY +//============================================================================== + +/** + * Revoke the token on the request. + */ +const HandleLogout = (req, res, next) => { + const {jwt} = req; + + const now = new Date(); + const expiry = (jwt.exp - now.getTime() / 1000).toFixed(0); + + client.set(`jtir[${jwt.jti}]`, now.toISOString(), 'EX', expiry, (err) => { + if (err) { + return next(err); + } + + res.status(204).end(); + }); +}; + +/** + * Check if the given token is already blacklisted, throw an error if it is. + */ +const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => { + client.get(`jtir[${jwt.jti}]`, (err, expiry) => { + if (err) { + return reject(err); + } + + if (expiry != null) { + return reject(new errors.ErrAuthentication('token was revoked')); + } + + return resolve(); + }); +}); + +const JwtStrategy = require('passport-jwt').Strategy; +const ExtractJwt = require('passport-jwt').ExtractJwt; + +// Extract the JWT from the 'Authorization' header with the 'Bearer' scheme. +passport.use(new JwtStrategy({ + + // Prepare the extractor from the header. + jwtFromRequest: ExtractJwt.fromAuthHeaderWithScheme('Bearer'), + + // Use the secret passed in which is loaded from the environment. This can be + // a certificate (loaded) or a HMAC key. + secretOrKey: JWT_SECRET, + + // Verify the issuer. + issuer: JWT_ISSUER, + + // Verify the audience. + audience: JWT_AUDIENCE, + + // Enable only the HS256 algorithm. + algorithms: ['HS256'], + + // Pass the request objecto back to the callback so we can attach the JWT to + // it. + passReqToCallback: true +}, async (req, jwt, done) => { + + // Load the user from the environment, because we just got a user from the + // header. + try { + + // Check to see if the token has been revoked + await CheckBlacklisted(jwt); + + let user = await UsersService.findById(jwt.sub); + + // Attach the JWT to the request. + req.jwt = jwt; + + return done(null, user); + } catch(e) { + return done(e); + } +})); + +//============================================================================== +// LOCAL STRATEGY //============================================================================== /** @@ -157,21 +239,6 @@ const CheckIfNeedsRecaptcha = (user, email) => { return false; }; -/** - * This stores the Recaptcha secret. - */ -const RECAPTCHA_SECRET = process.env.TALK_RECAPTCHA_SECRET; -const RECAPTCHA_PUBLIC = process.env.TALK_RECAPTCHA_PUBLIC; - -/** - * This is true when the recaptcha secret is provided and the Recaptcha feature - * is to be enabled. - */ -const RECAPTCHA_ENABLED = RECAPTCHA_SECRET && RECAPTCHA_SECRET.length > 0 && RECAPTCHA_PUBLIC && RECAPTCHA_PUBLIC.length > 0; -if (!RECAPTCHA_ENABLED) { - console.log('Recaptcha is not enabled for login/signup abuse prevention, set TALK_RECAPTCHA_SECRET and TALK_RECAPTCHA_PUBLIC to enable Recaptcha.'); -} - /** * This sends the request details down Google to check to see if the response is * genuine or not. @@ -356,6 +423,8 @@ module.exports = { passport, ValidateUserLogin, HandleFailedAttempt, - HandleAuthCallback, - HandleAuthPopupCallback + HandleAuthPopupCallback, + HandleGenerateCredentials, + HandleLogout, + CheckBlacklisted }; diff --git a/services/redis.js b/services/redis.js index d27303fca..c049b2d00 100644 --- a/services/redis.js +++ b/services/redis.js @@ -1,9 +1,11 @@ const redis = require('redis'); const debug = require('debug')('talk:redis'); -const url = process.env.TALK_REDIS_URL || 'redis://localhost'; +const { + REDIS_URL +} = require('../config'); const connectionOptions = { - url, + url: REDIS_URL, retry_strategy: function(options) { if (options.error && options.error.code === 'ECONNREFUSED') { diff --git a/services/session.js b/services/session.js deleted file mode 100644 index 505dfdf4a..000000000 --- a/services/session.js +++ /dev/null @@ -1,36 +0,0 @@ -const session = require('express-session'); -const RedisStore = require('connect-redis')(session); -const redis = require('./redis'); - -//============================================================================== -// SESSION MIDDLEWARE -//============================================================================== - -const session_opts = { - secret: process.env.TALK_SESSION_SECRET, - httpOnly: true, - rolling: true, - saveUninitialized: true, - resave: true, - unset: 'destroy', - name: 'talk.sid', - cookie: { - secure: false, - maxAge: 8.64e+7, // 24 hours for session token expiry - }, - store: new RedisStore({ - client: redis.createClient(), - }) -}; - -if (process.env.NODE_ENV === 'production') { - - // Enable the secure cookie when we are in production mode. - session_opts.cookie.secure = true; -} else if (process.env.NODE_ENV === 'test') { - - // Add in the secret during tests. - session_opts.secret = 'keyboard cat'; -} - -module.exports = session(session_opts); diff --git a/services/setup.js b/services/setup.js index 1f1542d5e..233021c17 100644 --- a/services/setup.js +++ b/services/setup.js @@ -2,6 +2,9 @@ const UsersService = require('./users'); const SettingsService = require('./settings'); const SettingsModel = require('../models/setting'); const errors = require('../errors'); +const { + INSTALL_LOCK +} = require('../config'); /** * This service is used when we want to setup the application. It is consumed by @@ -15,7 +18,7 @@ module.exports = class SetupService { static isAvailable() { // Check if we have an install lock present. - if (process.env.TALK_INSTALL_LOCK === 'TRUE') { + if (INSTALL_LOCK) { return Promise.reject(errors.ErrInstallLock); } diff --git a/services/subscriptions.js b/services/subscriptions.js index 8c34507f2..c4cd2378a 100644 --- a/services/subscriptions.js +++ b/services/subscriptions.js @@ -1,12 +1,18 @@ -const session = require('./session'); const passport = require('./passport'); +const authentication = require('../middleware/authentication'); // Session data does not automatically attach to websocket req objects. // This middleware code looks for a user in the session and, if it exists, // attaches it to the graph req. const deserializeUser = (req) => { return new Promise((resolve, reject) => { - session(req, {}, () => { + + // This uses the authentication connect middleware to establish the session + // user details from the headers. + authentication(req, null, (err) => { + if (err) { + return reject(err); + } if ('session' in req && 'passport' in req.session && 'user' in req.session.passport) { passport.deserializeUser(req.session.passport.user, (err, user) => { diff --git a/services/users.js b/services/users.js index 14301ccf4..03c1671d5 100644 --- a/services/users.js +++ b/services/users.js @@ -1,12 +1,14 @@ const assert = require('assert'); +const uuid = require('uuid'); const bcrypt = require('bcrypt'); const url = require('url'); const jwt = require('jsonwebtoken'); const Wordlist = require('./wordlist'); - const errors = require('../errors'); - -const uuid = require('uuid'); +const { + JWT_SECRET, + ROOT_URL +} = require('../config'); const redis = require('./redis'); const redisClient = redis.createClient(); @@ -22,14 +24,6 @@ const SettingsService = require('./settings'); const ActionsService = require('./actions'); const MailerService = require('./mailer'); -// In the event that the TALK_SESSION_SECRET is missing but we are testing, then -// set the process.env.TALK_SESSION_SECRET. -if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { - process.env.TALK_SESSION_SECRET = 'keyboard cat'; -} else if (!process.env.TALK_SESSION_SECRET) { - throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); -} - const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm'; const PASSWORD_RESET_JWT_SUBJECT = 'password_reset'; @@ -564,7 +558,7 @@ module.exports = class UsersService { version: user.__v }; - return jwt.sign(payload, process.env.TALK_SESSION_SECRET, { + return jwt.sign(payload, JWT_SECRET, { algorithm: 'HS256', expiresIn: '1d', subject: PASSWORD_RESET_JWT_SUBJECT @@ -583,7 +577,7 @@ module.exports = class UsersService { // Set the allowed algorithms. options.algorithms = ['HS256']; - jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => { + jwt.verify(token, JWT_SECRET, options, (err, decoded) => { if (err) { return reject(err); } @@ -697,7 +691,7 @@ module.exports = class UsersService { * @param {String} email The email that we are needing to get confirmed. * @return {Promise} */ - static createEmailConfirmToken(userID = null, email, referer = process.env.TALK_ROOT_URL) { + static createEmailConfirmToken(userID = null, email, referer = ROOT_URL) { if (!email || typeof email !== 'string') { return Promise.reject('email is required when creating a JWT for resetting passord'); } @@ -740,7 +734,7 @@ module.exports = class UsersService { email, referer, userID: user.id - }, process.env.TALK_SESSION_SECRET, tokenOptions); + }, JWT_SECRET, tokenOptions); }); } diff --git a/views/admin.ejs b/views/admin.ejs index 947f15425..946284622 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -3,7 +3,6 @@ - Talk - Coral Admin diff --git a/views/admin/confirm-email.ejs b/views/admin/confirm-email.ejs index 6b4d456d8..20d213fa2 100644 --- a/views/admin/confirm-email.ejs +++ b/views/admin/confirm-email.ejs @@ -74,9 +74,6 @@ url: '/api/v1/account/email/verify', contentType: 'application/json', method: 'POST', - headers: { - 'X-CSRF-Token': '<%= csrfToken %>' - }, data: JSON.stringify({token: location.hash.replace('#', '')}) }).then(function (success) { location.href = success.redirectUri; diff --git a/views/admin/password-reset.ejs b/views/admin/password-reset.ejs index 4b25d1279..704b5213d 100644 --- a/views/admin/password-reset.ejs +++ b/views/admin/password-reset.ejs @@ -82,7 +82,6 @@
- Set new password