Merge pull request #71 from coralproject/password-reset

reset password endpoints
This commit is contained in:
Riley Davis
2016-11-17 13:32:08 -07:00
committed by GitHub
10 changed files with 230 additions and 5 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
root = true
[*]
indent_style = tab
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
+7
View File
@@ -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'},
+67 -1
View File
@@ -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.
+3
View File
@@ -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"
},
+5 -1
View File
@@ -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'});
});
+5 -1
View File
@@ -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);
});
+69
View File
@@ -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;
+36
View File
@@ -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;
+31 -1
View File
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
<!-- extremely naive implementation of a password reset email -->
<p>We received a request to reset your password. If you did not request this change, you can ignore this email.<br />
If you did, <a href="<%= rootURL %>/admin/password-reset/<%= token %>">please click here to reset password</a>.</p>
<% if (process.env.NODE_ENV !== 'production') { %>
<p style="color: red"><%= token %></p>
<% } %>