diff --git a/Config.js b/Config.js index df44f8b170..aa52d81312 100644 --- a/Config.js +++ b/Config.js @@ -22,6 +22,7 @@ function Config(applicationId, mount) { this.fileKey = cacheInfo.fileKey; this.facebookAppIds = cacheInfo.facebookAppIds; this.mount = mount; + this.emailSender = cacheInfo.emailSender; } diff --git a/Constants.js b/Constants.js new file mode 100644 index 0000000000..ab46e59efb --- /dev/null +++ b/Constants.js @@ -0,0 +1,4 @@ +module.exports = { + RESET_PASSWORD: "RESET_PASSWORD", + VERIFY_EMAIL: "VERIFY_EMAIL" +}; \ No newline at end of file diff --git a/RestWrite.js b/RestWrite.js index 446a2db9a2..2315d897a4 100644 --- a/RestWrite.js +++ b/RestWrite.js @@ -13,6 +13,7 @@ var passwordCrypto = require('./password'); var facebook = require('./facebook'); var Parse = require('parse/node'); var triggers = require('./triggers'); +var VERIFY_EMAIL = require('./Constants').VERIFY_EMAIL; // query and data are both provided in REST API format. So data // types are encoded by plain old objects. @@ -350,13 +351,16 @@ RestWrite.prototype.transformUser = function() { email: this.data.email, objectId: {'$ne': this.objectId()} }, {limit: 1}).then((results) => { - if (results.length > 0) { - throw new Parse.Error(Parse.Error.EMAIL_TAKEN, - 'Account already exists for this email ' + - 'address'); - } - return Promise.resolve(); - }); + if (results.length > 0) { + throw new Parse.Error(Parse.Error.EMAIL_TAKEN, + 'Account already exists for this email ' + + 'address'); + } + this.data.emailVerified = false; + this.data.perishableToken = rack(); + this.data.emailVerifyToken = rack(); + return Promise.resolve(); + }); }); }; @@ -654,16 +658,27 @@ RestWrite.prototype.runDatabaseOperation = function() { } } + function sendEmailVerification () { + if (typeof this.data.email !== 'undefined' && this.className === "_User" && this.config.emailSender) { + var link = this.config.mount + "/verify_email?token=" + encodeURIComponent(this.data.emailVerifyToken) + "&username=" + encodeURIComponent(this.data.email); + this.config.emailSender(VERIFY_EMAIL, link, this.data.email); + } + } + if (this.query) { // Run an update return this.config.database.update( this.className, this.query, this.data, options).then((resp) => { - this.response = resp; - this.response.updatedAt = this.updatedAt; - }); + sendEmailVerification.call(this); + this.response = resp; + this.response.updatedAt = this.updatedAt; + }); } else { // Run a create return this.config.database.create(this.className, this.data, options) + .then(()=> { + sendEmailVerification.call(this); + }) .then(() => { var resp = { objectId: this.data.objectId, diff --git a/index.js b/index.js index 9f8a5a4702..5d981fadea 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ var batch = require('./batch'), multer = require('multer'), Parse = require('parse/node').Parse, PromiseRouter = require('./PromiseRouter'), + path = require('path'); httpRequest = require('./httpRequest'); // Mutate the Parse object to add the Cloud Code handlers @@ -38,6 +39,7 @@ addParseCloud(); // "dotNetKey": optional key from Parse dashboard // "restAPIKey": optional key from Parse dashboard // "javascriptKey": optional key from Parse dashboard +// "emailSender": optional function to be called with the parameters required to send a password reset or confirmation email function ParseServer(args) { if (!args.appId || !args.masterKey) { throw 'You must provide an appId and masterKey!'; @@ -72,7 +74,8 @@ function ParseServer(args) { dotNetKey: args.dotNetKey || '', restAPIKey: args.restAPIKey || '', fileKey: args.fileKey || 'invalid-file-key', - facebookAppIds: args.facebookAppIds || [] + facebookAppIds: args.facebookAppIds || [], + emailSender: args.emailSender }; // To maintain compatibility. TODO: Remove in v2.1 @@ -92,6 +95,10 @@ function ParseServer(args) { // File handling needs to be before default middlewares are applied api.use('/', require('./files').router); + api.set('views', path.join(__dirname, 'views')); + api.use("/request_password_reset", require('./passwordReset').reset(args.appName, args.appId)); + api.get("/password_reset_success", require('./passwordReset').success); + api.get("/verify_email", require('./verifyEmail')(args.appId)); // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { @@ -103,6 +110,7 @@ function ParseServer(args) { api.use(middlewares.allowCrossDomain); api.use(middlewares.allowMethodOverride); api.use(middlewares.handleParseHeaders); + api.set('view engine', 'jade'); var router = new PromiseRouter(); @@ -165,5 +173,6 @@ function getClassName(parseClass) { module.exports = { ParseServer: ParseServer, + Constants: require('./Constants'), S3Adapter: S3Adapter }; diff --git a/passwordReset.js b/passwordReset.js new file mode 100644 index 0000000000..f832ddc7b8 --- /dev/null +++ b/passwordReset.js @@ -0,0 +1,96 @@ +var passwordCrypto = require('./password'); +var rack = require('hat').rack(); + + +function passwordReset (appName, appId) { + var DatabaseAdapter = require('./DatabaseAdapter'); + var database = DatabaseAdapter.getDatabaseConnection(appId); + + return function (req, res) { + var mount = req.protocol + '://' + req.get('host') + req.baseUrl; + + Promise.resolve() + .then(()=> { + var error = null; + var password = req.body.password; + var passwordConfirm = req.body.passwordConfirm; + var username = req.body.username; + var token = req.body.token; + if (req.method !== 'POST') { + return Promise.resolve() + } + if (!password) { + error = "Password cannot be empty"; + } else if (!passwordConfirm) { + error = "Password confirm cannot be empty"; + } else if (password !== passwordConfirm) { + error = "Passwords do not match" + } else if (!username) { + error = "Username invalid: this is an invalid url"; + } else if (!token) { + error = "Invalid token: this is an invalid url"; + } + if (error) { + return Promise.resolve(error); + } + + return database.find('_User', {username: username}) + .then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Invalid username'); + } + var user = results[0]; + + if (user.perishableSessionToken !== token) { + return Promise.resolve("Invalid token: this is an invalid url") + } else { + return passwordCrypto.hash(password) + .then((hashedPassword)=> { + return database.update("_User", {email: username}, {_hashed_password: hashedPassword, _perishable_token: rack()}, {acl: [user.objectId]}) + }) + .then(()=> { + res.redirect(mount + '/password_reset_success?username=' + username); + return Promise.resolve(true) + }) + } + }) + + }) + .then((error)=> { + if (error === true) { + return; + } + var token = req.query.token; + var username = req.query.username; + if (req.body.token && req.body.username) { + token = req.body.token; + username = req.body.username; + } + var actionUrl = mount + '/request_password_reset?token=' + encodeURIComponent(token) + "&username=" + encodeURIComponent(username); + if (!token || !username) { + return res.status(404).render('not-found') + } + res.render('password-reset', { + name: appName, + token: req.query.token, + username: req.query.username, + action: actionUrl, + error: error + }) + }) + .catch(()=>{ + res.status(404).render('not-found') + }) + } +} + +function success (req, res) { + return res.render("reset-success", {email: req.query.username}); +} + + +module.exports = { + reset: passwordReset, + success: success +} \ No newline at end of file diff --git a/transform.js b/transform.js index 051bc75ae7..f8f7cfc2bb 100644 --- a/transform.js +++ b/transform.js @@ -42,6 +42,14 @@ function transformKeyValue(schema, className, restKey, restValue, options) { key = '_updated_at'; timeField = true; break; + case 'perishableToken': + case '_perishable_token': + key = "_perishable_token"; + break; + case 'emailVerifyToken': + case '_email_verify_token': + key = "_email_verify_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; @@ -628,8 +636,11 @@ function untransformObject(schema, className, mongoObject) { restObject['password'] = mongoObject[key]; break; case '_acl': + break; case '_email_verify_token': + restObject['emailVerifyToken'] = mongoObject[key]; case '_perishable_token': + restObject['perishableSessionToken'] = mongoObject[key]; break; case '_session_token': restObject['sessionToken'] = mongoObject[key]; diff --git a/users.js b/users.js index d769b9c5d0..4069aef351 100644 --- a/users.js +++ b/users.js @@ -10,6 +10,7 @@ var facebook = require('./facebook'); var PromiseRouter = require('./PromiseRouter'); var rest = require('./rest'); var RestWrite = require('./RestWrite'); +var Constants = require('./Constants'); var deepcopy = require('deepcopy'); var router = new PromiseRouter(); @@ -188,9 +189,34 @@ function handleUpdate(req) { }); } -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); +function handleReset(req) { + if (!req.body.email && req.query.email) { + req.body = req.query; + } + + if (!req.body.email) { + throw new Parse.Error(Parse.Error.EMAIL_MISSING, + 'email is required.'); + } + + return req.database.find('_User', {email: req.body.email}) + .then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, + 'Email not found.'); + } + var emailSender = req.info.app && req.info.app.emailSender; + if (!emailSender) { + throw new Error("No email sender function specified"); + } + var perishableSessionToken = encodeURIComponent(results[0].perishableSessionToken); + var encodedEmail = encodeURIComponent(req.body.email) + var endpoint = req.config.mount + "/request_password_reset?token=" + perishableSessionToken + "&username=" + encodedEmail; + return emailSender(Constants.RESET_PASSWORD, endpoint,req.body.email); + }) + .then(()=>{ + return {response:{}}; + }) } router.route('POST', '/users', handleCreate); @@ -202,6 +228,6 @@ router.route('PUT', '/users/:objectId', handleUpdate); router.route('GET', '/users', handleFind); router.route('DELETE', '/users/:objectId', handleDelete); -router.route('POST', '/requestPasswordReset', notImplementedYet); +router.route('POST', '/requestPasswordReset', handleReset); module.exports = router; diff --git a/verifyEmail.js b/verifyEmail.js new file mode 100644 index 0000000000..5983cd025a --- /dev/null +++ b/verifyEmail.js @@ -0,0 +1,44 @@ +function verifyEmail (appId) { + var DatabaseAdapter = require('./DatabaseAdapter'); + var database = DatabaseAdapter.getDatabaseConnection(appId); + return function (req, res) { + var token = req.query.token; + var username = req.query.username; + + Promise.resolve() + .then(()=>{ + var error = null; + if (!token || !username) { + error = "Unable to verify email, check the URL and try again"; + } + return Promise.resolve(error) + }) + .then((error)=>{ + if (error) { + return Promise.resolve(error); + } + return database.find('_User', {email: username}) + .then((results)=>{ + if (!results.length) { + return Promise.resolve("Could not find email " + username + " check the URL and try again"); + } + + var user = results[0]; + return database.update("_User", {email: username}, {emailVerified: true}, {acl:[user.objectId]}) + .then(()=>Promise.resolve()) + }) + + }) + .then((error)=>{ + res.render('email-verified', { + email: username, + error: error + }) + }) + .catch(()=>{ + res.status(404).render('not-found') + }) + } +} + +module.exports = verifyEmail; \ No newline at end of file diff --git a/views/email-verified.jade b/views/email-verified.jade new file mode 100644 index 0000000000..08643c82ee --- /dev/null +++ b/views/email-verified.jade @@ -0,0 +1,8 @@ +extends layout +block style + include styles.css +block content + if locals.error + h1.error #{error} + else + h1 Successfully verified #{email}! \ No newline at end of file diff --git a/views/four-o-four.css b/views/four-o-four.css new file mode 100644 index 0000000000..525b55618e --- /dev/null +++ b/views/four-o-four.css @@ -0,0 +1,106 @@ + +html{ + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +html, +body, +.four_oh_four { + padding: 0px; + margin: 0px; + height: 100%; + width: 100%; +} +.four_oh_four { + background-image: -o-linear-gradient(-38deg, #1AB5C2 0%, #0572E0 100%); + background-image: -moz-linear-gradient(-38deg, #1AB5C2 0%, #0572E0 100%); + background-image: -ms-linear-gradient(-38deg, #1AB5C2 0%, #0572E0 100%); + background-image: linear-gradient(128deg, #1AB5C2 0%, #0572E0 100%); + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: #fff; +} +.four_oh_four .logo { + display: block; + margin: 0 auto; + top: 50px; + position: relative; +} +.four_oh_four .nav { + list-style: none; + font-weight: 200; + font-size: 18px; + margin: 0 auto; + position: absolute; + padding: 0; + bottom: 50px; + width: 100%; + text-align: center; +} +.four_oh_four .nav li { + display: inline-block; + margin: 0px 10px; +} +.four_oh_four .nav a { + color: #fff; + text-decoration: none; +} +.four_oh_four .nav a:hover { + text-decoration: underline; +} +.four_oh_four .error { + display: block; + position: absolute; + top: 50%; + -moz-transform: translateY(-50%); + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + margin: 0; + padding: 0; + text-align: center; + width: 100%; +} +.four_oh_four h1 { + font-weight: 100; + font-size: 240px; + margin: 0; + padding: 0; +} +.four_oh_four h2 { + font-weight: 200; + font-size: 28px; + margin: 0; + padding: 0; +} +.four_oh_four #emoji { + position: relative; + top: 40px; + background-image: url('https://www.parse.com/images/404/sprite.png'); + display: inline-block; + width: 200px; + height: 230px; + background-size: 6000px; + background-repeat: no-repeat; +} +@media (max-width: 500px) { + .four_oh_four .logo { + top: 30px; + } + .four_oh_four h1 { + font-size: 120px; + } + .four_oh_four h2 { + font-size: 22px; + } + .four_oh_four #emoji { + width: 100px; + height: 115px; + top: 16px; + background-size: 3000px; + } + .four_oh_four .nav { + bottom: 30px; + } + .four_oh_four .nav li { + margin: 5px 14px; + } +} diff --git a/views/layout.jade b/views/layout.jade new file mode 100644 index 0000000000..a213a558d5 --- /dev/null +++ b/views/layout.jade @@ -0,0 +1,10 @@ +doctype html +html + head + block script + style + block style + block title + title Parse + body + block content \ No newline at end of file diff --git a/views/not-found.jade b/views/not-found.jade new file mode 100644 index 0000000000..8e3cc67bba --- /dev/null +++ b/views/not-found.jade @@ -0,0 +1,57 @@ +extends layout +block style + include four-o-four.css +block script + script. + var emojiCount = 30; + function getRandomInt(min, max) { + return Math.floor(Math.random() * (max - min + 1)) + min; + } + function catchFeelings() { + var emoji = getRandomInt(0, emojiCount - 1); + if(window.innerWidth > 400){ + var bgOffset = (emoji * 200 * -1) + "px 0px" + } else { + var bgOffset = (emoji * 100 * -1) + "px 0px" + } + document.getElementById('emoji').style.backgroundPosition = bgOffset; + } + +block content + div.four_oh_four + img.logo(src="https://www.parse.com/images/parse-logo.png", width="98" height="31") + figure.error + h1 + span 4 + div#emoji + span 4 + h2 Oh no, we can't find that page! + + script. + catchFeelings(); + + setTimeout(function () { + setInterval(function () { + catchFeelings() + }, 3000) + }, 3000) + + document.addEventListener('keydown', function (e) { + e = e || window.event; + switch (e.which || e.keyCode) { + case 32: + for (var i = 0; i < 20; i++) { + setTimeout(function () { + catchFeelings(); + }, 50 * i) + } + break; + + default: + return; + } + e.preventDefault(); + }); + + + diff --git a/views/password-reset.jade b/views/password-reset.jade new file mode 100644 index 0000000000..474b4d7bf0 --- /dev/null +++ b/views/password-reset.jade @@ -0,0 +1,15 @@ +extends layout +block style + include styles.css +block content + div.reset + h1 Reset Your Password for #{name} + div.error #{error} + form(method='POST' action="#{action}") + label New Password for #{username} + input.form-control(type='password', name='password', autofocus) + label Confirm Password + input.form-control(type='password', name='passwordConfirm') + input(type="hidden", name="token", value='#{token}') + input(type="hidden", name="username", value='#{username}') + button.btn.btn-primary(type='submit') Change Password diff --git a/views/reset-success.jade b/views/reset-success.jade new file mode 100644 index 0000000000..3ce14eefa0 --- /dev/null +++ b/views/reset-success.jade @@ -0,0 +1,5 @@ +extends layout +block style + include styles.css +block content + h1 Password for #{email} successfully reset! \ No newline at end of file diff --git a/views/styles.css b/views/styles.css new file mode 100644 index 0000000000..0693717575 --- /dev/null +++ b/views/styles.css @@ -0,0 +1,119 @@ + +h1 { + display: block; + font: inherit; + font-size: 30px; + font-weight: 600; + height: 30px; + line-height: 30px; + margin: 45px 0px 45px 0px; + padding: 0px 8px 0px 8px; +} + +h1.error { + color: red; +} + +:not(h1).error { + color: red; + padding: 0px 8px 0px 8px; + margin: -25px 0px -20px 0px; +} + +body { + font-family: 'Open Sans', 'Helvetica Neue', Helvetica; + color: #0067AB; +} +div.reset { + margin: 15px 99px 0px 98px; +} + +label { + color: #666666; +} +form { + margin: 0px 0px 45px 0px; + padding: 0px 8px 0px 8px; +} +form > * { + display: block; + margin-top: 25px; + margin-bottom: 7px; +} + +button { + font-size: 22px; + color: white; + background: #0067AB; + -moz-border-radius: 5px; + -webkit-border-radius: 5px; + -o-border-radius: 5px; + -ms-border-radius: 5px; + -khtml-border-radius: 5px; + border-radius: 5px; + background-image: -webkit-gradient(linear,50% 0,50% 100%,color-stop(0%,#0070BA),color-stop(100%,#00558C)); + background-image: -webkit-linear-gradient(#0070BA,#00558C); + background-image: -moz-linear-gradient(#0070BA,#00558C); + background-image: -o-linear-gradient(#0070BA,#00558C); + background-image: -ms-linear-gradient(#0070BA,#00558C); + background-image: linear-gradient(#0070BA,#00558C); + -moz-box-shadow: inset 0 1px 0 0 #0076c4; + -webkit-box-shadow: inset 0 1px 0 0 #0076c4; + -o-box-shadow: inset 0 1px 0 0 #0076c4; + box-shadow: inset 0 1px 0 0 #0076c4; + border: 1px solid #005E9C; + padding: 10px 14px; + cursor: pointer; + outline: none; + display: block; + font-family: "Helvetica Neue",Helvetica; + + -webkit-box-align: center; + text-align: center; + box-sizing: border-box; + letter-spacing: normal; + word-spacing: normal; + line-height: normal; + text-transform: none; + text-indent: 0px; + text-shadow: none; +} + +button:hover { + background-image: -webkit-gradient(linear,50% 0,50% 100%,color-stop(0%,#0079CA),color-stop(100%,#005E9C)); + background-image: -webkit-linear-gradient(#0079CA,#005E9C); + background-image: -moz-linear-gradient(#0079CA,#005E9C); + background-image: -o-linear-gradient(#0079CA,#005E9C); + background-image: -ms-linear-gradient(#0079CA,#005E9C); + background-image: linear-gradient(#0079CA,#005E9C); + -moz-box-shadow: inset 0 0 0 0 #0076c4; + -webkit-box-shadow: inset 0 0 0 0 #0076c4; + -o-box-shadow: inset 0 0 0 0 #0076c4; + box-shadow: inset 0 0 0 0 #0076c4; +} + +button:active { + background-image: -webkit-gradient(linear,50% 0,50% 100%,color-stop(0%,#00395E),color-stop(100%,#005891)); + background-image: -webkit-linear-gradient(#00395E,#005891); + background-image: -moz-linear-gradient(#00395E,#005891); + background-image: -o-linear-gradient(#00395E,#005891); + background-image: -ms-linear-gradient(#00395E,#005891); + background-image: linear-gradient(#00395E,#005891); +} + +input { + color: black; + cursor: auto; + display: inline-block; + font-family: 'Helvetica Neue', Helvetica; + font-size: 25px; + height: 30px; + letter-spacing: normal; + line-height: normal; + margin: 2px 0px 2px 0px; + padding: 5px; + text-transform: none; + vertical-align: baseline; + width: 500px; + word-spacing: 0px; +}