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..a1a44611f 100644 --- a/client/coral-framework/actions/auth.js +++ b/client/coral-framework/actions/auth.js @@ -1,3 +1,6 @@ +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from './../translations'; +const lang = new I18n(translations); import * as actions from '../constants/auth'; import {base, handleResp, getInit} from '../helpers/response'; @@ -27,7 +30,7 @@ export const fetchSignIn = (formData) => dispatch => { dispatch(hideSignInDialog()); dispatch(signInSuccess(user)); }) - .catch(() => dispatch(signInFailure('Email and/or password combination incorrect.'))); + .catch(() => dispatch(signInFailure(lang.t('error.emailPasswordError')))); }; // Sign In - Facebook @@ -75,7 +78,7 @@ export const fetchSignUp = formData => dispatch => { dispatch(changeView('SIGNIN')); }, 3000); }) - .catch((error) => dispatch(signUpFailure(error))); + .catch(() => dispatch(signUpFailure(lang.t('error.emailInUse')))); // We need to inprove error handling. TODO (bc) }; // Forgot Password Actions @@ -106,27 +109,8 @@ export const logout = () => dispatch => { .catch(error => dispatch(logOutFailure(error))); }; -// Availability Checks Actions +// LogOut 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)); -}; +export const validForm = () => ({type: actions.VALID_FORM}); +export const invalidForm = error => ({type: actions.INVALID_FORM, error}); diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js index 5c0135f58..a6a2c44f8 100644 --- a/client/coral-framework/constants/auth.js +++ b/client/coral-framework/constants/auth.js @@ -24,9 +24,6 @@ export const LOGOUT_REQUEST = 'LOGOUT_REQUEST'; export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; export const LOGOUT_FAILURE = 'LOGOUT_FAILURE'; -export const FETCH_AVAILABILITY_REQUEST = 'FETCH_AVAILABILITY_REQUEST'; -export const FETCH_AVAILABILITY_SUCCESS = 'FETCH_AVAILABILITY_SUCCESS'; -export const FETCH_AVAILABILITY_FAILURE = 'FETCH_AVAILABILITY_FAILURE'; +export const INVALID_FORM = 'INVALID_FORM'; +export const VALID_FORM = 'VALID_FORM'; -export const AVAILABLE_FIELD = 'AVAILABLE_FIELD'; -export const UNAVAILABLE_FIELD = 'UNAVAILABLE_FIELD'; diff --git a/client/coral-framework/helpers/error.js b/client/coral-framework/helpers/error.js index 735ad7a5d..34ba253ba 100644 --- a/client/coral-framework/helpers/error.js +++ b/client/coral-framework/helpers/error.js @@ -1,6 +1,10 @@ +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from './../translations'; +const lang = new I18n(translations); + export default { - email: 'Not a valid E-Mail', - password: 'Password must be at least 8 characters', - displayName: 'Display name is too short', - confirmPassword: 'Passwords don`t match. Please, check again' + email: lang.t('error.email'), + password: lang.t('error.password'), + displayName: lang.t('error.displayName'), + confirmPassword: lang.t('error.confirmPassword') }; diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js index bf7fef368..3e454892f 100644 --- a/client/coral-framework/reducers/auth.js +++ b/client/coral-framework/reducers/auth.js @@ -7,8 +7,7 @@ const initialState = Map({ user: null, showSignInDialog: false, view: 'SIGNIN', - signUpError: '', - emailAvailable: true, + error: '', successSignUp: false }); @@ -22,14 +21,12 @@ export default function auth (state = initialState, action) { isLoading: false, showSignInDialog: false, view: 'SIGNIN', - signInError: '', - signUpError: '', - emailAvailable: true, + error: '', successSignUp: false })); case actions.CHANGE_VIEW : return state - .set('signInError', '') + .set('error', '') .set('view', action.view); case actions.CLEAN_STATE: return initialState; @@ -43,7 +40,7 @@ export default function auth (state = initialState, action) { case actions.FETCH_SIGNIN_FAILURE: return state .set('isLoading', false) - .set('signInError', action.error); + .set('error', action.error); case actions.FETCH_SIGNIN_FACEBOOK_SUCCESS: return state .set('user', action.user) @@ -57,6 +54,7 @@ export default function auth (state = initialState, action) { .set('isLoading', true); case actions.FETCH_SIGNUP_FAILURE: return state + .set('error', action.error) .set('isLoading', false); case actions.FETCH_SIGNUP_SUCCESS: return state @@ -66,12 +64,12 @@ export default function auth (state = initialState, action) { return state .set('loggedIn', false) .set('user', null); - case actions.AVAILABLE_FIELD: + case actions.INVALID_FORM: return state - .set(`${action.field}Available`, true); - case actions.UNAVAILABLE_FIELD: + .set('error', action.error); + case actions.VALID_FORM: return state - .set(`${action.field}Available`, false); + .set('error', ''); default : return state; } diff --git a/client/coral-framework/translations.json b/client/coral-framework/translations.json new file mode 100644 index 000000000..4abd1ed03 --- /dev/null +++ b/client/coral-framework/translations.json @@ -0,0 +1,22 @@ +{ + "en": { + "error": { + "email": "Not a valid E-Mail", + "password": "Password must be at least 8 characters", + "displayName": "Display name is too short", + "confirmPassword": "Passwords don`t match. Please, check again", + "emailPasswordError": "Email and/or password combination incorrect.", + "emailInUse": "Email address already in use" + } + }, + "es": { + "error": { + "email": "No es un email válido", + "password": "La contraseña debe tener por lo menos 8 caracteres", + "displayName": "El nombre es muy corto", + "confirmPassword": "Las contraseñas no coinciden", + "emailPasswordError": "Email y/o contraseña incorrecta.", + "emailInUse": "Email address already in use" + } + } +} \ No newline at end of file diff --git a/client/coral-sign-in/components/SignInContent.js b/client/coral-sign-in/components/SignInContent.js index 8aa688133..76a93f56c 100644 --- a/client/coral-sign-in/components/SignInContent.js +++ b/client/coral-sign-in/components/SignInContent.js @@ -25,7 +25,7 @@ const SignInContent = ({handleChange, formData, ...props}) => ( {lang.t('signIn.or')} - { props.auth.signInError && {props.auth.signInError} } + { props.auth.error && {props.auth.error} }
( {lang.t('signIn.or')} - { props.auth.signUpError && {props.auth.signUpError} } + { props.auth.error && {props.auth.error} } { + map[prop] = lang.t('signIn.requiredField'); + return map; + }, {}); + this.setState({errors}); } handleChange(e) { @@ -71,29 +78,23 @@ class SignInContainer extends Component { validation(name, value) { const {addError} = this; const {formData} = this.state; - const {checkAvailability} = this.props; if (!value.length) { - addError(name, 'Please, fill this field'); + addError(name, lang.t('signIn.requiredField')); } else if (name === 'confirmPassword' && formData.confirmPassword !== formData.password) { - addError('confirmPassword', 'Passwords don`t match. Please, check again'); + addError('confirmPassword', lang.t('signIn.passwordsDontMatch')); } else if (!validate[name](value)) { addError(name, errorMsj[name]); } else { const { [name]: prop, ...errors } = this.state.errors; // eslint-disable-line // Removes Error this.setState(state => ({...state, errors})); - // Checks Email Availability - if (name === 'email') { - debounce(() => checkAvailability({[name]: value}), 200, {'maxWait': 1000})(); - } } } isCompleted() { const {formData} = this.state; - const {emailAvailable} = this.props.auth; - return !Object.keys(formData).filter(prop => !formData[prop].length).length && emailAvailable; + return !Object.keys(formData).filter(prop => !formData[prop].length).length; } displayErrors(show = true) { @@ -103,9 +104,13 @@ class SignInContainer extends Component { handleSignUp(e) { e.preventDefault(); const {errors} = this.state; + const {fetchSignUp, validForm, invalidForm} = this.props; this.displayErrors(); if (this.isCompleted() && !Object.keys(errors).length) { - this.props.fetchSignUp(this.state.formData); + fetchSignUp(this.state.formData); + validForm(); + } else { + invalidForm(lang.t('signIn.checkTheForm')); } } @@ -118,10 +123,6 @@ class SignInContainer extends Component { this.props.hideSignInDialog(); } - changeView(view) { - this.props.changeView(view); - } - render() { const {auth, showSignInDialog} = this.props; return ( @@ -147,14 +148,15 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ facebookCallback: (err, data) => dispatch(facebookCallback(err, data)), - fetchSignUp: (formData) => dispatch(fetchSignUp(formData)), - fetchSignIn: (formData) => dispatch(fetchSignIn(formData)), + fetchSignUp: formData => dispatch(fetchSignUp(formData)), + fetchSignIn: formData => dispatch(fetchSignIn(formData)), fetchSignInFacebook: () => dispatch(fetchSignInFacebook()), - fetchForgotPassword: (formData) => dispatch(fetchForgotPassword(formData)), + fetchForgotPassword: formData => dispatch(fetchForgotPassword(formData)), showSignInDialog: () => dispatch(showSignInDialog()), - changeView: (view) => dispatch(changeView(view)), + changeView: view => dispatch(changeView(view)), handleClose: () => dispatch(hideSignInDialog()), - checkAvailability: (formData) => dispatch(fetchCheckAvailability(formData)) + invalidForm: error => dispatch(invalidForm(error)), + validForm: () => dispatch(validForm()) }); export default connect( diff --git a/client/coral-sign-in/translations.js b/client/coral-sign-in/translations.js index fdf7de0a0..4e1d35805 100644 --- a/client/coral-sign-in/translations.js +++ b/client/coral-sign-in/translations.js @@ -16,7 +16,10 @@ export default { displayName: 'Display Name', alreadyHaveAnAccount: 'Already have an account?', recoverPassword: 'Recover password', - emailInUse: 'Email address already in use' + emailInUse: 'Email address already in use', + requiredField: 'This field is required', + passwordsDontMatch: 'Passwords don\'t match.', + checkTheForm: 'Invalid Form. Please, check the fields' } }, es: { @@ -36,7 +39,10 @@ export default { displayName: 'Nombre', alreadyHaveAnAccount: 'Ya tienes una cuenta?', recoverPassword: 'Recuperar contraseña', - emailInUse: 'Este email se encuentra en uso' + emailInUse: 'Este email se encuentra en uso', + requiredField: 'Este campo es requerido', + passwordsDontMatch: 'Las contraseñas no coinciden', + checkTheForm: 'Formulario Inválido. Por favor, completa los campos' } } }; 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 e38912c8a..0b43ff695 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. @@ -18,6 +19,14 @@ const USER_STATUS = [ 'banned' ]; +// In the event that the TALK_SESSION_SECRET is missing but we are testing, then +// set the process.env.TALK_SESSION_SECRET. +if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) { + process.env.TALK_SESSION_SECRET = 'keyboard cat'; +} else if (!process.env.TALK_SESSION_SECRET) { + throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality'); +} + // UserSchema is the mongoose schema defined as the representation of a User in // MongoDB. const UserSchema = new mongoose.Schema({ @@ -140,10 +149,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' } } @@ -247,6 +260,7 @@ UserService.changePassword = (id, password) => { }) .then((hashedPassword) => { return UserModel.update({id}, { + $inc: {__v: 1}, $set: { password: hashedPassword } @@ -278,6 +292,8 @@ UserService.createLocalUser = (email, password, displayName) => { return Promise.reject('email is required'); } + email = email.toLowerCase(); + if (!password) { return Promise.reject('password is required'); } @@ -306,9 +322,11 @@ UserService.createLocalUser = (email, password, displayName) => { user.save((err) => { if (err) { + if (err.code === 11000) { + return reject('Email address already in use'); + } return reject(err); } - return resolve(user); }); }); @@ -430,6 +448,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. @@ -464,22 +533,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..dd9f8db8a 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": { @@ -60,6 +50,11 @@ "passport": "^0.3.2", "passport-facebook": "^2.1.1", "passport-local": "^1.0.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", "redis": "^2.6.3", "uuid": "^2.0.3" diff --git a/redis.js b/redis.js index 0f2f211f9..c37fcc64e 100644 --- a/redis.js +++ b/redis.js @@ -4,7 +4,7 @@ const url = process.env.TALK_REDIS_URL || 'redis://localhost'; const client = redis.createClient(url, { retry_strategy: function(options) { - if (options.error.code === 'ECONNREFUSED') { + if (options.error && 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'); @@ -26,10 +26,6 @@ const client = redis.createClient(url, { } }); -// client.on('error', (err) => { -// throw err; -// }); - client.ping((err) => { if (err) { console.error('Can\'t ping the redis server!'); 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 4e51203ac..96da13406 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 { @@ -75,22 +81,67 @@ router.post('/', (req, res, next) => { }); }); -router.post('/availability', (req, res, 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 User.availabilityCheck(email) - .then(count => { - if (count) { - return res.json({status: 'unavailable'}); - } - return res.json({status: 'available'}); - }) - .catch(err => { - next(err); - }); + if (!email) { + return next(); } - return res.status(404).send('Wrong parameters'); + + 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..4da8024ca --- /dev/null +++ b/services/mailer.js @@ -0,0 +1,34 @@ +const nodemailer = require('nodemailer'); + +if (!process.env.TALK_SMTP_CONNECTION_URL) { + console.error('TALK_SMTP_CONNECTION_URL should be defined if you would like to send password reset emails from Talk'); +} + +const defaultTransporter = 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 = '', transporter = defaultTransporter}) { + 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 %>

+<% } %>