diff --git a/README.md b/README.md index ffc1f3077..0f217d89c 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,12 @@ Runs Talk. The Talk application requires specific configuration options to be available inside the environment in order to run, those variables are listed here: -- `TALK_SESSION_SECRET` (*required*) - -- `TALK_FACEBOOK_APP_ID` (*required*) - -- `TALK_FACEBOOK_APP_SECRET` (*required*) - +- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to + secure cookies +- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook + Login enabled app. +- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your + Facebook Login enabled app. - `TALK_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. ### Running with Docker diff --git a/app.js b/app.js index d945d6254..f5b39a074 100644 --- a/app.js +++ b/app.js @@ -38,6 +38,7 @@ const session_opts = { rolling: true, saveUninitialized: false, resave: false, + name: 'talk.sid', cookie: { secure: false, maxAge: 18000000, // 30 minutes for expiry. diff --git a/cache.js b/cache.js new file mode 100644 index 000000000..efe689f9c --- /dev/null +++ b/cache.js @@ -0,0 +1,97 @@ +const redis = require('./redis'); + +const cache = module.exports = {}; + +/** + * This collects a key that may either be an array or a string and creates a + * unified key out of it. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {String} A string that represents a key + */ +const keyfunc = (key) => { + if (Array.isArray(key)) { + return `cache[${key.join(':')}]`; + } + + return `cache[${key}]`; +}; + +/** + * This wraps a complicated function with a cache, in the event that the item is + * not inside the cache, it will perform the work to get it and then set it + * followed by returning the value. + * @param {Mixed} key Either an array of items or string represening this + * work + * @param {Integer} expiry Time in seconds for the cache entry to live for + * @param {Function} work A function that returns a promise that can be + * resolved as the value to cache. + * @return {Promise} Resolves to the value either retrieved from cache + */ +cache.wrap = (key, expiry, work) => { + return cache + .get(key) + .then((value) => { + if (value !== null) { + return value; + } + + return work() + .then((value) => { + return cache + .set(key, value, expiry) + .then(() => value); + }); + }); +}; + +/** + * This returns a promise that returns a promise that resolves with the value + * from the cache or null if it does not exist in the cache. + * @param {Mixed} key Either an array of items composing a key or a string + * @return {Promise} + */ +cache.get = (key) => new Promise((resolve, reject) => { + redis.get(keyfunc(key), (err, reply) => { + if (err) { + return reject(err); + } + + if (reply !== null) { + let value; + + try { + + // Parse the stored cache value from JSON. + value = JSON.parse(reply); + } catch (e) { + return reject(e); + } + + return resolve(value); + } + + resolve(null); + }); +}); + +/** + * This sets a value on the key with the expiry and then resolves once it is + * done. + * @param {Mixed} key Either an array of items composing a key or a string + * @param {Mixed} value Object to be serialized and set to the cache + * @param {Integer} expiry Time in seconds for the cache entry to live for + * @return {Promise} + */ +cache.set = (key, value, expiry) => new Promise((resolve, reject) => { + + // Serialize the value as JSON. + let reply = JSON.stringify(value); + + redis.set(keyfunc(key), reply, 'EX', expiry, (err) => { + if (err) { + return reject(err); + } + + return resolve(); + }); +}); diff --git a/models/user.js b/models/user.js index c0a846f1a..d76bf8441 100644 --- a/models/user.js +++ b/models/user.js @@ -12,6 +12,7 @@ const UserSchema = new mongoose.Schema({ required: true }, displayName: String, + photo: String, disabled: Boolean, password: String, profiles: [{ @@ -25,11 +26,6 @@ const UserSchema = new mongoose.Schema({ } }], roles: [String] -}, { - timestamps: { - createdAt: 'created_at', - updatedAt: 'updated_at' - } }); // Add the indixies on the user profile data. @@ -57,22 +53,6 @@ UserSchema.options.toJSON.transform = (doc, ret, options) => { return ret; }; -/** - * toObject overrides to remove the password field from the toObject - * output. - */ -UserSchema.options.toObject = {}; -UserSchema.options.toObject.hide = 'password'; -UserSchema.options.toObject.transform = (doc, ret, options) => { - if (options.hide) { - options.hide.split(' ').forEach((prop) => { - delete ret[prop]; - }); - } - - return ret; -}; - /** * Finds a user given their email address that we have for them in the system * and ensures that the retuned user matches the password passed in as well. @@ -121,21 +101,19 @@ UserSchema.statics.findLocalUser = function(email, password) { UserSchema.statics.mergeUsers = function(dstUserID, srcUserID) { let srcUser, dstUser; - return Promise - .all([ - User.findOne({id: dstUserID}).exec(), - User.findOne({id: srcUserID}).exec() - ]) - .then((users) => { - dstUser = users[0]; - srcUser = users[1]; + return Promise.all([ + User.findOne({id: dstUserID}).exec(), + User.findOne({id: srcUserID}).exec() + ]).then((users) => { + dstUser = users[0]; + srcUser = users[1]; - srcUser.profiles.forEach((profile) => { - dstUser.profiles.push(profile); - }); + srcUser.profiles.forEach((profile) => { + dstUser.profiles.push(profile); + }); - return srcUser.remove(); - }) + return srcUser.remove(); + }) .then(() => dstUser.save()); }; @@ -146,34 +124,34 @@ UserSchema.statics.mergeUsers = function(dstUserID, srcUserID) { * @param {Function} done [description] */ UserSchema.statics.findOrCreateExternalUser = function(profile) { - return User - .findOne({ - profiles: { - $elemMatch: { + return User.findOne({ + profiles: { + $elemMatch: { + id: profile.id, + provider: profile.provider + } + } + }) + .then((user) => { + if (user) { + return user; + } + + // The user was not found, lets create them! + user = new User({ + displayName: profile.displayName, + roles: [], + photo: Array.isArray(profile.photos) && profile.photos.length > 0 ? profile.photos[0].value : null, + profiles: [ + { id: profile.id, provider: profile.provider } - } - }) - .then((user) => { - if (user) { - return user; - } - - // The user was not found, lets create them! - user = new User({ - displayName: profile.displayName, - roles: [], - profiles: [ - { - id: profile.id, - provider: profile.provider - } - ] - }); - - return user.save(); + ] }); + + return user.save(); + }); }; UserSchema.statics.changePassword = function(id, password) { diff --git a/passport.js b/passport.js index ae4776894..2e084eafd 100644 --- a/passport.js +++ b/passport.js @@ -65,7 +65,8 @@ if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && passport.use(new FacebookStrategy({ clientID: process.env.TALK_FACEBOOK_APP_ID, clientSecret: process.env.TALK_FACEBOOK_APP_SECRET, - callbackURL: `${process.env.TALK_ROOT_URL}/connect/facebook/callback` + callbackURL: `${process.env.TALK_ROOT_URL}/api/v1/auth/facebook/callback`, + profileFields: ['id', 'displayName', 'picture.type(large)'] }, (accessToken, refreshToken, profile, done) => { User .findOrCreateExternalUser(profile) diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js index 57d126449..87ffb62c8 100644 --- a/routes/api/auth/index.js +++ b/routes/api/auth/index.js @@ -4,40 +4,91 @@ const authorization = require('../../../middleware/authorization'); const router = express.Router(); +/** + * This returns the user if they are logged in. + */ router.get('/', authorization.needed(), (req, res) => { res.json(req.user); }); +/** + * This destroys the session of a user, if they have one. + */ router.delete('/', (req, res) => { - req.logout(); - res.status(204).end(); + req.session.destroy(() => { + res.status(204).end(); + }); }); +/** + * This sends back the user data as JSON. + */ +const HandleAuthCallback = (req, res, next) => (err, user) => { + if (err) { + return next(err); + } + + if (!user) { + return next(authorization.ErrNotAuthorized); + } + + // Perform the login of the user! + req.logIn(user, (err) => { + if (err) { + return next(err); + } + + // We logged in the user! Let's send back the user data. + res.json({user}); + }); +}; + +/** + * Returns the response to the login attempt via a popup callback with some JS. + */ +const HandleAuthPopupCallback = (req, res, next) => (err, user) => { + if (err) { + return res.render('auth-callback', {err: JSON.stringify(err), data: null}); + } + + if (!user) { + return res.render('auth-callback', {err: JSON.stringify(authorization.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}); + } + + // We logged in the user! Let's send back the user data. + res.render('auth-callback', {err: null, data: JSON.stringify(user)}); + }); +}; + /** * Local auth endpoint, will recieve a email and password */ router.post('/local', (req, res, next) => { // Perform the local authentication. - passport.authenticate('local', (err, user) => { - if (err) { - return next(err); - } + passport.authenticate('local', HandleAuthCallback(req, res, next))(req, res, next); +}); - if (!user) { - return next(authorization.ErrNotAuthorized); - } +/** + * Facebook auth endpoint, this will redirect the user immediatly to facebook + * for authorization. + */ +router.get('/facebook', passport.authenticate('facebook', {display: 'popup', authType: 'rerequest', scope: ['public_profile']})); - // Perform the login of the user! - req.logIn(user, (err) => { - if (err) { - return next(err); - } +/** + * Facebook callback endpoint, this will send the user a html page designed to + * send back the user credentials upon sucesfull login. + */ +router.get('/facebook/callback', (req, res, next) => { - // We logged in the user! Let's send back the user data. - res.json({user}); - }); - })(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); }); module.exports = router; diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 5126bf93c..6492f5968 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -109,7 +109,7 @@ router.post('/:comment_id', (req, res, next) => { }); router.post('/:comment_id/status', (req, res, next) => { - + Comment .changeStatus(req.params.comment_id, req.body.status) .then(comment => res.status(200).send(comment)) diff --git a/views/auth-callback.ejs b/views/auth-callback.ejs new file mode 100644 index 000000000..d38d759ee --- /dev/null +++ b/views/auth-callback.ejs @@ -0,0 +1,9 @@ + + + + + +