diff --git a/Dockerfile b/Dockerfile index 3be5916a8..6776e2f04 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ RUN mkdir -p /usr/src/app WORKDIR /usr/src/app # Setup the environment +ENV NODE_ENV production ENV PATH /usr/src/app/bin:$PATH ENV TALK_PORT 5000 EXPOSE 5000 diff --git a/README.md b/README.md index f4d8b31de..ffc1f3077 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ Run it once to install the dependencies. `npm start` Runs Talk. +### Configuration + +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_ROOT_URL` (*required*) - Root url of the installed application externally available in the format: `://` without the path. + ### Running with Docker Make sure you have Docker running first and then run `docker-compose up -d` diff --git a/app.js b/app.js index 22f343221..ed928622c 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,11 @@ const express = require('express'); const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); +const helmet = require('helmet'); +const passport = require('./passport'); +const session = require('express-session'); +const RedisStore = require('connect-redis')(session); +const redis = require('./redis'); const app = express(); @@ -12,12 +17,57 @@ if (app.get('env') !== 'test') { app.use(morgan('dev')); } +//============================================================================== +// APP MIDDLEWARE +//============================================================================== + +app.set('trust proxy', 'loopback'); +app.use(helmet()); app.use(bodyParser.json()); +app.use('/client', express.static(path.join(__dirname, 'dist'))); app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); -// Routes. -app.use('/client', express.static(path.join(__dirname, 'dist'))); +//============================================================================== +// SESSION MIDDLEWARE +//============================================================================== + +const session_opts = { + secret: process.env.TALK_SESSION_SECRET, + httpOnly: true, + rolling: true, + saveUninitialized: false, + resave: false, + cookie: { + secure: false, + maxAge: 18000000, // 30 minutes for expiry. + }, + store: new RedisStore({ + ttl: 1800, + client: redis, + }) +}; + +if (app.get('env') === 'production') { + + // Enable the secure cookie when we are in production mode. + session_opts.cookie.secure = true; +} + +app.use(session(session_opts)); + +//============================================================================== +// PASSPORT MIDDLEWARE +//============================================================================== + +// Setup the PassportJS Middleware. +app.use(passport.initialize()); +app.use(passport.session()); + +//============================================================================== +// ROUTES +//============================================================================== + app.use('/', require('./routes')); //============================================================================== diff --git a/docker-compose.yml b/docker-compose.yml index ecfac00bc..d7a180277 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,10 @@ services: environment: - "TALK_PORT=5000" - "TALK_MONGO_URL=mongodb://mongo" + - "TALK_REDIS_URL=redis://redis" depends_on: - mongo + - redis mongo: image: mongo:3.2 @@ -19,6 +21,14 @@ services: volumes: - mongo:/data/db + redis: + image: redis:3.2-alpine + restart: always + volumes: + - redis:/data + volumes: mongo: external: false + redis: + external: false diff --git a/middleware/authorization.js b/middleware/authorization.js new file mode 100644 index 000000000..238219a06 --- /dev/null +++ b/middleware/authorization.js @@ -0,0 +1,53 @@ +/** + * authorization contains the references to the authorization middleware. + * @type {Object} + */ +const authorization = module.exports = {}; + +const debug = require('debug')('talk:middleware:authorization'); + +/** + * ErrNotAuthorized is an error that is returned in the event an operation is + * deemed not authorized. + * @type {Error} + */ +const ErrNotAuthorized = new Error('not authorized'); +ErrNotAuthorized.status = 401; + +// Add the ErrNotAuthorized error to the authorization object to be exported. +authorization.ErrNotAuthorized = ErrNotAuthorized; + +/** + * has returns true if the user has all the roles specified, otherwise it will + * return false. + * @param {Object} user the user to check for roles + * @param {Array} roles all the roles that a user must have + * @return {Boolean} true if the user has all the roles required, false + * otherwise + */ +authorization.has = (user, ...roles) => roles.every((role) => user.roles.indexOf(role) >= 0); + +/** + * needed is a connect middleware layer that ensures that all requests coming + * here are both authenticated and match a set of roles required to continue. + * @param {Array} roles all the roles that a user must have + * @return {Callback} connect middleware + */ +authorization.needed = (...roles) => (req, res, next) => { + // All routes that are wrapepd with this middleware actually require a role. + if (!req.user) { + debug(`No user on request, returning with ${ErrNotAuthorized}`); + return next(ErrNotAuthorized); + } + + // Check to see if the current user has all the roles requested for the given + // array of roles requested, if one is not on the user, then this will + // evaluate to true. + if (!authorization.has(req.user, ...roles)) { + debug('User does not have all the required roles to access this page'); + return next(ErrNotAuthorized); + } + + // Looks like they're allowed! + return next(); +}; diff --git a/mongoose.js b/mongoose.js index 9062816e6..712b2fcb0 100644 --- a/mongoose.js +++ b/mongoose.js @@ -10,16 +10,12 @@ if (enabled('talk:db')) { mongoose.set('debug', true); } -try { - mongoose.connect(url, (err) => { - if (err) { - throw err; - } +mongoose.connect(url, (err) => { + if (err) { + throw err; + } - debug('Connected to MongoDB!'); - }); -} catch (err) { - console.error('Cannot stablish a connection with MongoDB', err); -} + debug('connection established'); +}); module.exports = mongoose; diff --git a/package.json b/package.json index 207724d61..0de0b8607 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,6 @@ "build-watch": "webpack --config webpack.config.dev.js --watch", "lint": "eslint bin/* .", "lint-fix": "eslint . --fix", - "pretest": "npm install", "test": "mocha --compilers js:babel-core/register --recursive tests", "test-watch": "mocha --compilers js:babel-core/register --recursive -w tests", "embed-start": "npm run build && ./bin/www" @@ -17,13 +16,8 @@ "config": { "pre-git": { "commit-msg": [], - "pre-commit": [ - "npm run lint", - "npm test" - ], - "pre-push": [ - "npm test" - ], + "pre-commit": ["npm run lint", "npm test"], + "pre-push": ["npm test"], "post-commit": [], "post-merge": [] } @@ -32,12 +26,7 @@ "type": "git", "url": "git+https://github.com/coralproject/talk.git" }, - "keywords": [ - "talk", - "coral", - "coralproject", - "ask" - ], + "keywords": ["talk", "coral", "coralproject", "ask"], "author": "", "license": "Apache-2.0", "bugs": { @@ -48,12 +37,19 @@ "bcrypt": "^0.8.7", "body-parser": "^1.15.2", "commander": "^2.9.0", + "connect-redis": "^3.1.0", "debug": "^2.2.0", "ejs": "^2.5.2", "express": "^4.14.0", + "express-session": "^1.14.2", + "helmet": "^3.1.0", "mongoose": "^4.6.5", "morgan": "^1.7.0", + "passport": "^0.3.2", + "passport-facebook": "^2.1.1", + "passport-local": "^1.0.0", "prompt": "^1.0.0", + "redis": "^2.6.3", "uuid": "^2.0.3" }, "devDependencies": { diff --git a/passport.js b/passport.js new file mode 100644 index 000000000..ae4776894 --- /dev/null +++ b/passport.js @@ -0,0 +1,83 @@ +const passport = require('passport'); +const User = require('./models/user'); +const LocalStrategy = require('passport-local').Strategy; +const FacebookStrategy = require('passport-facebook').Strategy; + +//============================================================================== +// SESSION SERIALIZATION +//============================================================================== + +passport.serializeUser((user, done) => { + done(null, user.id); +}); + +passport.deserializeUser((id, done) => { + User + .findById(id) + .then((user) => { + done(null, user); + }) + .catch((err) => { + done(err); + }); +}); + +/** + * Validates that a user is allowed to login. + * @param {User} user the user to be validated + * @param {Function} done the callback for the validation + */ +function ValidateUserLogin(user, done) { + if (!user) { + return done(new Error('user not found')); + } + + if (user.disabled) { + return done(null, false, {message: 'Account disabled'}); + } + + return done(null, user); +} + +//============================================================================== +// STRATEGIES +//============================================================================== + +passport.use(new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' +}, (email, password, done) => { + User + .findLocalUser(email, password) + .then((user) => { + if (!user) { + return done(null, false, {message: 'Incorrect email/password combination'}); + } + + return ValidateUserLogin(user, done); + }) + .catch((err) => { + done(err); + }); +})); + +if (process.env.TALK_FACEBOOK_APP_ID && process.env.TALK_FACEBOOK_APP_SECRET && process.env.TALK_ROOT_URL) { + 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` + }, (accessToken, refreshToken, profile, done) => { + User + .findOrCreateExternalUser(profile) + .then((user) => + ValidateUserLogin(user, done) + ) + .catch((err) => { + done(err); + }); + })); +} else { + console.error('Facebook cannot be enabled, missing one of TALK_FACEBOOK_APP_ID, TALK_FACEBOOK_APP_SECRET, TALK_ROOT_URL'); +} + +module.exports = passport; diff --git a/redis.js b/redis.js new file mode 100644 index 000000000..0f2f211f9 --- /dev/null +++ b/redis.js @@ -0,0 +1,43 @@ +const redis = require('redis'); +const debug = require('debug')('talk:redis'); +const url = process.env.TALK_REDIS_URL || 'redis://localhost'; + +const client = redis.createClient(url, { + retry_strategy: function(options) { + if (options.error.code === 'ECONNREFUSED') { + + // End reconnecting on a specific error and flush all commands with a individual error + return new Error('The server refused the connection'); + } + if (options.total_retry_time > 1000 * 60 * 60) { + + // End reconnecting after a specific timeout and flush all commands with a individual error + return new Error('Retry time exhausted'); + } + + if (options.times_connected > 10) { + + // End reconnecting with built in error + return undefined; + } + + // reconnect after + return Math.max(options.attempt * 100, 3000); + } +}); + +// client.on('error', (err) => { +// throw err; +// }); + +client.ping((err) => { + if (err) { + console.error('Can\'t ping the redis server!'); + + throw err; + } + + debug('connection established'); +}); + +module.exports = client; diff --git a/routes/api/auth/index.js b/routes/api/auth/index.js new file mode 100644 index 000000000..57d126449 --- /dev/null +++ b/routes/api/auth/index.js @@ -0,0 +1,43 @@ +const express = require('express'); +const passport = require('../../../passport'); +const authorization = require('../../../middleware/authorization'); + +const router = express.Router(); + +router.get('/', authorization.needed(), (req, res) => { + res.json(req.user); +}); + +router.delete('/', (req, res) => { + req.logout(); + res.status(204).end(); +}); + +/** + * 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); + } + + 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}); + }); + })(req, res, next); +}); + +module.exports = router; diff --git a/routes/api/index.js b/routes/api/index.js index d161cf622..46570f588 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); router.use('/asset', require('./asset')); +router.use('/auth', require('./auth')); router.use('/comments', require('./comments')); router.use('/settings', require('./settings')); router.use('/stream', require('./stream')); diff --git a/routes/index.js b/routes/index.js index cde5b07c7..6fbc54ca9 100644 --- a/routes/index.js +++ b/routes/index.js @@ -6,7 +6,7 @@ router.use('/admin', require('./admin')); router.use('/embed', require('./embed')); router.get('/', (req, res) => { - return res.render('home', {}); + res.render('home', {}); }); module.exports = router; diff --git a/tests/routes/api/auth/index.js b/tests/routes/api/auth/index.js new file mode 100644 index 000000000..dd49a1472 --- /dev/null +++ b/tests/routes/api/auth/index.js @@ -0,0 +1,39 @@ +require('../../../utils/mongoose'); + +const app = require('../../../../app'); +const chai = require('chai'); +const expect = chai.expect; + +chai.use(require('chai-http')); + +const User = require('../../../../models/user'); + +describe('POST /auth/local', () => { + + beforeEach(() => { + return User.createLocalUser('maria@gmail.com', 'password!', 'Maria'); + }); + + it('should send back the user on a successful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!'}) + .catch((res) => { + expect(res).to.have.status(200); + expect(res).to.be.json; + expect(res.body).to.have.property('user'); + expect(res.body.user).to.have.property('displayName', 'Maria'); + }); + }); + + it('should not send back the user on a unsuccessful login', () => { + return chai.request(app) + .post('/api/v1/auth/local') + .send({email: 'maria@gmail.com', password: 'password!3'}) + .catch((err) => { + expect(err).to.not.be.null; + expect(err.response).to.have.status(401); + expect(err.response.body).to.have.property('message', 'not authorized'); + }); + }); +}); diff --git a/views/admin.ejs~HEAD b/views/admin.ejs~HEAD deleted file mode 100644 index 038fb58d7..000000000 --- a/views/admin.ejs~HEAD +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - Talk - Coral Admin - - - - - -
- - -