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/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 d841480d8..45db2adf8 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. diff --git a/package.json b/package.json index 354340ae1..b279050f6 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,11 @@ "debug": "^2.2.0", "ejs": "^2.5.2", "express": "^4.14.0", + "jsonwebtoken": "^7.1.9", + "lodash": "^4.16.6", "mongoose": "^4.6.5", "morgan": "^1.7.0", + "nodemailer": "^2.6.4", "prompt": "^1.0.0", "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 71407300a..3faab9db3 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,4 +58,67 @@ 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}); + }); +}); + 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.
<%= token %>
+<% } %>