diff --git a/.editorconfig b/.editorconfig index 9c378bd19..986314661 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,7 +1,7 @@ root = true [*] -indent_style = tab +indent_style = space end_of_line = lf charset = utf-8 trim_trailing_whitespace = true diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js index e3bf0babd..54970ba60 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -106,27 +106,3 @@ export const logout = () => dispatch => { .catch(error => dispatch(logOutFailure(error))); }; -// Availability Checks Actions - -const availabilityRequest = () => ({type: actions.FETCH_AVAILABILITY_REQUEST}); -const availabilitySuccess = () => ({type: actions.FETCH_AVAILABILITY_SUCCESS}); -const availabilityFailure = () => ({type: actions.FETCH_AVAILABILITY_FAILURE}); - -const availableField = field => ({type: actions.AVAILABLE_FIELD, field}); -const unavailableField = field => ({type: actions.UNAVAILABLE_FIELD, field}); - -export const fetchCheckAvailability = formData => dispatch => { - dispatch(availabilityRequest()); - fetch(`${base}/user/availability`, getInit('POST', formData)) - .then(handleResp) - .then(({status}) => { - const [field] = Object.keys(formData); - dispatch(availabilitySuccess()); - if (status === 'available') { - return dispatch(availableField(field)); - } - return dispatch(unavailableField(field)); - }) - .catch((error) => availabilityFailure(error)); -}; - diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index bf7fef368..6dc59a019 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -66,12 +66,6 @@ export default function auth (state = initialState, action) { return state .set('loggedIn', false) .set('user', null); - case actions.AVAILABLE_FIELD: - return state - .set(`${action.field}Available`, true); - case actions.UNAVAILABLE_FIELD: - return state - .set(`${action.field}Available`, false); default : return state; } diff --git a/models/setting.js b/models/setting.js index a35a1aba4..fb35e1eef 100644 --- a/models/setting.js +++ b/models/setting.js @@ -1,6 +1,13 @@ const mongoose = require('../mongoose'); const Schema = mongoose.Schema; +/** + * this Schema manages application settings that get used on front- and backend + * NOTE: when you set a setting here, it will not automatically be exposed to + * the front end. You must add it to the whitelist in the settings route + * in /routes/api/settings/index.js + * @type {Schema} + */ const SettingSchema = new Schema({ id: {type: String, default: '1'}, moderation: {type: String, enum: ['pre', 'post'], default: 'pre'}, diff --git a/models/user.js b/models/user.js index a28ea3863..a09415e96 100644 --- a/models/user.js +++ b/models/user.js @@ -1,6 +1,7 @@ const mongoose = require('../mongoose'); const uuid = require('uuid'); const bcrypt = require('bcrypt'); +const jwt = require('jsonwebtoken'); // SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run // through during the salting process. @@ -12,6 +13,13 @@ const USER_ROLES = [ 'moderator' ]; +if (!process.env.TALK_SESSION_SECRET) { + throw new Error('\n////////////////////////////////////////////////////////////\n' + + '/// TALK_SESSION_SECRET must be defined to encode ///\n' + + '/// JSON Web Tokens and other auth functionality ///\n' + + '////////////////////////////////////////////////////////////'); +} + // UserSchema is the mongoose schema defined as the representation of a User in // MongoDB. const UserSchema = new mongoose.Schema({ @@ -130,10 +138,14 @@ const UserService = module.exports = {}; * @param {Function} done [description] */ UserService.findLocalUser = (email, password) => { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required for findLocalUser'); + } + return UserModel.findOne({ profiles: { $elemMatch: { - id: email, + id: email.toLowerCase(), provider: 'local' } } @@ -237,6 +249,7 @@ UserService.changePassword = (id, password) => { }) .then((hashedPassword) => { return UserModel.update({id}, { + $inc: {__v: 1}, $set: { password: hashedPassword } @@ -268,6 +281,8 @@ UserService.createLocalUser = (email, password, displayName) => { return Promise.reject('email is required'); } + email = email.toLowerCase(); + if (!password) { return Promise.reject('password is required'); } @@ -393,6 +408,57 @@ UserService.findByIdArray = (ids) => { }); }; +/** + * Creates a JWT from a user email. Only works for local accounts. + * @param {String} email of the local user + */ +UserService.createPasswordResetToken = function (email) { + if (!email || typeof email !== 'string') { + return Promise.reject('email is required when creating a JWT for resetting passord'); + } + + email = email.toLowerCase(); + + return UserModel.findOne({profiles: {$elemMatch: {id: email}}}) + .then(user => { + + if (user === null) { + // since we don't want to reveal that the email does/doesn't exist + // just go ahead and resolve the Promise with null and check in the endpoint + return Promise.resolve(null); + } + + const payload = {email, jti: uuid.v4(), userId: user.id, version: user.__v}; + const token = jwt.sign(payload, process.env.TALK_SESSION_SECRET, {expiresIn: '1d'}); + + return token; + }); +}; + +/** + * verifies a jwt and returns the associated user + * @param {String} token the JSON Web Token to verify + */ +UserService.verifyPasswordResetToken = token => { + return new Promise((resolve, reject) => { + jwt.verify(token, process.env.TALK_SESSION_SECRET, (error, decoded) => { + if (error) { + return reject(error); + } + + resolve(decoded); + }); + }) + .then(decoded => { + /** + * TODO: check the jti from this decoded token in redis + * and make an entry if it does not exist. + * reject if entry already exists. + */ + return UserService.findById(decoded.userId); + }); +}; + /** * Finds a user using a value which gets compared using a prefix match against * the user's email address and/or their display name. @@ -427,22 +493,6 @@ UserService.search = (value) => { }); }; -/** - * Finds users by email and returns the count. The result should be 1 or 0 (bool) indicating email availability - * @param {String} email to search by - * @return {Promise} - */ -UserService.availabilityCheck = (email) => { - return UserModel.count({ - profiles: { - $elemMatch: { - id: email, - provider: 'local' - } - } - }); -}; - /** * Returns a count of the current users. * @return {Promise} diff --git a/package.json b/package.json index d7f04a921..822480c5a 100644 --- a/package.json +++ b/package.json @@ -16,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": [] } @@ -31,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": { @@ -55,11 +45,14 @@ "express-session": "^1.14.2", "helmet": "^3.1.0", "lodash.debounce": "^4.0.8", + "jsonwebtoken": "^7.1.9", + "lodash": "^4.16.6", "mongoose": "^4.6.5", "morgan": "^1.7.0", "passport": "^0.3.2", "passport-facebook": "^2.1.1", "passport-local": "^1.0.0", + "nodemailer": "^2.6.4", "prompt": "^1.0.0", "redis": "^2.6.3", "uuid": "^2.0.3" diff --git a/routes/admin/index.js b/routes/admin/index.js index 52957f54a..2b967708a 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,11 +1,15 @@ const express = require('express'); - const router = express.Router(); router.get('/embed/stream/preview', (req, res) => { res.render('embed-stream', {basePath: '/client/embed/stream'}); }); +router.get('/password-reset/:token', (req, res, next) => { + // render a page or something? + res.send('ok'); +}); + router.get('*', (req, res) => { res.render('admin', {basePath: '/client/coral-admin'}); }); diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js index 585bf083a..2665cacc8 100644 --- a/routes/api/settings/index.js +++ b/routes/api/settings/index.js @@ -1,3 +1,4 @@ +const _ = require('lodash'); const express = require('express'); const router = express.Router(); const Setting = require('../../../models/setting'); @@ -5,7 +6,10 @@ const Setting = require('../../../models/setting'); router.get('/', (req, res, next) => { Setting .getSettings() - .then(settings => res.json(settings)) + .then(settings => { + const whitelist = ['moderation']; + res.json(_.pick(settings, whitelist)); + }) .catch(next); }); diff --git a/routes/api/user/index.js b/routes/api/user/index.js index a702adb41..bbe66c647 100644 --- a/routes/api/user/index.js +++ b/routes/api/user/index.js @@ -1,6 +1,12 @@ const express = require('express'); const router = express.Router(); const User = require('../../../models/user'); +const mailer = require('../../../services/mailer'); +const ejs = require('ejs'); +const fs = require('fs'); +const path = require('path'); +const resetEmailFile = fs.readFileSync(path.resolve(__dirname, '../../../views/password-reset-email.ejs')); +const resetEmailTemplate = ejs.compile(resetEmailFile.toString()); router.get('/', (req, res, next) => { const { @@ -52,6 +58,69 @@ router.post('/:user_id/role', (req, res, next) => { .catch(next); }); +/** + * expects 2 fields in the body of the request + * 1) the token that was in the url of the email link {String} + * 2) the new password {String} + */ +router.post('/update-password', (req, res, next) => { + const {token, password} = req.body; + + User.verifyPasswordResetToken(token) + .then(user => { + return User.changePassword(user.id, password); + }) + .then(() => { + res.status(204).end(); + }) + .catch(error => { + console.error(error); + res.status(401).send('Not Authorized'); + }); +}); + +/** + * this endpoint takes an email (username) and checks if it belongs to a User account + * if it does, create a JWT and send an email + */ +router.post('/request-password-reset', (req, res, next) => { + const {email} = req.body; + + if (!email) { + return next(); + } + + User + .createPasswordResetToken(email) + .then(token => { + if (token === null) { + return Promise.resolve('the email was not found in the db.'); + } + + const options = { + subject: 'Password Reset Requested - Talk', + from: 'noreply@coralproject.net', + to: email, + html: resetEmailTemplate({ + token, + // probably more clear to explicitly pass this + rootURL: process.env.TALK_ROOT_URL + }) + }; + return mailer.sendSimple(options); + }) + .then(() => { + // we want to send a 204 regardless of the user being found in the db + // if we fail on missing emails, it would reveal if people are registered or not. + res.status(204).send('OK'); + }) + .catch(error => { + const errorMsg = typeof error === 'string' ? error : error.message; + + res.status(500).json({error: errorMsg}); + }); +}); + router.post('/', (req, res, next) => { const {email, password, displayName} = req.body; @@ -65,22 +134,4 @@ router.post('/', (req, res, next) => { }); }); -router.post('/availability', (req, res, next) => { - const {email} = req.body; - - if (email) { - return User.availabilityCheck(email) - .then(count => { - if (count) { - return res.json({status: 'unavailable'}); - } - return res.json({status: 'available'}); - }) - .catch(err => { - next(err); - }); - } - return res.status(404).send('Wrong parameters'); -}); - module.exports = router; diff --git a/services/mailer.js b/services/mailer.js new file mode 100644 index 000000000..cdb23f370 --- /dev/null +++ b/services/mailer.js @@ -0,0 +1,36 @@ +const nodemailer = require('nodemailer'); + +if (!process.env.TALK_SMTP_CONNECTION_URL) { + throw new Error('\n///////////////////////////////////////////////////////////////\n' + + '/// TALK_SMTP_CONNECTION_URL should be defined if you would ///\n' + + '/// like to send password reset emails from Talk ///\n' + + '///////////////////////////////////////////////////////////////'); +} + +const transporter = nodemailer.createTransport(process.env.TALK_SMTP_CONNECTION_URL); + +const mailer = { + /** + * sendSimple + * + * @param {Object} {from, to, subject, text = '', html = ''} + * @returns + */ + sendSimple ({from, to, subject, text = '', html = ''}) { + return new Promise((resolve, reject) => { + if (!from) { + reject('sendSimple requires a from address'); + } + if (!to) { + reject('sendSimple requires a comma-separated list of "to" addresses'); + } + if (!subject) { + reject('sendSimple requires a subject for the email'); + } + + return resolve(transporter.sendMail({from, to, subject, text, html})); + }); + } +}; + +module.exports = mailer; diff --git a/swagger.yaml b/swagger.yaml index ba945e2e1..16ae9a960 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -162,6 +162,36 @@ paths: responses: 204: description: OK + + /user/request-password-reset: + post: + tags: + - Users + description: trigger a reset password email. sends a success code whether email was found or no. + responses: + 204: + description: OK + + /user/update-password: + post: + tags: + - Users + description: Update existing user password + parameters: + - name: token + type: string + in: body + description: JSON Web token taken taken from emailed link + required: true + - name: password + type: string + in: body + description: new password to be settings + required: true + responses: + 204: + description: OK + /asset: get: tags: @@ -276,7 +306,7 @@ definitions: description: A summary of the asset, inferred on initial load. section: type: string - description: The section the asset is in. + description: The section the asset is in. subsection: type: string description: The subsection that the asset is in. diff --git a/views/password-reset-email.ejs b/views/password-reset-email.ejs new file mode 100644 index 000000000..0637b6bb2 --- /dev/null +++ b/views/password-reset-email.ejs @@ -0,0 +1,6 @@ + +

We received a request to reset your password. If you did not request this change, you can ignore this email.
+If you did, please click here to reset password.

+<% if (process.env.NODE_ENV !== 'production') { %> +

<%= token %>

+<% } %>