diff --git a/package.json b/package.json index b2700d7d..de258eb3 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "NODE_ENV=production webpack --config webpack.config.prod.js --progress", "test": "echo \"Error: no test specified\" && exit 1", "fetch-examples": "node fetch-examples.js", - "postinstall" : "git submodule update --remote --recursive" + "postinstall": "git submodule update --remote --recursive" }, "main": "index.js", "author": "Cassie Tarakajian", @@ -83,10 +83,13 @@ "express": "^4.13.4", "express-session": "^1.13.0", "file-type": "^3.8.0", + "fs-promise": "^1.0.0", "htmlhint": "^0.9.13", + "is_js": "^0.9.0", "js-beautify": "^1.6.4", "jsdom": "^9.8.3", "jshint": "^2.9.2", + "jsonwebtoken": "^7.2.1", "lodash": "^4.16.4", "loop-protect": "git+https://git@github.com/catarak/loop-protect.git", "moment": "^2.14.1", @@ -97,8 +100,9 @@ "passport": "^0.3.2", "passport-github": "^1.1.0", "passport-local": "^1.0.0", - "q": "^1.4.1", "project-name-generator": "^2.1.3", + "pug": "^2.0.0-beta6", + "q": "^1.4.1", "react": "^15.1.0", "react-dom": "^15.1.0", "react-inlinesvg": "^0.4.2", diff --git a/server/config/passport.js b/server/config/passport.js index 7ec0e1ba..004322ea 100644 --- a/server/config/passport.js +++ b/server/config/passport.js @@ -53,6 +53,7 @@ passport.use(new GitHubStrategy({ existingEmailUser.username = existingEmailUser.username || profile.username; existingEmailUser.tokens.push({ kind: 'github', accessToken }); existingEmailUser.name = existingEmailUser.name || profile.displayName; + existingEmailUser.verified = 0; existingEmailUser.save((err) => { return done(null, existingEmailUser); }); @@ -63,6 +64,7 @@ passport.use(new GitHubStrategy({ user.username = profile.username; user.tokens.push({ kind: 'github', accessToken }); user.name = profile.displayName; + user.verified = 0; user.save((err) => { return done(null, user); }); diff --git a/server/controllers/user.controller.js b/server/controllers/user.controller.js index 648fd870..ff92628f 100644 --- a/server/controllers/user.controller.js +++ b/server/controllers/user.controller.js @@ -1,8 +1,8 @@ import User from '../models/user'; import crypto from 'crypto'; import async from 'async'; -import nodemailer from 'nodemailer'; -import mg from 'nodemailer-mailgun-transport'; +import mail from '../utils/mail'; +import auth from '../utils/auth'; export function createUser(req, res, next) { const user = new User({ @@ -24,11 +24,19 @@ export function createUser(req, res, next) { if (loginErr) { return next(loginErr); } - res.json({ - email: req.user.email, - username: req.user.username, - preferences: req.user.preferences, - id: req.user._id + mail.send('email-verification', { + body: { + link: `http://${req.headers.host}/verify?t=${auth.createVerificationToken(req.body.email)}` + }, + to: req.body.email, + subject: 'Email Verification', + }, (result) => { // eslint-disable-line no-unused-vars + res.json({ + email: req.user.email, + username: req.user.username, + preferences: req.user.preferences, + id: req.user._id + }); }); }); }); @@ -99,27 +107,13 @@ 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 = { + mail.send('reset-password', { + body: { + 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); - }); + }, done); } ], (err) => { if (err) { @@ -140,6 +134,28 @@ export function validateResetPasswordToken(req, res) { }); } +export function verifyEmail(req, res) { + const token = req.query.t; + // verify the token + auth.verifyEmailToken(token) + .then((data) => { + const email = data.email; + // change the verified field for the user or throw if the user is not found + User.findOne({ email }) + .then((user) => { + // change the field for the user, and send the new cookie + user.verified = 0; // eslint-disable-line + user.save() + .then((result) => { // eslint-disable-line + res.json({ user }); + }); + }); + }) + .catch((err) => { + res.json(err); + }); +} + export function updatePassword(req, res) { User.findOne({ resetPasswordToken: req.params.token, resetPasswordExpires: { $gt: Date.now() } }, function (err, user) { if (!user) { diff --git a/server/models/user.js b/server/models/user.js index 34243650..b8ee61da 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -8,6 +8,7 @@ const userSchema = new Schema({ password: { type: String }, resetPasswordToken: String, resetPasswordExpires: Date, + verified: { type: Number, default: -1 }, github: { type: String }, email: { type: String, unique: true }, tokens: Array, diff --git a/server/routes/user.routes.js b/server/routes/user.routes.js index da8fd02b..64a81fbc 100644 --- a/server/routes/user.routes.js +++ b/server/routes/user.routes.js @@ -14,4 +14,6 @@ router.route('/reset-password/:token').get(UserController.validateResetPasswordT router.route('/reset-password/:token').post(UserController.updatePassword); +router.route('/verify').get(UserController.verifyEmail); + export default router; diff --git a/server/utils/auth.js b/server/utils/auth.js new file mode 100644 index 00000000..b36450b6 --- /dev/null +++ b/server/utils/auth.js @@ -0,0 +1,37 @@ +const jwt = require('jsonwebtoken'); + + +class Auth { + /** + * Create a verification token using jwt + */ + createVerificationToken(email, fromEmail) { + return jwt.sign({ + email, + fromEmail, + }, process.env.SECRET_TOKEN, { + expiresIn: '1 day', + subject: 'email-verification', + }); + } + + /** + * Verify token + */ + verifyEmailToken(token) { + return new Promise((resolve, reject) => { + jwt.verify(token, process.env.SECRET_TOKEN, (err, data) => { + if (err) { + if (err.name === 'TokenExpiredError') { + reject('The verification link has expired'); + } else if (err.name === 'JsonWebTokenError') { + reject('Verification link is malformend'); + } + } + resolve(data); + }); + }); + } +} + +export default new Auth(); diff --git a/server/utils/mail.js b/server/utils/mail.js new file mode 100644 index 00000000..76db0dd8 --- /dev/null +++ b/server/utils/mail.js @@ -0,0 +1,80 @@ +'use strict'; +/** + * Mail service wrapping around mailgun + */ + +import fsp from 'fs-promise'; +import pug from 'pug'; +import is from 'is_js'; +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, + replyTo: process.env.EMAIL_REPLY_TO, + }; + } + + getMailTemplate(type) { + let mailTemp; + switch (type) { + case 'reset-password': + mailTemp = 'server/views/mailTemplates/reset-password.pug'; + break; + case 'email-verification': + mailTemp = 'server/views/mailTemplates/email-verification.pug'; + break; + } + return mailTemp; + } + + sendMail(mailOptions) { + return new Promise((resolve, reject) => { + this.client.sendMail(mailOptions, (err, info) => { + resolve(err, info); + }); + }); + } + + dispatchMail(template, data, callback) { + const self = this; + return fsp.readFile(template, 'utf8') + .then((file) => { + const compiled = pug.compile(file, { + filename: template, + }); + const body = compiled(data.body); + const mailOptions = { + to: data.to, + subject: data.subject, + from: self.sendOptions.from, + 'h:Reply-To': self.sendOptions.replyTo, + html: body, + }; + return self.sendMail(mailOptions); + }) + .then((err, res) => { + callback(err, res); + }); + } + + send(type, data, callback) { + let template = null; + if (is.existy(data.template)) { + template = data.template; + } else { + template = this.getMailTemplate(type); + } + return this.dispatchMail(template, data, callback); + } +} + +export default new Mail(); diff --git a/server/views/mailTemplates/email-verification.pug b/server/views/mailTemplates/email-verification.pug new file mode 100644 index 00000000..502099bf --- /dev/null +++ b/server/views/mailTemplates/email-verification.pug @@ -0,0 +1,41 @@ +doctype html +html(xmlns='http://www.w3.org/1999/xhtml') + head + meta(http-equiv='Content-Type', content='text/html; charset=utf-8') + + body(paddingwidth='0', paddingheight='0', style='padding-top: 0; padding-bottom: 0; background-repeat: repeat; width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; -webkit-font-smoothing: antialiased;background-color:white color:black;', offset='0', toppadding='0', leftpadding='0') + + table.tableContent(border='0', cellspacing='0', cellpadding='0', align='center', bgcolor='white', style='font-family: Helvetica, Arial,serif;') + tr + td + table(width='650', border='0', cellspacing='0', cellpadding='0', align='center') + tr + td + table(width='650', border='0', cellspacing='0', cellpadding='0', align='center', ) + tr + td(valign='top', align='center') + //.contentEditableContainer.contentImageEditable + .contentEditable + //- img(src='/img/mail-header-new.png', width='100%',alt='', data-default='placeholder', data-max-width='650') + tr + td(height='20') + tr + td.movableContentContainer(valign='top') + table(cellspacing="15") + tbody(style="font-size : 15px") + tr + td(style='text-align: center;font-family: sans-serif;font-size: 18px;') + h3 Email Verification + tr + td(style="color:black;line-height: 130%;padding: 10px;white-space:pre;") + | Hello, + | To verify you email, click on the button below: + tr(style="color:black;text-align:center") + td + a(href="#{link}" style="text-align:center;font-size:20px;font-family:Helvetica,arial,sans-serif;color:white;font-weight:bold; padding-left: 10px;display:inline-block;min-height:27px;padding : 4px 25px 4px 25px;line-height:27px;border-radius:2px;border-width:1px; background-color:black;" target="_blank") Verify Email + tr + td(style="color:black;padding:10px 10px 0 10px") Or copy and paste the URL into your browser: + tr(style="color:black") + td(width="560px" style='padding:10px;') #{link} + tr(style="color:black;padding:0 10px") + td This link is only valid for the next 24 hours. diff --git a/server/views/mailTemplates/reset-password.pug b/server/views/mailTemplates/reset-password.pug new file mode 100644 index 00000000..3169bebe --- /dev/null +++ b/server/views/mailTemplates/reset-password.pug @@ -0,0 +1,41 @@ +doctype html +html(xmlns='http://www.w3.org/1999/xhtml') + head + meta(http-equiv='Content-Type', content='text/html; charset=utf-8') + + body(paddingwidth='0', paddingheight='0', style='padding-top: 0; padding-bottom: 0; background-repeat: repeat; width: 100% !important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; -webkit-font-smoothing: antialiased;background-color:white color:black;', offset='0', toppadding='0', leftpadding='0') + + table.tableContent(border='0', cellspacing='0', cellpadding='0', align='center', bgcolor='white', style='font-family: Helvetica, Arial,serif;') + tr + td + table(width='650', border='0', cellspacing='0', cellpadding='0', align='center') + tr + td + table(width='650', border='0', cellspacing='0', cellpadding='0', align='center', ) + tr + td(valign='top', align='center') + //.contentEditableContainer.contentImageEditable + .contentEditable + //- img(src='/img/mail-header-new.png', width='100%',alt='', data-default='placeholder', data-max-width='650') + tr + td(height='20') + tr + td.movableContentContainer(valign='top') + table(cellspacing="15") + tbody(style="font-size : 15px") + tr + td(style='text-align: center;font-family: sans-serif;font-size: 18px;') + h3 Reset your password + tr + td(style="color:black;line-height: 130%;padding: 10px;white-space:pre;") + | Hello, + | We received a request to reset the password for your account. To reset your password, click on the button below: + tr(style="color:black;text-align:center") + td + a(href="#{link}" style="text-align:center;font-size:20px;font-family:Helvetica,arial,sans-serif;color:white;font-weight:bold; padding-left: 10px;display:inline-block;min-height:27px;padding : 4px 25px 4px 25px;line-height:27px;border-radius:2px;border-width:1px; background-color:black;" target="_blank") Reset password + tr + td(style="color:black;padding:10px 10px 0 10px") Or copy and paste the URL into your browser: + tr(style="color:black") + td(width="560px" style='padding:10px;') #{link} + tr(style="color:black;padding:0 10px") + td If you did not request this, please ignore this email and your password will remain unchanged. Thanks for using the p5.js Web Editor!