From 24f9176e178927a810700f4a42fd2190798370a9 Mon Sep 17 00:00:00 2001 From: Alex Kwan Date: Wed, 17 Feb 2016 12:53:17 +0800 Subject: [PATCH 1/8] fix spacing --- src/RestQuery.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/RestQuery.js b/src/RestQuery.js index b5bec1fbb6..15c199aac7 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -381,7 +381,7 @@ RestQuery.prototype.handleInclude = function() { this.include = this.include.slice(1); return this.handleInclude(); } - + return pathResponse; }; From 40d37e9a238bad83950b734e5613da9ced3a29fe Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Thu, 11 Feb 2016 22:16:07 -0500 Subject: [PATCH 2/8] FIX : User Roles not added to create, update or delete calls --- src/RestWrite.js | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/src/RestWrite.js b/src/RestWrite.js index 2728a9a6bd..02262b5ebe 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -86,21 +86,15 @@ RestWrite.prototype.execute = function() { // Uses the Auth object to get the list of roles, adds the user id RestWrite.prototype.getUserAndRoleACL = function() { - if (this.auth.isMaster) { + if (this.auth.isMaster || !this.auth.user) { return Promise.resolve(); } - - this.runOptions.acl = ['*']; - - if (this.auth.user) { - return this.auth.getUserRoles().then((roles) => { - roles.push(this.auth.user.id); - this.runOptions.acl = this.runOptions.acl.concat(roles); - return Promise.resolve(); - }); - }else{ + return this.auth.getUserRoles().then((roles) => { + roles.push('*'); + roles.push(this.auth.user.id); + this.runOptions.acl = roles; return Promise.resolve(); - } + }); }; // Validates this operation against the schema. @@ -176,11 +170,11 @@ RestWrite.prototype.validateAuthData = function() { var authData = this.data.authData; var anonData = this.data.authData.anonymous; - + if (this.config.enableAnonymousUsers === true && (anonData === null || (anonData && anonData.id))) { return this.handleAnonymousAuthData(); - } + } // Not anon, try other providers var providers = Object.keys(authData); @@ -710,7 +704,7 @@ RestWrite.prototype.runDatabaseOperation = function() { throw new Parse.Error(Parse.Error.SESSION_MISSING, 'cannot modify user ' + this.query.objectId); } - + if (this.className === '_Product' && this.data.download) { this.data.downloadName = this.data.download.name; } @@ -735,7 +729,7 @@ RestWrite.prototype.runDatabaseOperation = function() { ACL[this.data.objectId] = { read: true, write: true }; ACL['*'] = { read: true, write: false }; this.data.ACL = ACL; - } + } // Run a create return this.config.database.create(this.className, this.data, this.runOptions) .then(() => { From 3c31e1f864e46087717bcc97f9d671bd6c7cf98f Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 16 Feb 2016 23:43:09 -0800 Subject: [PATCH 3/8] Exploring the interface of a mail adapter --- package.json | 1 + src/Adapters/Email/SimpleMailgunAdapter.js | 34 ++++++++ src/Config.js | 5 +- src/RestWrite.js | 21 +++++ src/Routers/UsersRouter.js | 48 ++++++++--- src/index.js | 69 ++++++++++------ src/passwordReset.js | 94 ++++++++++++++++++++++ src/transform.js | 8 +- src/verifyEmail.js | 44 ++++++++++ 9 files changed, 287 insertions(+), 37 deletions(-) create mode 100644 src/Adapters/Email/SimpleMailgunAdapter.js create mode 100644 src/passwordReset.js create mode 100644 src/verifyEmail.js diff --git a/package.json b/package.json index cc2ff211ce..49f2808c21 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "body-parser": "^1.14.2", "deepcopy": "^0.6.1", "express": "^4.13.4", + "mailgun-js": "^0.7.7", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js new file mode 100644 index 0000000000..8e326f70cb --- /dev/null +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -0,0 +1,34 @@ +import Mailgun from 'mailgun-js'; + +export default (mailgunOptions) => { + let mailgun = Mailgun(mailgunOptions); + + let sendMail = (to, subject, text) => { + let data = { + from: mailgunOptions.fromAddress, + to: to, + subject: subject, + text: text, + } + + return new Promise((resolve, reject) => { + mailgun.messages().send(data, (err, body) => { + if (typeof err !== 'undefined') { + reject(err); + } + resolve(body); + }); + }); + } + + return { + sendVerificationEmail: ({ link, user, appName, }) => { + let verifyMessage = + "Hi,\n\n" + + "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + + "" + + "Click here to confirm it:\n" + link; + sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); + } + } +} diff --git a/src/Config.js b/src/Config.js index 4859c09c85..0c8233fea2 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,9 +24,12 @@ export class Config { this.facebookAppIds = cacheInfo.facebookAppIds; this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; + this.verifyUserEmails = cacheInfo.verifyUserEmails; + this.emailAdapter = cacheInfo.emailAdapter; + this.database = DatabaseAdapter.getDatabaseConnection(applicationId); this.filesController = cacheInfo.filesController; - this.pushController = cacheInfo.pushController; + this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; this.oauth = cacheInfo.oauth; diff --git a/src/RestWrite.js b/src/RestWrite.js index 02262b5ebe..bf2f94d9ce 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -403,6 +403,11 @@ RestWrite.prototype.transformUser = function() { throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username'); } + if (this.config.verifyUserEmails && this.data.email) { + this.data.emailVerified = false; + this.data._email_verify_token = cryptoUtils.randomString(25); + this.data._perishable_token = cryptoUtils.randomString(25); + } return Promise.resolve(); }); }).then(() => { @@ -715,10 +720,23 @@ RestWrite.prototype.runDatabaseOperation = function() { throw new Parse.Error(Parse.Error.INVALID_ACL, 'Invalid ACL.'); } + function sendEmailVerification() { + var hasUserEmail = typeof this.data.email !== 'undefined' && this.className === "_User"; + if (hasUserEmail && this.config.verifyUserEmails) { + let link = this.config.mount + "/verify_email?token=" + encodeURIComponent(this.data._email_verify_token) + "&username=" + encodeURIComponent(this.data.username); + this.config.emailAdapter.sendVerificationEmail({ + link: link, + user: this.auth.user, + appName: this.co.appName, + }); + } + } + if (this.query) { // Run an update return this.config.database.update( this.className, this.query, this.data, this.runOptions).then((resp) => { + sendEmailVerification.call(this); this.response = resp; this.response.updatedAt = this.updatedAt; }); @@ -732,6 +750,9 @@ RestWrite.prototype.runDatabaseOperation = function() { } // Run a create return this.config.database.create(this.className, this.data, this.runOptions) + .then(() => { + sendEmailVerification.call(this); + }) .then(() => { var resp = { objectId: this.data.objectId, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 29d33ed2ff..8b873e2e0c 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,14 +1,14 @@ // These methods handle the User-related routes. -import deepcopy from 'deepcopy'; +import deepcopy from 'deepcopy'; -import ClassesRouter from './ClassesRouter'; -import PromiseRouter from '../PromiseRouter'; -import rest from '../rest'; -import Auth from '../Auth'; +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; import passwordCrypto from '../password'; -import RestWrite from '../RestWrite'; -import { newToken } from '../cryptoUtils'; +import RestWrite from '../RestWrite'; +import { newToken } from '../cryptoUtils'; export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -138,6 +138,36 @@ export class UsersRouter extends ClassesRouter { return Promise.resolve(success); } + 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:{}}; + }) + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); this.route('POST', '/users', req => { return this.handleCreate(req); }); @@ -147,9 +177,7 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', () => { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); - }); + this.route('POST', '/requestPasswordReset', req => this.handleReset(req)); } } diff --git a/src/index.js b/src/index.js index fb8d29efed..2846f55348 100644 --- a/src/index.js +++ b/src/index.js @@ -11,31 +11,31 @@ var batch = require('./batch'), multer = require('multer'), Parse = require('parse/node').Parse, httpRequest = require('./httpRequest'); - -import PromiseRouter from './PromiseRouter'; -import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; -import { S3Adapter } from './Adapters/Files/S3Adapter'; -import { FilesController } from './Controllers/FilesController'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; -import { PushController } from './Controllers/PushController'; - -import { ClassesRouter } from './Routers/ClassesRouter'; -import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { UsersRouter } from './Routers/UsersRouter'; -import { SessionsRouter } from './Routers/SessionsRouter'; -import { RolesRouter } from './Routers/RolesRouter'; +import passwordReset from './passwordReset'; +import PromiseRouter from './PromiseRouter'; +import SimpleMailgunAdapter from './Adapters/Email/SimpleMailgunAdapter'; +import verifyEmail from './verifyEmail'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; +import { ClassesRouter } from './Routers/ClassesRouter'; +import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { FilesController } from './Controllers/FilesController'; +import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; -import { SchemasRouter } from './Routers/SchemasRouter'; +import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { IAPValidationRouter } from './Routers/IAPValidationRouter'; -import { PushRouter } from './Routers/PushRouter'; -import { FilesRouter } from './Routers/FilesRouter'; -import { LogsRouter } from './Routers/LogsRouter'; - -import { loadAdapter } from './Adapters/AdapterLoader'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { loadAdapter } from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; +import { LogsRouter } from './Routers/LogsRouter'; +import { PushController } from './Controllers/PushController'; +import { PushRouter } from './Routers/PushRouter'; +import { RolesRouter } from './Routers/RolesRouter'; +import { S3Adapter } from './Adapters/Files/S3Adapter'; +import { SchemasRouter } from './Routers/SchemasRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; +import { UsersRouter } from './Routers/UsersRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -66,6 +66,7 @@ addParseCloud(); function ParseServer({ appId, + appName, masterKey, databaseAdapter, filesAdapter, @@ -83,6 +84,8 @@ function ParseServer({ enableAnonymousUsers = true, oauth = {}, serverURL = '', + verifyUserEmails = false, + emailAdapter, }) { if (!appId || !masterKey) { throw 'You must provide an appId and masterKey!'; @@ -105,8 +108,7 @@ function ParseServer({ throw "argument 'cloud' must either be a string or a function"; } } - - + const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); @@ -116,7 +118,19 @@ function ParseServer({ const filesController = new FilesController(filesControllerAdapter); const pushController = new PushController(pushControllerAdapter); const loggerController = new LoggerController(loggerControllerAdapter); - + + if (verifyUserEmails) { + if (typeof appName !== 'string') { + throw 'An app name is required when using email verification.'; + } + if (!emailAdapter) { + throw 'User email verification was enabled, but no email adapter was provided'; + } + if (typeof emailAdapter.sendVerificationEmail !== 'function') { + throw 'Invalid email adapter: no sendVerificationEmail() function was provided'; + } + } + cache.apps[appId] = { masterKey: masterKey, collectionPrefix: collectionPrefix, @@ -131,7 +145,8 @@ function ParseServer({ loggerController: loggerController, enableAnonymousUsers: enableAnonymousUsers, oauth: oauth, -}; + verifyUserEmails: verifyUserEmails, + }; // To maintain compatibility. TODO: Remove in v2.1 if (process.env.FACEBOOK_APP_ID) { @@ -148,6 +163,9 @@ function ParseServer({ // File handling needs to be before default middlewares are applied api.use('/', new FilesRouter().getExpressRouter()); + api.use('/request_password_reset', passwordReset.reset(appName, appId)); + api.get('/password_reset_success', passwordReset.success); + api.get('/verify_email', verifyEmail); // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { @@ -172,7 +190,7 @@ function ParseServer({ new LogsRouter(), new IAPValidationRouter() ]; - + if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { routers.push(require('./global_config')); } @@ -244,5 +262,6 @@ function getClassName(parseClass) { module.exports = { ParseServer: ParseServer, - S3Adapter: S3Adapter + S3Adapter: S3Adapter, + SimpleMailgunAdapter: SimpleMailgunAdapter, }; diff --git a/src/passwordReset.js b/src/passwordReset.js new file mode 100644 index 0000000000..b4223c8aa2 --- /dev/null +++ b/src/passwordReset.js @@ -0,0 +1,94 @@ +var passwordCrypto = require('./password'); +var cryptoUtils = require('./cryptoUtils'); + +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._perishable_token !== 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: cryptoUtils.randomString(25)}, {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 +} diff --git a/src/transform.js b/src/transform.js index f254f0d464..c76e1e4c9d 100644 --- a/src/transform.js +++ b/src/transform.js @@ -42,6 +42,12 @@ export function transformKeyValue(schema, className, restKey, restValue, options key = '_updated_at'; timeField = true; break; + case '_perishable_token': + key = "_perishable_token"; + break; + case '_email_verify_token': + key = "_email_verify_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; @@ -649,7 +655,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals restObject['authData'][provider] = mongoObject[key]; break; } - + if (key.indexOf('_p_') == 0) { var newKey = key.substring(3); var expected; diff --git a/src/verifyEmail.js b/src/verifyEmail.js new file mode 100644 index 0000000000..f62ed517d4 --- /dev/null +++ b/src/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; From e953953310033771be8abafda4ae00608be6d039 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Sun, 21 Feb 2016 17:09:10 -0800 Subject: [PATCH 4/8] Implement and test sending of signup email --- spec/ParseUser.spec.js | 36 +++++++++++++++++++++- src/Adapters/Email/SimpleMailgunAdapter.js | 2 +- src/RestWrite.js | 17 ---------- src/Routers/UsersRouter.js | 24 +++++++++++++-- src/index.js | 1 + 5 files changed, 58 insertions(+), 22 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 33da62e824..78369b832d 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -47,6 +47,40 @@ describe('Parse.User testing', () => { }); }); + it('sends verification email if email verification is enabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + it("user login wrong username", (done) => { Parse.User.signUp("asdf", "zxcv", null, { success: function(user) { @@ -1628,7 +1662,7 @@ describe('Parse.User testing', () => { done(); }); }); - + it('test parse user become', (done) => { var sessionToken = null; Parse.Promise.as().then(function() { diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index 8e326f70cb..47c690968c 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -28,7 +28,7 @@ export default (mailgunOptions) => { "You are being asked to confirm the e-mail address " + user.email + " with " + appName + "\n\n" + "" + "Click here to confirm it:\n" + link; - sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); + return sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); } } } diff --git a/src/RestWrite.js b/src/RestWrite.js index bf2f94d9ce..95f072b3ca 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -405,7 +405,6 @@ RestWrite.prototype.transformUser = function() { } if (this.config.verifyUserEmails && this.data.email) { this.data.emailVerified = false; - this.data._email_verify_token = cryptoUtils.randomString(25); this.data._perishable_token = cryptoUtils.randomString(25); } return Promise.resolve(); @@ -720,23 +719,10 @@ RestWrite.prototype.runDatabaseOperation = function() { throw new Parse.Error(Parse.Error.INVALID_ACL, 'Invalid ACL.'); } - function sendEmailVerification() { - var hasUserEmail = typeof this.data.email !== 'undefined' && this.className === "_User"; - if (hasUserEmail && this.config.verifyUserEmails) { - let link = this.config.mount + "/verify_email?token=" + encodeURIComponent(this.data._email_verify_token) + "&username=" + encodeURIComponent(this.data.username); - this.config.emailAdapter.sendVerificationEmail({ - link: link, - user: this.auth.user, - appName: this.co.appName, - }); - } - } - if (this.query) { // Run an update return this.config.database.update( this.className, this.query, this.data, this.runOptions).then((resp) => { - sendEmailVerification.call(this); this.response = resp; this.response.updatedAt = this.updatedAt; }); @@ -750,9 +736,6 @@ RestWrite.prototype.runDatabaseOperation = function() { } // Run a create return this.config.database.create(this.className, this.data, this.runOptions) - .then(() => { - sendEmailVerification.call(this); - }) .then(() => { var resp = { objectId: this.data.objectId, diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 8b873e2e0c..81473a844d 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -8,7 +8,7 @@ import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; import RestWrite from '../RestWrite'; -import { newToken } from '../cryptoUtils'; +let cryptoUtils = require('../cryptoUtils'); export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -25,7 +25,25 @@ export class UsersRouter extends ClassesRouter { let data = deepcopy(req.body); req.body = data; req.params.className = '_User'; - return super.handleCreate(req); + + if (req.config.verifyUserEmails) { + req.body._email_verify_token = cryptoUtils.randomString(25); + } + + let p = super.handleCreate(req); + + if (req.config.verifyUserEmails) { + // Send email as fire-and-forget once the user makes it into the DB. + p.then(() => { + let link = req.config.mount + "/verify_email?token=" + encodeURIComponent(req.body._email_verify_token) + "&username=" + encodeURIComponent(req.body.username); + req.config.emailAdapter.sendVerificationEmail({ + appName: req.config.appName, + link: link, + user: req.auth.user, + }); + }); + } + return p; } handleUpdate(req) { @@ -84,7 +102,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - let token = 'r:' + newToken(); + let token = 'r:' + cryptoUtils.newToken(); user.sessionToken = token; delete user.password; diff --git a/src/index.js b/src/index.js index 2846f55348..c4f40fdacb 100644 --- a/src/index.js +++ b/src/index.js @@ -146,6 +146,7 @@ function ParseServer({ enableAnonymousUsers: enableAnonymousUsers, oauth: oauth, verifyUserEmails: verifyUserEmails, + emailAdapter: emailAdapter, }; // To maintain compatibility. TODO: Remove in v2.1 From 8f643c74335a6253833065874bd621afd705b3b5 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Mon, 22 Feb 2016 14:47:43 -0800 Subject: [PATCH 5/8] Finish implementation of email verification on create --- spec/ParseUser.spec.js | 177 +++++++++++++++++++++++++++++++++++++ src/Config.js | 1 + src/RestWrite.js | 1 - src/Routers/UsersRouter.js | 4 +- src/index.js | 11 ++- src/verifyEmail.js | 53 ++++------- 6 files changed, 206 insertions(+), 41 deletions(-) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 78369b832d..35c9cc6871 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -72,7 +72,49 @@ describe('Parse.User testing', () => { user.signUp(null, { success: function(user) { expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); done(); + } + }); + }); + + it('does not send verification email if email verification is disabled', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve() + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: false, + emailAdapter: emailAdapter, + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.signUp(null, { + success: function(user) { + user.fetch() + .then(() => { + expect(emailAdapter.sendVerificationEmail.calls.count()).toEqual(0); + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); }, error: function(userAgain, error) { fail('Failed to save user'); @@ -81,6 +123,141 @@ describe('Parse.User testing', () => { }); }); + it('receives the app name and user in the adapter', done => { + var emailAdapter = { + sendVerificationEmail: options => { + expect(options.appName).toEqual('emailing app'); + expect(options.user.get('email')).toEqual('user@parse.com'); + done(); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }) + + it('when you click the link in the email it sets emailVerified to true and redirects you', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/verify_email_success.html?username=zxcv'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }); + }); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(); + }); + + it('redirects you to invalid link if you try to verify email incorrecly', done => { + request.get('http://localhost:8378/1/verify_email', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + done() + }); + }); + + it('redirects you to invalid link if you try to validate a nonexistant users email', done => { + request.get('http://localhost:8378/1/verify_email?token=asdfasdf&username=sadfasga', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + done(); + }); + }); + + it('does not update email verified if you use an invalid token', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: options => { + request.get('http://localhost:8378/1/verify_email?token=invalid&username=zxcv', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/invalid_link.html'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'emailing app', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: emailAdapter, + }); + user.setPassword("asdf"); + user.setUsername("zxcv"); + user.set('email', 'user@parse.com'); + user.signUp(null, { + success: () => {}, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + it("user login wrong username", (done) => { Parse.User.signUp("asdf", "zxcv", null, { success: function(user) { diff --git a/src/Config.js b/src/Config.js index 0c8233fea2..770500787d 100644 --- a/src/Config.js +++ b/src/Config.js @@ -26,6 +26,7 @@ export class Config { this.verifyUserEmails = cacheInfo.verifyUserEmails; this.emailAdapter = cacheInfo.emailAdapter; + this.appName = cacheInfo.appName; this.database = DatabaseAdapter.getDatabaseConnection(applicationId); this.filesController = cacheInfo.filesController; diff --git a/src/RestWrite.js b/src/RestWrite.js index 95f072b3ca..7835f7a5b8 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -404,7 +404,6 @@ RestWrite.prototype.transformUser = function() { 'Account already exists for this username'); } if (this.config.verifyUserEmails && this.data.email) { - this.data.emailVerified = false; this.data._perishable_token = cryptoUtils.randomString(25); } return Promise.resolve(); diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 81473a844d..b8e053a903 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -9,6 +9,7 @@ import Auth from '../Auth'; import passwordCrypto from '../password'; import RestWrite from '../RestWrite'; let cryptoUtils = require('../cryptoUtils'); +let triggers = require('../triggers'); export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -28,6 +29,7 @@ export class UsersRouter extends ClassesRouter { if (req.config.verifyUserEmails) { req.body._email_verify_token = cryptoUtils.randomString(25); + req.body.emailVerified = false; } let p = super.handleCreate(req); @@ -39,7 +41,7 @@ export class UsersRouter extends ClassesRouter { req.config.emailAdapter.sendVerificationEmail({ appName: req.config.appName, link: link, - user: req.auth.user, + user: triggers.inflate('_User', req.body), }); }); } diff --git a/src/index.js b/src/index.js index c4f40fdacb..5a32be4ca1 100644 --- a/src/index.js +++ b/src/index.js @@ -147,9 +147,10 @@ function ParseServer({ oauth: oauth, verifyUserEmails: verifyUserEmails, emailAdapter: emailAdapter, + appName: appName, }; - // To maintain compatibility. TODO: Remove in v2.1 + // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { cache.apps[appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } @@ -164,9 +165,11 @@ function ParseServer({ // File handling needs to be before default middlewares are applied api.use('/', new FilesRouter().getExpressRouter()); - api.use('/request_password_reset', passwordReset.reset(appName, appId)); - api.get('/password_reset_success', passwordReset.success); - api.get('/verify_email', verifyEmail); + if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { + api.use('/request_password_reset', passwordReset.reset(appName, appId)); + api.get('/password_reset_success', passwordReset.success); + api.get('/verify_email', verifyEmail(appId, serverURL)); + } // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { diff --git a/src/verifyEmail.js b/src/verifyEmail.js index f62ed517d4..5bd1da3269 100644 --- a/src/verifyEmail.js +++ b/src/verifyEmail.js @@ -1,43 +1,26 @@ -function verifyEmail (appId) { +function verifyEmail(appId, serverURL) { var DatabaseAdapter = require('./DatabaseAdapter'); var database = DatabaseAdapter.getDatabaseConnection(appId); - return function (req, res) { + return (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"); + if (!token || !username) { + res.redirect(302, serverURL + '/invalid_link.html'); + return; + } + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + res.redirect(302, serverURL + '/invalid_link.html'); + } else { + res.redirect(302, serverURL + '/verify_email_success.html?username=' + username); } - - 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') - }) + }); + }); } } From 817ef9734c2eb8627490f68c15cbeb42f0a9bc12 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Mon, 22 Feb 2016 14:55:16 -0800 Subject: [PATCH 6/8] Cleanup reset logic (that will come in another PR) --- src/RestWrite.js | 3 -- src/Routers/UsersRouter.js | 33 ++----------- src/passwordReset.js | 94 -------------------------------------- src/transform.js | 3 -- 4 files changed, 3 insertions(+), 130 deletions(-) delete mode 100644 src/passwordReset.js diff --git a/src/RestWrite.js b/src/RestWrite.js index 7835f7a5b8..02262b5ebe 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -403,9 +403,6 @@ RestWrite.prototype.transformUser = function() { throw new Parse.Error(Parse.Error.USERNAME_TAKEN, 'Account already exists for this username'); } - if (this.config.verifyUserEmails && this.data.email) { - this.data._perishable_token = cryptoUtils.randomString(25); - } return Promise.resolve(); }); }).then(() => { diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index b8e053a903..2610fc7651 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -158,36 +158,6 @@ export class UsersRouter extends ClassesRouter { return Promise.resolve(success); } - 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:{}}; - }) - } - mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); this.route('POST', '/users', req => { return this.handleCreate(req); }); @@ -197,6 +167,9 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); + this.route('POST', '/requestPasswordReset', () => { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); + }); this.route('POST', '/requestPasswordReset', req => this.handleReset(req)); } } diff --git a/src/passwordReset.js b/src/passwordReset.js deleted file mode 100644 index b4223c8aa2..0000000000 --- a/src/passwordReset.js +++ /dev/null @@ -1,94 +0,0 @@ -var passwordCrypto = require('./password'); -var cryptoUtils = require('./cryptoUtils'); - -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._perishable_token !== 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: cryptoUtils.randomString(25)}, {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 -} diff --git a/src/transform.js b/src/transform.js index c76e1e4c9d..7ff570c063 100644 --- a/src/transform.js +++ b/src/transform.js @@ -42,9 +42,6 @@ export function transformKeyValue(schema, className, restKey, restValue, options key = '_updated_at'; timeField = true; break; - case '_perishable_token': - key = "_perishable_token"; - break; case '_email_verify_token': key = "_email_verify_token"; break; From 317d6eabc3a6425d3587f1332c5df4c5cce58378 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Mon, 22 Feb 2016 15:07:03 -0800 Subject: [PATCH 7/8] Remove reset routes --- src/index.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.js b/src/index.js index 5a32be4ca1..72f08fa63c 100644 --- a/src/index.js +++ b/src/index.js @@ -13,7 +13,7 @@ var batch = require('./batch'), httpRequest = require('./httpRequest'); import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; -import passwordReset from './passwordReset'; +//import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; import SimpleMailgunAdapter from './Adapters/Email/SimpleMailgunAdapter'; import verifyEmail from './verifyEmail'; @@ -166,8 +166,8 @@ function ParseServer({ // File handling needs to be before default middlewares are applied api.use('/', new FilesRouter().getExpressRouter()); if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - api.use('/request_password_reset', passwordReset.reset(appName, appId)); - api.get('/password_reset_success', passwordReset.success); + //api.use('/request_password_reset', passwordReset.reset(appName, appId)); + //api.get('/password_reset_success', passwordReset.success); api.get('/verify_email', verifyEmail(appId, serverURL)); } From 0a982b7d83aa9230ffe069433fd0a6c793e3145e Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 23 Feb 2016 12:38:16 -0800 Subject: [PATCH 8/8] Add some tests and demonstrate the adapter loading interface --- spec/MockEmailAdapter.js | 3 + spec/MockEmailAdapterWithOptions.js | 8 ++ spec/index.spec.js | 111 +++++++++++++++++++++ src/Adapters/AdapterLoader.js | 7 +- src/Adapters/Email/SimpleMailgunAdapter.js | 7 +- src/Adapters/loadAdapter.js | 25 +++++ src/index.js | 50 +++++----- 7 files changed, 183 insertions(+), 28 deletions(-) create mode 100644 spec/MockEmailAdapter.js create mode 100644 spec/MockEmailAdapterWithOptions.js create mode 100644 src/Adapters/loadAdapter.js diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js new file mode 100644 index 0000000000..e06e27cb08 --- /dev/null +++ b/spec/MockEmailAdapter.js @@ -0,0 +1,3 @@ +module.exports = { + sendVerificationEmail: () => Promise.resolve(); +} diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js new file mode 100644 index 0000000000..fe402e0650 --- /dev/null +++ b/spec/MockEmailAdapterWithOptions.js @@ -0,0 +1,8 @@ +module.exports = options => { + if (!options) { + throw "Options were not provided" + } + return { + sendVerificationEmail: () => Promise.resolve() + } +} diff --git a/spec/index.spec.js b/spec/index.spec.js index 0e3eba5db8..c4280128c0 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -1,4 +1,5 @@ var request = require('request'); +var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions'); describe('server', () => { it('requires a master key and app id', done => { @@ -36,4 +37,114 @@ describe('server', () => { done(); }); }); + + it('can load email adapter via object', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: MockEmailAdapterWithOptions({ + apiKey: 'k', + domain: 'd', + }), + }); + done(); + }); + + it('can load email adapter via class', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + class: MockEmailAdapterWithOptions, + options: { + apiKey: 'k', + domain: 'd', + } + }, + }); + done(); + }); + + it('can load email adapter via module name', done => { + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + module: './Email/SimpleMailgunAdapter', + options: { + apiKey: 'k', + domain: 'd', + } + }, + }); + done(); + }); + + it('can load email adapter via only module name', done => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: './Email/SimpleMailgunAdapter', + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); + + it('throws if you initialize email adapter incorrecly', done => { + expect(() => setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'unused', + javascriptKey: 'test', + dotNetKey: 'windows', + clientKey: 'client', + restAPIKey: 'rest', + masterKey: 'test', + collectionPrefix: 'test_', + fileKey: 'test', + verifyUserEmails: true, + emailAdapter: { + module: './Email/SimpleMailgunAdapter', + options: { + domain: 'd', + } + }, + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); }); diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index cfe51ffd7e..1557324b31 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,4 +1,3 @@ - export function loadAdapter(options, defaultAdapter) { let adapter; @@ -12,7 +11,7 @@ export function loadAdapter(options, defaultAdapter) { adapter = options.adapter; } } - + if (!adapter) { adapter = defaultAdapter; } @@ -26,10 +25,12 @@ export function loadAdapter(options, defaultAdapter) { } } // From there it's either a function or an object - // if it's an function, instanciate and pass the options + // if it's an function, instanciate and pass the options if (typeof adapter === "function") { var Adapter = adapter; adapter = new Adapter(options); } return adapter; } + +module.exports = { loadAdapter } diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index 47c690968c..2d51173d84 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -1,6 +1,9 @@ import Mailgun from 'mailgun-js'; -export default (mailgunOptions) => { +let SimpleMailgunAdapter = mailgunOptions => { + if (!mailgunOptions || !mailgunOptions.apiKey || !mailgunOptions.domain) { + throw 'SimpleMailgunAdapter requires an API Key and domain.'; + } let mailgun = Mailgun(mailgunOptions); let sendMail = (to, subject, text) => { @@ -32,3 +35,5 @@ export default (mailgunOptions) => { } } } + +module.exports = SimpleMailgunAdapter diff --git a/src/Adapters/loadAdapter.js b/src/Adapters/loadAdapter.js new file mode 100644 index 0000000000..2ab7b35077 --- /dev/null +++ b/src/Adapters/loadAdapter.js @@ -0,0 +1,25 @@ +export default options => { + if (!options) { + return undefined; + } + + if (typeof options === 'string') { + //Configuring via module name with no options + return require(options)(); + } + + if (!options.module && !options.class) { + //Configuring via object + return options; + } + + if (options.module) { + //Configuring via module name + options + return require(options.module)(options.options) + } + + if (options.class) { + //Configuring via class + options + return options.class(options.options); + } +} diff --git a/src/index.js b/src/index.js index 72f08fa63c..e20fcbcbc0 100644 --- a/src/index.js +++ b/src/index.js @@ -15,8 +15,8 @@ var batch = require('./batch'), import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; -import SimpleMailgunAdapter from './Adapters/Email/SimpleMailgunAdapter'; import verifyEmail from './verifyEmail'; +import loadAdapter from './Adapters/loadAdapter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; @@ -26,7 +26,7 @@ import { FunctionsRouter } from './Routers/FunctionsRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { loadAdapter } from './Adapters/AdapterLoader'; +import AdapterLoader from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; import { LogsRouter } from './Routers/LogsRouter'; import { PushController } from './Controllers/PushController'; @@ -64,6 +64,20 @@ addParseCloud(); // "javascriptKey": optional key from Parse dashboard // "push": optional key from configure push +let validateEmailConfiguration = (verifyUserEmails, appName, emailAdapter) => { + if (verifyUserEmails) { + if (typeof appName !== 'string') { + throw 'An app name is required when using email verification.'; + } + if (!emailAdapter) { + throw 'User email verification was enabled, but no email adapter was provided'; + } + if (typeof emailAdapter.sendVerificationEmail !== 'function') { + throw 'Invalid email adapter: no sendVerificationEmail() function was provided'; + } + } +} + function ParseServer({ appId, appName, @@ -109,27 +123,11 @@ function ParseServer({ } } - const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); - const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); - const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); - // We pass the options and the base class for the adatper, // Note that passing an instance would work too - const filesController = new FilesController(filesControllerAdapter); - const pushController = new PushController(pushControllerAdapter); - const loggerController = new LoggerController(loggerControllerAdapter); - - if (verifyUserEmails) { - if (typeof appName !== 'string') { - throw 'An app name is required when using email verification.'; - } - if (!emailAdapter) { - throw 'User email verification was enabled, but no email adapter was provided'; - } - if (typeof emailAdapter.sendVerificationEmail !== 'function') { - throw 'Invalid email adapter: no sendVerificationEmail() function was provided'; - } - } + const filesController = new FilesController(AdapterLoader.loadAdapter(filesAdapter, GridStoreAdapter)); + const pushController = new PushController(AdapterLoader.loadAdapter(push, ParsePushAdapter)); + const loggerController = new LoggerController(AdapterLoader.loadAdapter(loggerAdapter, FileLoggerAdapter)); cache.apps[appId] = { masterKey: masterKey, @@ -145,11 +143,16 @@ function ParseServer({ loggerController: loggerController, enableAnonymousUsers: enableAnonymousUsers, oauth: oauth, - verifyUserEmails: verifyUserEmails, - emailAdapter: emailAdapter, appName: appName, }; + if (verifyUserEmails && process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { + emailAdapter = loadAdapter(emailAdapter); + validateEmailConfiguration(verifyUserEmails, appName, emailAdapter); + cache.apps[appId].verifyUserEmails = verifyUserEmails; + cache.apps[appId].emailAdapter = emailAdapter; + } + // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { cache.apps[appId]['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); @@ -267,5 +270,4 @@ function getClassName(parseClass) { module.exports = { ParseServer: ParseServer, S3Adapter: S3Adapter, - SimpleMailgunAdapter: SimpleMailgunAdapter, };