From 3ecaa0aa4bb7b284590404d9717653693beed9ff Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 15:24:45 -0500 Subject: [PATCH] Sends verification email upon set and update email - nits --- spec/OneSignalPushAdapter.spec.js | 4 +- spec/ValidationAndPasswordsReset.spec.js | 121 ++++++++++++++++++++++ src/Adapters/Logger/FileLoggerAdapter.js | 6 +- src/Adapters/Push/OneSignalPushAdapter.js | 2 +- src/Config.js | 19 ++-- src/Controllers/UserController.js | 40 +++---- src/RestWrite.js | 17 ++- src/Routers/UsersRouter.js | 18 ++-- src/index.js | 23 ++-- 9 files changed, 195 insertions(+), 55 deletions(-) diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index f3ae2cdb..a9b853d9 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -20,11 +20,11 @@ describe('OneSignalPushAdapter', () => { done(); }); - it('cannt be initialized if options are missing', (done) => { + it('cannot be initialized if options are missing', (done) => { expect(() => { new OneSignalPushAdapter(); - }).toThrow("Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); + }).toThrow("Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); done(); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index e5e07b34..0519b887 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -1,6 +1,40 @@ "use strict"; var request = require('request'); +var Config = require("../src/Config"); +describe("Custom Pages Configuration", () => { + it("should set the custom pages", (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', + customPages: { + invalidLink: "myInvalidLink", + verifyEmailSuccess: "myVerifyEmailSuccess", + choosePassword: "myChoosePassword", + passwordResetSuccess: "myPasswordResetSuccess" + }, + publicServerURL: "https://my.public.server.com/1" + }); + + var config = new Config("test"); + + expect(config.invalidLinkURL).toEqual("myInvalidLink"); + expect(config.verifyEmailSuccessURL).toEqual("myVerifyEmailSuccess"); + expect(config.choosePasswordURL).toEqual("myChoosePassword"); + expect(config.passwordResetSuccessURL).toEqual("myPasswordResetSuccess"); + expect(config.verifyEmailURL).toEqual("https://my.public.server.com/1/apps/test/verify_email"); + expect(config.requestResetPasswordURL).toEqual("https://my.public.server.com/1/apps/test/request_password_reset"); + done(); + }); +}); describe("Email Verification", () => { it('sends verification email if email verification is enabled', done => { @@ -27,6 +61,7 @@ describe("Email Verification", () => { var user = new Parse.User(); user.setPassword("asdf"); user.setUsername("zxcv"); + user.setEmail('cool_guy@parse.com'); user.signUp(null, { success: function(user) { expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); @@ -42,6 +77,92 @@ describe("Email Verification", () => { } }); }); + + it('does not send verification email when verification is enabled and email is not set', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => 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).not.toHaveBeenCalled(); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(undefined); + done(); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does send a validation email when updating the email', done => { + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => 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).not.toHaveBeenCalled(); + user.fetch() + .then((user) => { + user.set("email", "cool_guy@parse.com"); + return user.save(); + }).then((user) => { + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + return 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 = { diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js index 5c8bd495..3d3c192f 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -99,12 +99,8 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { } export class FileLoggerAdapter extends LoggerAdapter { - constructor(options) { + constructor(options = {}) { super(); - if (options && !options.logsFolder) { - throw "FileLoggerAdapter requires logsFolder"; - } - options = options || {}; this._logsFolder = options.logsFolder || LOGS_FOLDER; // check logs folder exists diff --git a/src/Adapters/Push/OneSignalPushAdapter.js b/src/Adapters/Push/OneSignalPushAdapter.js index ae5e8283..b92d00c5 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -20,7 +20,7 @@ export class OneSignalPushAdapter extends PushAdapter { this.OneSignalConfig = {}; const { oneSignalAppId, oneSignalApiKey } = pushConfig; if (!oneSignalAppId || !oneSignalApiKey) { - throw "Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; + throw "Trying to initialize OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; diff --git a/src/Config.js b/src/Config.js index 12059993..cfa53361 100644 --- a/src/Config.js +++ b/src/Config.js @@ -25,6 +25,7 @@ export class Config { this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); this.serverURL = cacheInfo.serverURL; + this.publicServerURL = cacheInfo.publicServerURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -34,32 +35,36 @@ export class Config { this.loggerController = cacheInfo.loggerController; this.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; - + this.customPages = cacheInfo.customPages || {}; this.mount = mount; } + get linksServerURL() { + return this.publicServerURL || this.serverURL; + } + get invalidLinkURL() { - return `${this.serverURL}/apps/invalid_link.html`; + return this.customPages.invalidLink || `${this.linksServerURL}/apps/invalid_link.html`; } get verifyEmailSuccessURL() { - return `${this.serverURL}/apps/verify_email_success.html`; + return this.customPages.verifyEmailSuccess || `${this.linksServerURL}/apps/verify_email_success.html`; } get choosePasswordURL() { - return `${this.serverURL}/apps/choose_password`; + return this.customPages.choosePassword || `${this.linksServerURL}/apps/choose_password`; } get requestResetPasswordURL() { - return `${this.serverURL}/apps/${this.applicationId}/request_password_reset`; + return `${this.linksServerURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { - return `${this.serverURL}/apps/password_reset_success.html`; + return this.customPages.passwordResetSuccess || `${this.linksServerURL}/apps/password_reset_success.html`; } get verifyEmailURL() { - return `${this.serverURL}/apps/${this.applicationId}/verify_email`; + return `${this.linksServerURL}/apps/${this.applicationId}/verify_email`; } }; diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 2abd7f49..786d118e 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -38,7 +38,7 @@ export class UserController extends AdaptableController { } - verifyEmail(username, token, config = this.config) { + verifyEmail(username, token) { return new Promise((resolve, reject) => { @@ -48,7 +48,7 @@ export class UserController extends AdaptableController { return; } - var database = config.database; + var database = this.config.database; database.collection('_User').then(coll => { // Need direct database access because verification token is not a parse field @@ -57,9 +57,9 @@ export class UserController extends AdaptableController { _email_verify_token: token, }, null, {$set: {emailVerified: true}}, (err, doc) => { if (err || !doc.value) { - reject(); + reject(err); } else { - resolve(); + resolve(doc.value); } }); }); @@ -67,9 +67,9 @@ export class UserController extends AdaptableController { }); } - checkResetTokenValidity(username, token, config = this.config) { + checkResetTokenValidity(username, token) { return new Promise((resolve, reject) => { - return config.database.collection('_User').then(coll => { + return this.config.database.collection('_User').then(coll => { return coll.findOne({ username: username, _perishable_token: token, @@ -85,7 +85,7 @@ export class UserController extends AdaptableController { } - sendVerificationEmail(user, config = this.config) { + sendVerificationEmail(user) { if (!this.shouldVerifyEmails) { return; } @@ -93,16 +93,16 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._email_verify_token); const username = encodeURIComponent(user.username); - let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; + let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; this.adapter.sendVerificationEmail({ - appName: config.appName, + appName: this.config.appName, link: link, user: inflate('_User', user), }); } - setPasswordResetToken(email, config = this.config) { - var database = config.database; + setPasswordResetToken(email) { + var database = this.config.database; var token = randomString(25); return new Promise((resolve, reject) => { return database.collection('_User').then(coll => { @@ -122,7 +122,7 @@ export class UserController extends AdaptableController { }); } - sendPasswordResetEmail(email, config = this.config) { + sendPasswordResetEmail(email) { if (!this.adapter) { throw "Trying to send a reset password but no adapter is set"; // TODO: No adapter? @@ -133,27 +133,21 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._perishable_token); const username = encodeURIComponent(user.username); - let link = `${config.requestResetPasswordURL}?token=${token}&username=${username}` + let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` this.adapter.sendPasswordResetEmail({ - appName: config.appName, + appName: this.config.appName, link: link, user: inflate('_User', user), }); return Promise.resolve(user); - }, (err) => { - return Promise.reject(err); }); } - updatePassword(username, token, password, config = this.config) { - return this.checkResetTokenValidity(username, token, config).then(() => { - return updateUserPassword(username, token, password, config); + updatePassword(username, token, password, config) { + return this.checkResetTokenValidity(username, token).then(() => { + return updateUserPassword(username, token, password, this.config); }); } - - sendMail(options) { - this.adapter.sendMail(options); - } } // Mark this private diff --git a/src/RestWrite.js b/src/RestWrite.js index 31d8f125..02815403 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -465,12 +465,18 @@ RestWrite.prototype.transformUser = function() { 'address'); } return Promise.resolve(); - }); + }).then(() => { + // We updated the email, send a new validation + this.storage['sendVerificationEmail'] = true; + this.config.userController.setEmailVerifyToken(this.data); + return Promise.resolve(); + }) }); }; // Handles any followup logic RestWrite.prototype.handleFollowup = function() { + if (this.storage && this.storage['clearSessions']) { var sessionQuery = { user: { @@ -480,9 +486,16 @@ RestWrite.prototype.handleFollowup = function() { } }; delete this.storage['clearSessions']; - return this.config.database.destroy('_Session', sessionQuery) + this.config.database.destroy('_Session', sessionQuery) .then(this.handleFollowup.bind(this)); } + + if (this.storage && this.storage['sendVerificationEmail']) { + delete this.storage['sendVerificationEmail']; + // Fire and forget! + this.config.userController.sendVerificationEmail(this.data); + this.handleFollowup.bind(this); + } }; // Handles the _Role class specialness. diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 2d63d701..21dc80ba 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -27,17 +27,17 @@ export class UsersRouter extends ClassesRouter { req.body = data; req.params.className = '_User'; - req.config.userController.setEmailVerifyToken(req.body); + //req.config.userController.setEmailVerifyToken(req.body); - let p = super.handleCreate(req); + return super.handleCreate(req); - if (req.config.verifyUserEmails) { - // Send email as fire-and-forget once the user makes it into the DB. - p.then(() => { - req.config.userController.sendVerificationEmail(req.body); - }); - } - return p; + // if (req.config.verifyUserEmails) { + // // Send email as fire-and-forget once the user makes it into the DB. + // p.then(() => { + // req.config.userController.sendVerificationEmail(req.body); + // }); + // } + // return p; } handleUpdate(req) { diff --git a/src/index.js b/src/index.js index 84ab3f55..1fa39aa7 100644 --- a/src/index.js +++ b/src/index.js @@ -107,6 +107,13 @@ function ParseServer({ maxUploadSize = '20mb', verifyUserEmails = false, emailAdapter, + publicServerURL, + customPages = { + invalidLink: undefined, + verifyEmailSuccess: undefined, + choosePassword: undefined, + passwordResetSuccess: undefined + }, }) { // Initialize the node client SDK automatically @@ -121,6 +128,12 @@ function ParseServer({ DatabaseAdapter.setAppDatabaseURI(appId, databaseURI); } + if (verifyUserEmails && !publicServerURL && !process.env.TESTING) { + console.warn(""); + console.warn("You should set publicServerURL to serve the public pages"); + console.warn(""); + } + if (cloud) { addParseCloud(); if (typeof cloud === 'function') { @@ -165,6 +178,8 @@ function ParseServer({ allowClientClassCreation: allowClientClassCreation, oauth: oauth, appName: appName, + publicServerURL: publicServerURL, + customPages: customPages, }); // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability @@ -181,12 +196,8 @@ function ParseServer({ maxUploadSize: maxUploadSize })); - if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - // need the body parser for the password reset - api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); - } - - + api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router);