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/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 bfca21643..f5ab201ef 100644 --- a/package.json +++ b/package.json @@ -58,14 +58,12 @@ "commander": "^2.9.0", "connect-redis": "^3.1.0", "cross-spawn": "^5.1.0", - "csurf": "^1.9.0", "dataloader": "^1.3.0", "debug": "^2.6.3", "dotenv": "^4.0.0", "ejs": "^2.5.6", "env-rewrite": "^1.0.2", "express": "^4.15.2", - "express-session": "^1.15.1", "form-data": "^2.1.2", "gql-merge": "^0.0.4", "graphql": "^0.9.1", @@ -77,11 +75,10 @@ "helmet": "^3.5.0", "inquirer": "^3.0.6", "joi": "^10.4.1", - "jsonwebtoken": "^7.3.0", + "jsonwebtoken": "^7.4.0", "kue": "^0.11.5", "linkify-it": "^2.0.3", "lodash": "^4.16.6", - "marked": "^0.3.6", "metascraper": "^1.0.6", "minimist": "^1.2.0", "mongoose": "^4.9.1", @@ -92,18 +89,12 @@ "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", "uuid": "^3.0.1" }, "devDependencies": { @@ -157,6 +148,7 @@ "keymaster": "^1.6.2", "license-webpack-plugin": "^0.4.2", "material-design-lite": "^1.2.1", + "marked": "^0.3.6", "mocha": "^3.1.2", "mocha-junit-reporter": "^1.12.1", "nightwatch": "^0.9.11", @@ -178,11 +170,16 @@ "react-redux": "^4.4.5", "react-router": "^3.0.0", "react-tagsinput": "^3.14.0", + "prop-types": "^15.5.8", + "react-apollo": "^1.1.0", + "react-recaptcha": "^2.2.6", + "recompose": "^0.23.1", "redux": "^3.6.0", "redux-mock-store": "^1.2.1", "redux-thunk": "^2.1.0", "regenerator": "^0.8.46", "selenium-standalone": "^5.11.2", + "simplemde": "^1.11.2", "style-loader": "^0.16.0", "subscriptions-transport-ws": "^0.5.5-alpha.0", "supertest": "^2.0.1", 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/api/auth/index.js b/routes/api/auth/index.js index 4926192de..54932ac03 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} = require('../../../services/passport'); const router = express.Router(); @@ -20,15 +19,6 @@ router.get('/', (req, res, next) => { res.json({user: req.user}); }); -/** - * This destroys the session of a user, if they have one. - */ -router.delete('/', authorization.needed(), (req, res) => { - delete req.session.passport; - - res.status(204).end(); -}); - //============================================================================== // PASSPORT ROUTES //============================================================================== @@ -39,7 +29,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/services/passport.js b/services/passport.js index 682d86ce1..a455fa745 100644 --- a/services/passport.js +++ b/services/passport.js @@ -3,33 +3,43 @@ 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'); -//============================================================================== -// SESSION SERIALIZATION -//============================================================================== +// JWT_SECRET is the secret used to sign and verify tokens issued by this +// application. +const JWT_SECRET = process.env.JWT_SECRET; -passport.serializeUser((user, done) => { - done(null, user.id); +// JWT_EXPIRY is the time for which a given token is valid for. +const JWT_EXPIRY = process.env.JWT_EXPIRY || '1 day'; + +// 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. +const JWT_ISSUER = process.env.JWT_ISSUER || process.env.TALK_ROOT_URL || undefined; + +// 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`. +const JWT_AUDIENCE = process.env.JWT_AUDIENCE || 'talk'; + +// 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 +48,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 +60,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, 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 +121,45 @@ function ValidateUserLogin(loginProfile, user, done) { } //============================================================================== -// STRATEGIES +// JWT STRATEGY +//============================================================================== + +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'] +}, async (jwt, done) => { + + // Load the user from the environment, because we just got a user from the + // header. + try { + let user = await UsersService.findById(jwt.sub); + + return done(null, user); + } catch(e) { + return done(e); + } +})); + +//============================================================================== +// LOCAL STRATEGY //============================================================================== /** @@ -356,6 +396,6 @@ module.exports = { passport, ValidateUserLogin, HandleFailedAttempt, - HandleAuthCallback, - HandleAuthPopupCallback + HandleAuthPopupCallback, + HandleGenerateCredentials }; 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/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/views/admin.ejs b/views/admin.ejs index 947f15425..946284622 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -3,7 +3,6 @@
-