From 1dc0c22cb7769adf37ad486afd33768c679695e5 Mon Sep 17 00:00:00 2001 From: Andrew Nicolaou Date: Mon, 26 Jun 2017 18:48:28 +0200 Subject: [PATCH] Email verification (#369) * Re-introduce Email Verification code Revert "Revert "Email verification"" This reverts commit d154d8bff259350523a0f139e844db96c43d2ee1. * Uses MJML to generate Reset Password email * Sends Password Reset and Email Confirmation emails using MJML template * Sends verified status along with user data * API endpoint for resending email verification confirmation * Displays verification status on Account page and allows resending * Send back error string * Passes email address through to sign/verify helper * Uses enum-style object to set verified state * Sends minimal info when user verifies since it can be done without login * Provides /verify UI and sends confirmation token to API * Better name for JWT secret token env var * Adds mail config variables to Readme * Encrypts email address in JWT The JWT sent as the token in the Confirm Password URL can be unencoded by anyone, although it's signature can only be verified by us. To ensure that no passwords are leaked, we encrypt the email address before creating the token. * Removes unused mail templates * Resets verified flag when email is changed and sends another email * Moves email confirmation functions next to each other * Extracts random token generator to helper * Moves email confirmation actions into Redux - updates the AccountForm label with a message to check inbox - show status when verifying email token * Uses generated token stored in DB for email confirmation * Sets email confirmation status to verified if logging in from Github * Sends email using new method on account creation * Fixes linting errors * Removes replyTo config --- README.md | 4 + client/constants.js | 5 + client/modules/User/actions.js | 35 ++++ .../modules/User/components/AccountForm.jsx | 35 +++- client/modules/User/pages/AccountView.jsx | 6 +- .../User/pages/EmailVerificationView.jsx | 110 +++++++++++ client/modules/User/reducers.js | 8 + client/routes.jsx | 2 + client/styles/components/_forms.scss | 9 + package.json | 5 + server/config/passport.js | 2 + server/controllers/session.controller.js | 2 + server/controllers/user.controller.js | 180 ++++++++++++++---- server/models/user.js | 11 ++ server/routes/server.routes.js | 4 + server/routes/user.routes.js | 4 + server/utils/mail.js | 48 +++++ server/utils/renderMjml.js | 12 ++ server/views/mail.js | 56 ++++++ server/views/mailLayout.js | 59 ++++++ static/images/p5js-square-logo.png | Bin 0 -> 5895 bytes 21 files changed, 554 insertions(+), 43 deletions(-) create mode 100644 client/modules/User/pages/EmailVerificationView.jsx create mode 100644 server/utils/mail.js create mode 100644 server/utils/renderMjml.js create mode 100644 server/views/mail.js create mode 100644 server/views/mailLayout.js create mode 100644 static/images/p5js-square-logo.png diff --git a/README.md b/README.md index 4a3b76fd..6c69e2c7 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ The automatic redirection to HTTPS is turned off by default in development. If y S3_BUCKET= GITHUB_ID= GITHUB_SECRET= + EMAIL_SENDER= + MAILGUN_KEY= + MAILGUN_DOMAIN= + EMAIL_VERIFY_SECRET_TOKEN=whatever_you_want_this_to_be_it_only_matters_for_production ``` For production, you will need to have real Github and Amazon credentions. Refer to [this gist](https://gist.github.com/catarak/70c9301f0fd1ac2d6b58de03f61997e3) for creating an S3 bucket for testing. diff --git a/client/constants.js b/client/constants.js index 8f1a5573..3d4d1e8b 100644 --- a/client/constants.js +++ b/client/constants.js @@ -102,6 +102,11 @@ export const RESET_PASSWORD_INITIATE = 'RESET_PASSWORD_INITIATE'; export const RESET_PASSWORD_RESET = 'RESET_PASSWORD_RESET'; export const INVALID_RESET_PASSWORD_TOKEN = 'INVALID_RESET_PASSWORD_TOKEN'; +export const EMAIL_VERIFICATION_INITIATE = 'EMAIL_VERIFICATION_INITIATE'; +export const EMAIL_VERIFICATION_VERIFY = 'EMAIL_VERIFICATION_VERIFY'; +export const EMAIL_VERIFICATION_VERIFIED = 'EMAIL_VERIFICATION_VERIFIED'; +export const EMAIL_VERIFICATION_INVALID = 'EMAIL_VERIFICATION_INVALID'; + // eventually, handle errors more specifically and better export const ERROR = 'ERROR'; diff --git a/client/modules/User/actions.js b/client/modules/User/actions.js index 2bcb7d1c..3150da4b 100644 --- a/client/modules/User/actions.js +++ b/client/modules/User/actions.js @@ -130,6 +130,41 @@ export function initiateResetPassword(formValues) { }; } +export function initiateVerification() { + return (dispatch) => { + dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_INITIATE + }); + axios.post(`${ROOT_URL}/verify/send`, {}, { withCredentials: true }) + .then(() => { + // do nothing + }) + .catch(response => dispatch({ + type: ActionTypes.ERROR, + message: response.data + })); + }; +} + +export function verifyEmailConfirmation(token) { + return (dispatch) => { + dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_VERIFY, + state: 'checking', + }); + return axios.get(`${ROOT_URL}/verify?t=${token}`, {}, { withCredentials: true }) + .then(response => dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_VERIFIED, + message: response.data, + })) + .catch(response => dispatch({ + type: ActionTypes.EMAIL_VERIFICATION_INVALID, + message: response.data + })); + }; +} + + export function resetPasswordReset() { return { type: ActionTypes.RESET_PASSWORD_RESET diff --git a/client/modules/User/components/AccountForm.jsx b/client/modules/User/components/AccountForm.jsx index f8b4296e..e504bc9b 100644 --- a/client/modules/User/components/AccountForm.jsx +++ b/client/modules/User/components/AccountForm.jsx @@ -4,11 +4,19 @@ import { domOnlyProps } from '../../../utils/reduxFormUtils'; function AccountForm(props) { const { fields: { username, email, currentPassword, newPassword }, + user, handleSubmit, + initiateVerification, submitting, invalid, pristine } = props; + + const handleInitiateVerification = (evt) => { + evt.preventDefault(); + initiateVerification(); + }; + return (

@@ -22,6 +30,26 @@ function AccountForm(props) { /> {email.touched && email.error && {email.error}}

+ { + user.verified !== 'verified' && + ( +

+ Unconfirmed. + { + user.emailVerificationInitiate === true ? + ( + Confirmation sent, check your email. + ) : + ( + + ) + } +

+ ) + }

get(this.props, 'location.query.t', null); + + closeLoginPage() { + browserHistory.push(this.props.previousPath); + } + + gotoHomePage() { + browserHistory.push('/'); + } + + render() { + let status = null; + const { + emailVerificationTokenState, + } = this.props; + + if (this.verificationToken() == null) { + status = ( +

That link is invalid

+ ); + } else if (emailVerificationTokenState === 'checking') { + status = ( +

Validating token, please wait...

+ ); + } else if (emailVerificationTokenState === 'verified') { + status = ( +

All done, your email address has been verified.

+ ); + } else if (emailVerificationTokenState === 'invalid') { + status = ( +

Something went wrong.

+ ); + } + + return ( +
+
+ + +
+
+

Verify your email

+ {status} +
+
+ ); + } +} + +function mapStateToProps(state) { + return { + emailVerificationTokenState: state.user.emailVerificationTokenState, + previousPath: state.ide.previousPath + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + verifyEmailConfirmation, + }, dispatch); +} + + +EmailVerificationView.propTypes = { + previousPath: PropTypes.string.isRequired, + emailVerificationTokenState: PropTypes.oneOf([ + 'checking', 'verified', 'invalid' + ]), + verifyEmailConfirmation: PropTypes.func.isRequired, +}; + +export default connect(mapStateToProps, mapDispatchToProps)(EmailVerificationView); diff --git a/client/modules/User/reducers.js b/client/modules/User/reducers.js index 5554a11f..6989bfc0 100644 --- a/client/modules/User/reducers.js +++ b/client/modules/User/reducers.js @@ -19,6 +19,14 @@ const user = (state = { authenticated: false }, action) => { return Object.assign({}, state, { resetPasswordInitiate: false }); case ActionTypes.INVALID_RESET_PASSWORD_TOKEN: return Object.assign({}, state, { resetPasswordInvalid: true }); + case ActionTypes.EMAIL_VERIFICATION_INITIATE: + return Object.assign({}, state, { emailVerificationInitiate: true }); + case ActionTypes.EMAIL_VERIFICATION_VERIFY: + return Object.assign({}, state, { emailVerificationTokenState: 'checking' }); + case ActionTypes.EMAIL_VERIFICATION_VERIFIED: + return Object.assign({}, state, { emailVerificationTokenState: 'verified' }); + case ActionTypes.EMAIL_VERIFICATION_INVALID: + return Object.assign({}, state, { emailVerificationTokenState: 'invalid' }); case ActionTypes.SETTINGS_UPDATED: return { ...state, ...action.user }; default: diff --git a/client/routes.jsx b/client/routes.jsx index 082bc68c..f5f7ce6b 100644 --- a/client/routes.jsx +++ b/client/routes.jsx @@ -7,6 +7,7 @@ import FullView from './modules/IDE/pages/FullView'; import LoginView from './modules/User/pages/LoginView'; import SignupView from './modules/User/pages/SignupView'; import ResetPasswordView from './modules/User/pages/ResetPasswordView'; +import EmailVerificationView from './modules/User/pages/EmailVerificationView'; import NewPasswordView from './modules/User/pages/NewPasswordView'; import AccountView from './modules/User/pages/AccountView'; // import SketchListView from './modules/Sketch/pages/SketchListView'; @@ -38,6 +39,7 @@ const routes = (store) => { + done(null, existingEmailUser)); } else { const user = new User(); @@ -92,6 +93,7 @@ passport.use(new GitHubStrategy({ user.username = profile.username; user.tokens.push({ kind: 'github', accessToken }); user.name = profile.displayName; + user.verified = User.EmailConfirmation.Verified; user.save(saveErr => done(null, user)); } }); diff --git a/server/controllers/session.controller.js b/server/controllers/session.controller.js index dd35a07f..211b8a46 100644 --- a/server/controllers/session.controller.js +++ b/server/controllers/session.controller.js @@ -13,6 +13,7 @@ export function createSession(req, res, next) { email: req.user.email, username: req.user.username, preferences: req.user.preferences, + verified: req.user.verified, id: req.user._id }); }); @@ -25,6 +26,7 @@ export function getSession(req, res) { email: req.user.email, username: req.user.username, preferences: req.user.preferences, + verified: req.user.verified, id: req.user._id }); } diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 8785d3e1..fb134f58 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,17 +1,34 @@ import crypto from 'crypto'; import async from 'async'; -import nodemailer from 'nodemailer'; -import mg from 'nodemailer-mailgun-transport'; + import User from '../models/user'; +import mail from '../utils/mail'; +import { + renderEmailConfirmation, + renderResetPassword, +} from '../views/mail'; + +const random = (done) => { + crypto.randomBytes(20, (err, buf) => { + const token = buf.toString('hex'); + done(err, token); + }); +}; + +const EMAIL_VERIFY_TOKEN_EXPIRY_TIME = Date.now() + (3600000 * 24); // 24 hours export function createUser(req, res, next) { - const user = new User({ - username: req.body.username, - email: req.body.email, - password: req.body.password - }); + random((tokenError, token) => { + const user = new User({ + username: req.body.username, + email: req.body.email, + password: req.body.password, + verified: User.EmailConfirmation.Sent, + verifiedToken: token, + verifiedTokenExpires: EMAIL_VERIFY_TOKEN_EXPIRY_TIME, + }); - User.findOne({ email: req.body.email }, + User.findOne({ email: req.body.email }, (err, existingUser) => { if (err) { res.status(404).send({ error: err }); @@ -32,15 +49,28 @@ export function createUser(req, res, next) { next(loginErr); return; } - res.json({ - email: req.user.email, - username: req.user.username, - preferences: req.user.preferences, - id: req.user._id + + const mailOptions = renderEmailConfirmation({ + body: { + domain: `http://${req.headers.host}`, + link: `http://${req.headers.host}/verify?t=${token}` + }, + to: req.user.email, + }); + + mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars + res.json({ + email: req.user.email, + username: req.user.username, + preferences: req.user.preferences, + verified: req.user.verified, + id: req.user._id + }); }); }); }); }); + }); } export function duplicateUserCheck(req, res) { @@ -90,12 +120,7 @@ export function updatePreferences(req, res) { export function resetPasswordInitiate(req, res) { async.waterfall([ - (done) => { - crypto.randomBytes(20, (err, buf) => { - const token = buf.toString('hex'); - done(err, token); - }); - }, + random, (token, done) => { User.findOne({ email: req.body.email }, (err, user) => { if (!user) { @@ -111,27 +136,15 @@ export function resetPasswordInitiate(req, res) { }); }, (token, user, done) => { - const auth = { - auth: { - api_key: process.env.MAILGUN_KEY, - domain: process.env.MAILGUN_DOMAIN - } - }; - - const transporter = nodemailer.createTransport(mg(auth)); - const message = { + const mailOptions = renderResetPassword({ + body: { + domain: `http://${req.headers.host}`, + link: `http://${req.headers.host}/reset-password/${token}`, + }, to: user.email, - from: 'p5.js Web Editor ', - subject: 'p5.js Web Editor Password Reset', - text: `You are receiving this email because you (or someone else) have requested the reset of the password for your account. - \n\nPlease click on the following link, or paste this into your browser to complete the process: - \n\nhttp://${req.headers.host}/reset-password/${token} - \n\nIf you did not request this, please ignore this email and your password will remain unchanged. - \n\nThanks for using the p5.js Web Editor!\n` - }; - transporter.sendMail(message, (error) => { - done(error); }); + + mail.send(mailOptions, done); } ], (err) => { if (err) { @@ -153,6 +166,75 @@ export function validateResetPasswordToken(req, res) { }); } +export function emailVerificationInitiate(req, res) { + async.waterfall([ + random, + (token, done) => { + User.findById(req.user.id, (err, user) => { + if (err) { + res.status(500).json({ error: err }); + return; + } + if (!user) { + res.status(404).json({ error: 'Document not found' }); + return; + } + + if (user.verified === User.EmailConfirmation.Verified) { + res.status(409).json({ error: 'Email already verified' }); + return; + } + + const mailOptions = renderEmailConfirmation({ + body: { + domain: `http://${req.headers.host}`, + link: `http://${req.headers.host}/verify?t=${token}` + }, + to: user.email, + }); + + mail.send(mailOptions, (mailErr, result) => { // eslint-disable-line no-unused-vars + if (mailErr != null) { + res.status(500).send({ error: 'Error sending mail' }); + } else { + user.verified = User.EmailConfirmation.Resent; + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; // 24 hours + user.save(); + + res.json({ + email: req.user.email, + username: req.user.username, + preferences: req.user.preferences, + verified: user.verified, + id: req.user._id + }); + } + }); + }); + }, + ]); +} + +export function verifyEmail(req, res) { + const token = req.query.t; + + User.findOne({ verifiedToken: token, verifiedTokenExpires: { $gt: Date.now() } }, (err, user) => { + if (!user) { + res.status(401).json({ success: false, message: 'Token is invalid or has expired.' }); + return; + } + + user.verified = User.EmailConfirmation.Verified; + user.verifiedToken = null; + user.verifiedTokenExpires = null; + user.save() + .then((result) => { // eslint-disable-line + res.json({ success: true }); + }); + }); +} + export function updatePassword(req, res) { User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, (err, user) => { if (!user) { @@ -205,7 +287,6 @@ export function updateSettings(req, res) { return; } - user.email = req.body.email; user.username = req.body.username; if (req.body.currentPassword) { @@ -218,6 +299,27 @@ export function updateSettings(req, res) { user.password = req.body.newPassword; saveUser(res, user); }); + } else if (user.email !== req.body.email) { + user.verified = User.EmailConfirmation.Sent; + + user.email = req.body.email; + + random((error, token) => { + user.verifiedToken = token; + user.verifiedTokenExpires = EMAIL_VERIFY_TOKEN_EXPIRY_TIME; + + saveUser(res, user); + + const mailOptions = renderEmailConfirmation({ + body: { + domain: `http://${req.headers.host}`, + link: `http://${req.headers.host}/verify?t=${token}` + }, + to: user.email, + }); + + mail.send(mailOptions); + }); } else { saveUser(res, user); } diff --git a/server/models/user.js b/server/models/user.js index 0f89cb15..5ddaf2f9 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -2,6 +2,12 @@ import mongoose from 'mongoose'; const bcrypt = require('bcrypt-nodejs'); +const EmailConfirmationStates = { + Verified: 'verified', + Sent: 'sent', + Resent: 'resent', +}; + const Schema = mongoose.Schema; const userSchema = new Schema({ @@ -10,6 +16,9 @@ const userSchema = new Schema({ password: { type: String }, resetPasswordToken: String, resetPasswordExpires: Date, + verified: { type: String }, + verifiedToken: String, + verifiedTokenExpires: Date, github: { type: String }, email: { type: String, unique: true }, tokens: Array, @@ -73,4 +82,6 @@ userSchema.statics.findByMailOrName = function findByMailOrName(email) { return this.findOne(query).exec(); }; +userSchema.statics.EmailConfirmation = EmailConfirmationStates; + export default mongoose.model('User', userSchema); diff --git a/server/routes/server.routes.js b/server/routes/server.routes.js index 1b69dbda..7bd944a2 100644 --- a/server/routes/server.routes.js +++ b/server/routes/server.routes.js @@ -40,6 +40,10 @@ router.route('/reset-password/:reset_password_token').get((req, res) => { res.send(renderIndex()); }); +router.route('/verify').get((req, res) => { + res.send(renderIndex()); +}); + router.route('/sketches').get((req, res) => { res.send(renderIndex()); }); diff --git a/server/routes/user.routes.js b/server/routes/user.routes.js index b3468cd8..682ac698 100644 --- a/server/routes/user.routes.js +++ b/server/routes/user.routes.js @@ -17,4 +17,8 @@ router.route('/reset-password/:token').post(UserController.updatePassword); router.route('/account').put(UserController.updateSettings); +router.route('/verify/send').post(UserController.emailVerificationInitiate); + +router.route('/verify').get(UserController.verifyEmail); + export default router; diff --git a/server/utils/mail.js b/server/utils/mail.js new file mode 100644 index 00000000..42c9b317 --- /dev/null +++ b/server/utils/mail.js @@ -0,0 +1,48 @@ +/** + * Mail service wrapping around mailgun + */ + +import nodemailer from 'nodemailer'; +import mg from 'nodemailer-mailgun-transport'; + +const auth = { + api_key: process.env.MAILGUN_KEY, + domain: process.env.MAILGUN_DOMAIN, +}; + +class Mail { + constructor() { + this.client = nodemailer.createTransport(mg({ auth })); + this.sendOptions = { + from: process.env.EMAIL_SENDER, + }; + } + + sendMail(mailOptions) { + return new Promise((resolve, reject) => { + this.client.sendMail(mailOptions, (err, info) => { + resolve(err, info); + }); + }); + } + + dispatchMail(data, callback) { + const mailOptions = { + to: data.to, + subject: data.subject, + from: this.sendOptions.from, + html: data.html, + }; + + return this.sendMail(mailOptions) + .then((err, res) => { + callback(err, res); + }); + } + + send(data, callback) { + return this.dispatchMail(data, callback); + } +} + +export default new Mail(); diff --git a/server/utils/renderMjml.js b/server/utils/renderMjml.js new file mode 100644 index 00000000..1a9cf8f4 --- /dev/null +++ b/server/utils/renderMjml.js @@ -0,0 +1,12 @@ +import { mjml2html } from 'mjml'; + +export default (template) => { + try { + const output = mjml2html(template); + return output.html; + } catch (e) { + // fall through to null + } + + return null; +}; diff --git a/server/views/mail.js b/server/views/mail.js new file mode 100644 index 00000000..949aeb4b --- /dev/null +++ b/server/views/mail.js @@ -0,0 +1,56 @@ +import renderMjml from '../utils/renderMjml'; +import mailLayout from './mailLayout'; + +export const renderResetPassword = (data) => { + const subject = 'p5.js Web Editor Password Reset'; + const templateOptions = { + domain: data.body.domain, + headingText: 'Reset your password', + greetingText: 'Hello,', + messageText: 'We received a request to reset the password for your account. To reset your password, click on the button below:', // eslint-disable-line max-len + link: data.body.link, + buttonText: 'Reset password', + directLinkText: 'Or copy and paste the URL into your browser:', + noteText: 'If you did not request this, please ignore this email and your password will remain unchanged. Thanks for using the p5.js Web Editor!', // eslint-disable-line max-len + }; + + // Return MJML string + const template = mailLayout(templateOptions); + + // Render MJML to HTML string + const html = renderMjml(template); + + // Return options to send mail + return Object.assign( + {}, + data, + { html, subject }, + ); +}; + +export const renderEmailConfirmation = (data) => { + const subject = 'p5.js Email Verification'; + const templateOptions = { + domain: data.body.domain, + headingText: 'Email Verification', + greetingText: 'Hello,', + messageText: 'To verify your email, click on the button below:', + link: data.body.link, + buttonText: 'Verify Email', + directLinkText: 'Or copy and paste the URL into your browser:', + noteText: 'This link is only valid for the next 24 hours. Thanks for using the p5.js Web Editor!', + }; + + // Return MJML string + const template = mailLayout(templateOptions); + + // Render MJML to HTML string + const html = renderMjml(template); + + // Return options to send mail + return Object.assign( + {}, + data, + { html, subject }, + ); +}; diff --git a/server/views/mailLayout.js b/server/views/mailLayout.js new file mode 100644 index 00000000..cd69cab7 --- /dev/null +++ b/server/views/mailLayout.js @@ -0,0 +1,59 @@ +export default ({ + domain, + headingText, + greetingText, + messageText, + link, + buttonText, + directLinkText, + noteText, +}) => ( +` + + + + + + + + + + + + + + ${headingText} + + + + + + + + ${greetingText} + + + ${messageText} + + + ${buttonText} + + + + + + + + ${directLinkText} + + ${link} + + ${noteText} + + + + + + +` +); diff --git a/static/images/p5js-square-logo.png b/static/images/p5js-square-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..fd9506b8647c90f9790c11125483c78479e1c9d9 GIT binary patch literal 5895 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 literal 0 HcmV?d00001