From 8dc37b9d304b459a015aa5ab1f642c5e93e0d3e4 Mon Sep 17 00:00:00 2001 From: Drew Gross Date: Tue, 16 Feb 2016 23:43:09 -0800 Subject: [PATCH 1/9] Exploring the interface of a mail adapter Add some tests and demonstrate the adapter loading interface --- package.json | 1 + spec/MockEmailAdapter.js | 3 + spec/MockEmailAdapterWithOptions.js | 8 + spec/ParseUser.spec.js | 213 ++++++++++++++++++++- spec/index.spec.js | 111 +++++++++++ src/Adapters/AdapterLoader.js | 7 +- src/Adapters/Email/SimpleMailgunAdapter.js | 39 ++++ src/Adapters/loadAdapter.js | 25 +++ src/Config.js | 7 +- src/Routers/UsersRouter.js | 39 +++- src/index.js | 75 ++++++-- src/transform.js | 5 +- src/verifyEmail.js | 27 +++ 13 files changed, 524 insertions(+), 36 deletions(-) create mode 100644 spec/MockEmailAdapter.js create mode 100644 spec/MockEmailAdapterWithOptions.js create mode 100644 src/Adapters/Email/SimpleMailgunAdapter.js create mode 100644 src/Adapters/loadAdapter.js create mode 100644 src/verifyEmail.js diff --git a/package.json b/package.json index 9837376d53..560e8e99ef 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "commander": "^2.9.0", "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/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/ParseUser.spec.js b/spec/ParseUser.spec.js index a36b3cdcba..23d41fdd9c 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -49,6 +49,217 @@ 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(); + 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'); + done(); + } + }); + }); + + 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) { @@ -1704,7 +1915,7 @@ describe('Parse.User testing', () => { done(); }); }); - + it('test parse user become', (done) => { var sessionToken = null; Parse.Promise.as().then(function() { diff --git a/spec/index.spec.js b/spec/index.spec.js index 8b55808997..005b9c76e0 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 => { @@ -37,4 +38,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 new file mode 100644 index 0000000000..2d51173d84 --- /dev/null +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -0,0 +1,39 @@ +import Mailgun from 'mailgun-js'; + +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) => { + 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; + return sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); + } + } +} + +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/Config.js b/src/Config.js index 988efb1e11..2391a83168 100644 --- a/src/Config.js +++ b/src/Config.js @@ -23,9 +23,14 @@ export class Config { this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); + + this.verifyUserEmails = cacheInfo.verifyUserEmails; + this.emailAdapter = cacheInfo.emailAdapter; + this.appName = cacheInfo.appName; + this.hooksController = cacheInfo.hooksController; this.filesController = cacheInfo.filesController; - this.pushController = cacheInfo.pushController; + this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; this.oauth = cacheInfo.oauth; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4cba3edb0d..79dee41c26 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,14 +1,15 @@ // 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'; +let cryptoUtils = require('../cryptoUtils'); +let triggers = require('../triggers'); export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -25,7 +26,26 @@ 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); + req.body.emailVerified = false; + } + + 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: triggers.inflate('_User', req.body), + }); + }); + } + return p; } handleUpdate(req) { @@ -87,7 +107,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; @@ -153,6 +173,7 @@ export class UsersRouter extends ClassesRouter { 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 5062b6b03a..247a92748d 100644 --- a/src/index.js +++ b/src/index.js @@ -11,32 +11,34 @@ var batch = require('./batch'), Parse = require('parse/node').Parse; import cache from './cache'; -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 verifyEmail from './verifyEmail'; +import loadAdapter from './Adapters/loadAdapter'; 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 { HooksRouter } from './Routers/HooksRouter'; -import { loadAdapter } from './Adapters/AdapterLoader'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; -import { LoggerController } from './Controllers/LoggerController'; import { HooksController } from './Controllers/HooksController'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { AdapterLoader } from './Adapters/AdapterLoader'; +import { LoggerController } from './Controllers/LoggerController'; +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'; import requiredParameter from './requiredParameter'; import { randomString } from './cryptoUtils'; @@ -69,9 +71,24 @@ 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 = requiredParameter('You must provide an appId!'), masterKey = requiredParameter('You must provide a masterKey!'), + appName, databaseAdapter, filesAdapter, push, @@ -89,7 +106,9 @@ function ParseServer({ allowClientClassCreation = true, oauth = {}, serverURL = requiredParameter('You must provide a serverURL!'), - maxUploadSize = '20mb' + maxUploadSize = '20mb', + verifyUserEmails = false, + emailAdapter, }) { // Initialize the node client SDK automatically @@ -141,10 +160,18 @@ function ParseServer({ hooksController: hooksController, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, - oauth: oauth + oauth: oauth, + appName: appName, }); - // To maintain compatibility. TODO: Remove in v2.1 + 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.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } @@ -158,6 +185,12 @@ function ParseServer({ maxUploadSize: maxUploadSize })); + 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) { api.use('/', require('./testing-routes').router); @@ -222,5 +255,5 @@ function addParseCloud() { module.exports = { ParseServer: ParseServer, - S3Adapter: S3Adapter + S3Adapter: S3Adapter, }; diff --git a/src/transform.js b/src/transform.js index f254f0d464..7ff570c063 100644 --- a/src/transform.js +++ b/src/transform.js @@ -42,6 +42,9 @@ export function transformKeyValue(schema, className, restKey, restValue, options key = '_updated_at'; timeField = true; break; + case '_email_verify_token': + key = "_email_verify_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; @@ -649,7 +652,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..5bd1da3269 --- /dev/null +++ b/src/verifyEmail.js @@ -0,0 +1,27 @@ +function verifyEmail(appId, serverURL) { + var DatabaseAdapter = require('./DatabaseAdapter'); + var database = DatabaseAdapter.getDatabaseConnection(appId); + return (req, res) => { + var token = req.query.token; + var username = req.query.username; + 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); + } + }); + }); + } +} + +module.exports = verifyEmail; From 0b307bc22f19b8eca614f0dd8c760c32b5011133 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Tue, 23 Feb 2016 21:05:27 -0500 Subject: [PATCH 2/9] Improves AdapterLoader, enforces configuraiton on Adapters --- spec/AdapterLoader.spec.js | 27 ++++++++-- spec/MockAdapter.js | 6 ++- spec/MockEmailAdapterWithOptions.js | 3 +- spec/OneSignalPushAdapter.spec.js | 33 ++++++++----- spec/ParseUser.spec.js | 15 ++++-- src/Adapters/AdapterLoader.js | 57 ++++++++++++---------- src/Adapters/Email/MailAdapter.js | 6 +++ src/Adapters/Email/SimpleMailgunAdapter.js | 15 ++++-- src/Adapters/Files/S3Adapter.js | 31 +++++++++--- src/Adapters/Logger/FileLoggerAdapter.js | 7 ++- src/Adapters/Push/OneSignalPushAdapter.js | 4 ++ src/Adapters/loadAdapter.js | 25 ---------- src/Config.js | 2 +- src/Controllers/AdaptableController.js | 1 - src/Controllers/MailController.js | 29 +++++++++++ src/Routers/UsersRouter.js | 10 +--- src/index.js | 14 ++---- 17 files changed, 176 insertions(+), 109 deletions(-) create mode 100644 src/Adapters/Email/MailAdapter.js delete mode 100644 src/Adapters/loadAdapter.js create mode 100644 src/Controllers/MailController.js diff --git a/spec/AdapterLoader.spec.js b/spec/AdapterLoader.spec.js index 80f30d6fed..f32867e0bc 100644 --- a/spec/AdapterLoader.spec.js +++ b/spec/AdapterLoader.spec.js @@ -2,15 +2,17 @@ var loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter; var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; -describe("AdaptableController", ()=>{ +describe("AdapterLoader", ()=>{ it("should instantiate an adapter from string in object", (done) => { var adapterPath = require('path').resolve("./spec/MockAdapter"); var adapter = loadAdapter({ adapter: adapterPath, - key: "value", - foo: "bar" + options: { + key: "value", + foo: "bar" + } }); expect(adapter instanceof Object).toBe(true); @@ -24,7 +26,6 @@ describe("AdaptableController", ()=>{ var adapter = loadAdapter(adapterPath); expect(adapter instanceof Object).toBe(true); - expect(adapter.options).toBe(adapterPath); done(); }); @@ -65,4 +66,22 @@ describe("AdaptableController", ()=>{ expect(adapter).toBe(originalAdapter); done(); }); + + it("should fail loading an improperly configured adapter", (done) => { + var Adapter = function(options) { + if (!options.foo) { + throw "foo is required for that adapter"; + } + } + var adapterOptions = { + param: "key", + doSomething: function() {} + }; + + expect(() => { + var adapter = loadAdapter(adapterOptions, Adapter); + expect(adapter).toEqual(adapterOptions); + }).not.toThrow("foo is required for that adapter"); + done(); + }); }); diff --git a/spec/MockAdapter.js b/spec/MockAdapter.js index 60d8ef8686..c3f557849d 100644 --- a/spec/MockAdapter.js +++ b/spec/MockAdapter.js @@ -1,3 +1,5 @@ module.exports = function(options) { - this.options = options; -} + return { + options: options + }; +}; diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js index fe402e0650..d5b6141afc 100644 --- a/spec/MockEmailAdapterWithOptions.js +++ b/spec/MockEmailAdapterWithOptions.js @@ -3,6 +3,7 @@ module.exports = options => { throw "Options were not provided" } return { - sendVerificationEmail: () => Promise.resolve() + sendVerificationEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } } diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index 2c165c4588..f3ae2cdb84 100644 --- a/spec/OneSignalPushAdapter.spec.js +++ b/spec/OneSignalPushAdapter.spec.js @@ -1,13 +1,15 @@ var OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter'); var classifyInstallations = require('../src/Adapters/Push/PushAdapterUtils').classifyInstallations; + +// Make mock config +var pushConfig = { + oneSignalAppId:"APP ID", + oneSignalApiKey:"API KEY" +}; + describe('OneSignalPushAdapter', () => { it('can be initialized', (done) => { - // Make mock config - var pushConfig = { - oneSignalAppId:"APP ID", - oneSignalApiKey:"API KEY" - }; var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); @@ -17,9 +19,17 @@ describe('OneSignalPushAdapter', () => { expect(senderMap.android instanceof Function).toBe(true); done(); }); + + it('cannt be initialized if options are missing', (done) => { + + expect(() => { + new OneSignalPushAdapter(); + }).toThrow("Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"); + done(); + }); it('can get valid push types', (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); expect(oneSignalPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); done(); @@ -56,7 +66,7 @@ describe('OneSignalPushAdapter', () => { it('can send push notifications', (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); // Mock android ios senders var androidSender = jasmine.createSpy('send') @@ -108,7 +118,7 @@ describe('OneSignalPushAdapter', () => { }); it("can send iOS notifications", (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; @@ -135,7 +145,7 @@ describe('OneSignalPushAdapter', () => { }); it("can send Android notifications", (done) => { - var oneSignalPushAdapter = new OneSignalPushAdapter(); + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; @@ -157,10 +167,7 @@ describe('OneSignalPushAdapter', () => { }); it("can post the correct data", (done) => { - var pushConfig = { - oneSignalAppId:"APP ID", - oneSignalApiKey:"API KEY" - }; + var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var write = jasmine.createSpy('write'); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 23d41fdd9c..64477074ae 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -51,7 +51,8 @@ describe('Parse.User testing', () => { it('sends verification email if email verification is enabled', done => { var emailAdapter = { - sendVerificationEmail: () => Promise.resolve() + sendVerificationEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -89,7 +90,8 @@ describe('Parse.User testing', () => { it('does not send verification email if email verification is disabled', done => { var emailAdapter = { - sendVerificationEmail: () => Promise.resolve() + sendVerificationEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -131,7 +133,8 @@ describe('Parse.User testing', () => { expect(options.appName).toEqual('emailing app'); expect(options.user.get('email')).toEqual('user@parse.com'); done(); - } + }, + sendMail: () => {} } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -175,7 +178,8 @@ describe('Parse.User testing', () => { done(); }); }); - } + }, + sendMail: () => {} } setServerConfiguration({ serverURL: 'http://localhost:8378/1', @@ -232,7 +236,8 @@ describe('Parse.User testing', () => { done(); }); }); - } + }, + sendMail: () => {} } setServerConfiguration({ serverURL: 'http://localhost:8378/1', diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index 1557324b31..5b46f22d2d 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,36 +1,43 @@ -export function loadAdapter(options, defaultAdapter) { - let adapter; +export function loadAdapter(adapter, defaultAdapter, options) { - // We have options and options have adapter key - if (options) { - // Pass an adapter as a module name, a function or an instance - if (typeof options == "string" || typeof options == "function" || options.constructor != Object) { - adapter = options; + if (!adapter) + { + if (!defaultAdapter) { + return options; } - if (options.adapter) { - adapter = options.adapter; + // Load from the default adapter when no adapter is set + return loadAdapter(defaultAdapter, undefined, options); + } else if (typeof adapter === "function") { + try { + return adapter(options); + } catch(e) { + var Adapter = adapter; + return new Adapter(options); } - } - - if (!adapter) { - adapter = defaultAdapter; - } - - // This is a string, require the module - if (typeof adapter === "string") { + } else if (typeof adapter === "string") { adapter = require(adapter); // If it's define as a module, get the default if (adapter.default) { adapter = adapter.default; } + + return loadAdapter(adapter, undefined, options); + } else if (adapter.module) { + return loadAdapter(adapter.module, undefined, adapter.options); + } else if (adapter.class) { + return loadAdapter(adapter.class, undefined, adapter.options); + } else if (adapter.adapter) { + return loadAdapter(adapter.adapter, undefined, adapter.options); + } else { + // Try to load the defaultAdapter with the options + // The default adapter should throw if the options are + // incompatible + try { + return loadAdapter(defaultAdapter, undefined, adapter); + } catch (e) {}; } - // From there it's either a function or an object - // if it's an function, instanciate and pass the options - if (typeof adapter === "function") { - var Adapter = adapter; - adapter = new Adapter(options); - } - return adapter; + // return the adapter as is as it's unusable otherwise + return adapter; } -module.exports = { loadAdapter } +export default loadAdapter; diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js new file mode 100644 index 0000000000..ceccf931c7 --- /dev/null +++ b/src/Adapters/Email/MailAdapter.js @@ -0,0 +1,6 @@ +export class MailAdapter { + sendVerificationEmail(options) {} + sendMail(options) {} +} + +export default MailAdapter; diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index 2d51173d84..f2460182a4 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -6,7 +6,7 @@ let SimpleMailgunAdapter = mailgunOptions => { } let mailgun = Mailgun(mailgunOptions); - let sendMail = (to, subject, text) => { + let sendMail = ({to, subject, text}) => { let data = { from: mailgunOptions.fromAddress, to: to, @@ -24,16 +24,21 @@ let SimpleMailgunAdapter = mailgunOptions => { }); } - return { + return Object.freeze({ 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; - return sendMail(user.email, 'Please verify your e-mail for ' + appName, verifyMessage); - } - } + return sendMail({ + to:user.email, + subject: 'Please verify your e-mail for ' + appName, + text: verifyMessage + }); + }, + sendMail: sendMail + }); } module.exports = SimpleMailgunAdapter diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index 0732fbfeb9..d63880f461 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -4,23 +4,38 @@ import * as AWS from 'aws-sdk'; import { FilesAdapter } from './FilesAdapter'; +import requiredParameter from '../../requiredParameter'; const DEFAULT_S3_REGION = "us-east-1"; +function parseS3AdapterOptions(...options) { + if (options.length === 1 && typeof options[0] == "object") { + return options; + } + + const additionalOptions = options[3] || {}; + + return { + accessKey: options[0], + secretKey: options[1], + bucket: options[2], + region: additionalOptions.region + } +} + export class S3Adapter extends FilesAdapter { // Creates an S3 session. // Providing AWS access and secret keys is mandatory // Region and bucket will use sane defaults if omitted constructor( - accessKey, - secretKey, - bucket, - { region = DEFAULT_S3_REGION, - bucketPrefix = '', - directAccess = false } = {} - ) { + accessKey = requiredParameter('S3Adapter requires an accessKey'), + secretKey = requiredParameter('S3Adapter requires a secretKey'), + bucket, + { region = DEFAULT_S3_REGION, + bucketPrefix = '', + directAccess = false } = {}) { super(); - + this._region = region; this._bucket = bucket; this._bucketPrefix = bucketPrefix; diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js index 9e3082424d..5c8bd49509 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -99,9 +99,12 @@ 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 fe2fcc0b84..ae5e828374 100644 --- a/src/Adapters/Push/OneSignalPushAdapter.js +++ b/src/Adapters/Push/OneSignalPushAdapter.js @@ -18,6 +18,10 @@ export class OneSignalPushAdapter extends PushAdapter { this.validPushTypes = ['ios', 'android']; this.senderMap = {}; this.OneSignalConfig = {}; + const { oneSignalAppId, oneSignalApiKey } = pushConfig; + if (!oneSignalAppId || !oneSignalApiKey) { + throw "Trying to initialiazed OneSignalPushAdapter without oneSignalAppId or oneSignalApiKey"; + } this.OneSignalConfig['appId'] = pushConfig['oneSignalAppId']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; diff --git a/src/Adapters/loadAdapter.js b/src/Adapters/loadAdapter.js deleted file mode 100644 index 2ab7b35077..0000000000 --- a/src/Adapters/loadAdapter.js +++ /dev/null @@ -1,25 +0,0 @@ -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/Config.js b/src/Config.js index 2391a83168..1203b0a334 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,8 +24,8 @@ export class Config { this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); + this.mailController = cacheInfo.mailController; this.verifyUserEmails = cacheInfo.verifyUserEmails; - this.emailAdapter = cacheInfo.emailAdapter; this.appName = cacheInfo.appName; this.hooksController = cacheInfo.hooksController; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index ef45b0225f..83f3f0a08c 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -31,7 +31,6 @@ export class AdaptableController { } validateAdapter(adapter) { - if (!adapter) { throw new Error(this.constructor.name+" requires an adapter"); } diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js new file mode 100644 index 0000000000..ee467fe6c1 --- /dev/null +++ b/src/Controllers/MailController.js @@ -0,0 +1,29 @@ +import AdaptableController from './AdaptableController'; +import { MailAdapter } from '../Adapters/Email/MailAdapter'; +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; + +export class MailController extends AdaptableController { + setEmailVerificationStatus(user, status) { + if (status == false) { + user._email_verify_token = randomString(25); + } + user.emailVerified = status; + } + sendVerificationEmail(user, config) { + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + let link = `${config.mount}/verify_email?token=${token}&username=${username}`; + this.adapter.sendVerificationEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + sendMail(options) { + this.adapter.sendMail(options); + } + expectedAdapterType() { + return MailAdapter; + } +} diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 79dee41c26..1e329734d1 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -28,8 +28,7 @@ export class UsersRouter extends ClassesRouter { req.params.className = '_User'; if (req.config.verifyUserEmails) { - req.body._email_verify_token = cryptoUtils.randomString(25); - req.body.emailVerified = false; + req.config.mailController.setEmailVerificationStatus(req.body, false); } let p = super.handleCreate(req); @@ -37,12 +36,7 @@ export class UsersRouter extends ClassesRouter { 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: triggers.inflate('_User', req.body), - }); + req.config.mailController.sendVerificationEmail(req.body, req.config); }); } return p; diff --git a/src/index.js b/src/index.js index 247a92748d..74a63bf994 100644 --- a/src/index.js +++ b/src/index.js @@ -16,11 +16,11 @@ import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; 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'; import { FilesController } from './Controllers/FilesController'; +import { MailController } from './Controllers/MailController'; import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; @@ -30,7 +30,7 @@ import { HooksRouter } from './Routers/HooksRouter'; import { HooksController } from './Controllers/HooksController'; import { InstallationsRouter } from './Routers/InstallationsRouter'; -import { AdapterLoader } from './Adapters/AdapterLoader'; +import { loadAdapter } from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; import { PushController } from './Controllers/PushController'; import { PushRouter } from './Routers/PushRouter'; @@ -79,9 +79,6 @@ let validateEmailConfiguration = (verifyUserEmails, appName, emailAdapter) => { 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'; - } } } @@ -164,11 +161,10 @@ function ParseServer({ appName: appName, }); - if (verifyUserEmails && process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - emailAdapter = loadAdapter(emailAdapter); - validateEmailConfiguration(verifyUserEmails, appName, emailAdapter); + if (verifyUserEmails && (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1)) { + let mailController = new MailController(loadAdapter(emailAdapter)); + cache.apps[appId].mailController = mailController; cache.apps[appId].verifyUserEmails = verifyUserEmails; - cache.apps[appId].emailAdapter = emailAdapter; } // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability From 7dd765256c0d479d9a0ca9dda726726d9a1572b1 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Thu, 25 Feb 2016 19:04:27 -0500 Subject: [PATCH 3/9] Refactors verify_email, adds public html --- public_html/choose_password.html | 175 ++++++++++++++++++++++++ public_html/invalid_link.html | 43 ++++++ public_html/password_reset_success.html | 27 ++++ public_html/verify_email_success.html | 27 ++++ spec/ParseUser.spec.js | 14 +- src/Config.js | 25 +++- src/Controllers/AdaptableController.js | 8 +- src/Controllers/MailController.js | 3 +- src/Controllers/UserController.js | 32 +++++ src/PromiseRouter.js | 37 ++++- src/Routers/PublicAPIRouter.js | 48 +++++++ src/Routers/UsersRouter.js | 8 +- src/index.js | 44 +++--- src/verifyEmail.js | 27 ---- 14 files changed, 455 insertions(+), 63 deletions(-) create mode 100644 public_html/choose_password.html create mode 100644 public_html/invalid_link.html create mode 100644 public_html/password_reset_success.html create mode 100644 public_html/verify_email_success.html create mode 100644 src/Controllers/UserController.js create mode 100644 src/Routers/PublicAPIRouter.js delete mode 100644 src/verifyEmail.js diff --git a/public_html/choose_password.html b/public_html/choose_password.html new file mode 100644 index 0000000000..b487862a22 --- /dev/null +++ b/public_html/choose_password.html @@ -0,0 +1,175 @@ + + + + + Password Reset + + + +

Reset Your Password

+ +
+
+ + + + + + +
+ + + diff --git a/public_html/invalid_link.html b/public_html/invalid_link.html new file mode 100644 index 0000000000..66bdc788fb --- /dev/null +++ b/public_html/invalid_link.html @@ -0,0 +1,43 @@ + + + + + Invalid Link + + +
+

Invalid Link

+
+ + diff --git a/public_html/password_reset_success.html b/public_html/password_reset_success.html new file mode 100644 index 0000000000..774cbb350c --- /dev/null +++ b/public_html/password_reset_success.html @@ -0,0 +1,27 @@ + + + + + Password Reset + + +

Successfully updated your password!

+ + diff --git a/public_html/verify_email_success.html b/public_html/verify_email_success.html new file mode 100644 index 0000000000..774ea38a0d --- /dev/null +++ b/public_html/verify_email_success.html @@ -0,0 +1,27 @@ + + + + + Email Verification + + +

Successfully verified your email!

+ + diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 64477074ae..475622cf3d 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -171,7 +171,7 @@ describe('Parse.User testing', () => { 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'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/verify_email_success.html?username=zxcv'); user.fetch() .then(() => { expect(user.get('emailVerified')).toEqual(true); @@ -202,21 +202,21 @@ describe('Parse.User testing', () => { }); it('redirects you to invalid link if you try to verify email incorrecly', done => { - request.get('http://localhost:8378/1/verify_email', { + request.get('http://localhost:8378/1/apps/test/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'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/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', { + request.get('http://localhost:8378/1/apps/test/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'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); done(); }); }); @@ -225,11 +225,11 @@ describe('Parse.User testing', () => { var user = new Parse.User(); var emailAdapter = { sendVerificationEmail: options => { - request.get('http://localhost:8378/1/verify_email?token=invalid&username=zxcv', { + request.get('http://localhost:8378/1/apps/test/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'); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/invalid_link.html'); user.fetch() .then(() => { expect(user.get('emailVerified')).toEqual(false); diff --git a/src/Config.js b/src/Config.js index 1203b0a334..c31f62eb68 100644 --- a/src/Config.js +++ b/src/Config.js @@ -25,6 +25,8 @@ export class Config { this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); this.mailController = cacheInfo.mailController; + + this.serverURL = cacheInfo.serverURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -32,11 +34,32 @@ export class Config { this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; + this.mailController = cacheInfo.mailController; this.oauth = cacheInfo.oauth; this.mount = mount; } -} + + get invalidLinkURL() { + return `${this.serverURL}/apps/invalid_link.html`; + } + + get verifyEmailSuccessURL() { + return `${this.serverURL}/apps/verify_email_success.html`; + } + + get choosePasswordURL() { + return `${this.serverURL}/apps/choose_password`; + } + + get passwordResetSuccessURL() { + return `${this.serverURL}/apps/password_reset_success.html`; + } + + get verifyEmailURL() { + return `${this.serverURL}/apps/${this.applicationId}/verify_email`; + } +}; export default Config; module.exports = Config; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index 83f3f0a08c..cfb0b9afa1 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,11 +10,13 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); +import cache from '../cache'; export class AdaptableController { - constructor(adapter) { + constructor(adapter, appId) { this.adapter = adapter; + this.appId = appId; } set adapter(adapter) { @@ -26,6 +28,10 @@ export class AdaptableController { return this[_adapter]; } + get config() { + return cache.apps[this.appId]; + } + expectedAdapterType() { throw new Error("Subclasses should implement expectedAdapterType()"); } diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js index ee467fe6c1..47d008cce8 100644 --- a/src/Controllers/MailController.js +++ b/src/Controllers/MailController.js @@ -13,7 +13,8 @@ export class MailController extends AdaptableController { sendVerificationEmail(user, config) { const token = encodeURIComponent(user._email_verify_token); const username = encodeURIComponent(user.username); - let link = `${config.mount}/verify_email?token=${token}&username=${username}`; + + let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; this.adapter.sendVerificationEmail({ appName: config.appName, link: link, diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js new file mode 100644 index 0000000000..62d6dd39d2 --- /dev/null +++ b/src/Controllers/UserController.js @@ -0,0 +1,32 @@ + +var DatabaseAdapter = require('../DatabaseAdapter'); + +export class UserController { + + constructor(appId) { + this.appId = appId; + } + + verifyEmail(username, token) { + var database = DatabaseAdapter.getDatabaseConnection(this.appId); + return new Promise((resolve, reject) => { + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); + }); + + }); + + } +} + +export default UserController; diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index 8155c7967b..c3ca10ec61 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -5,6 +5,8 @@ // themselves use our routing information, without disturbing express // components that external developers may be modifying. +import express from 'express'; + export default class PromiseRouter { // Each entry should be an object with: // path: the path to route, in express format @@ -15,8 +17,8 @@ export default class PromiseRouter { // status: optional. the http status code. defaults to 200 // response: a json object with the content of the response // location: optional. a location header - constructor() { - this.routes = []; + constructor(routes = []) { + this.routes = routes; this.mountRoutes(); } @@ -125,6 +127,29 @@ export default class PromiseRouter { } } }; + + expressApp() { + var expressApp = express(); + for (var route of this.routes) { + switch(route.method) { + case 'POST': + expressApp.post(route.path, makeExpressHandler(route.handler)); + break; + case 'GET': + expressApp.get(route.path, makeExpressHandler(route.handler)); + break; + case 'PUT': + expressApp.put(route.path, makeExpressHandler(route.handler)); + break; + case 'DELETE': + expressApp.delete(route.path, makeExpressHandler(route.handler)); + break; + default: + throw 'unexpected code branch'; + } + } + return expressApp; + } } // Global flag. Set this to true to log every request and response. @@ -142,15 +167,19 @@ function makeExpressHandler(promiseHandler) { JSON.stringify(req.body, null, 2)); } promiseHandler(req).then((result) => { - if (!result.response) { - console.log('BUG: the handler did not include a "response" field'); + if (!result.response && !result.location) { + console.log('BUG: the handler did not include a "response" or a "location" field'); throw 'control should not get here'; } if (PromiseRouter.verbose) { console.log('response:', JSON.stringify(result.response, null, 2)); } + var status = result.status || 200; res.status(status); + if (result.location && !result.response) { + return res.redirect(result.location); + } if (result.location) { res.set('Location', result.location); } diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js new file mode 100644 index 0000000000..2b75d3f539 --- /dev/null +++ b/src/Routers/PublicAPIRouter.js @@ -0,0 +1,48 @@ +import PromiseRouter from '../PromiseRouter'; +import UserController from '../Controllers/UserController'; +import Config from '../Config'; +import express from 'express'; +import path from 'path'; + +export class PublicAPIRouter extends PromiseRouter { + + verifyEmail(req) { + var token = req.query.token; + var username = req.query.username; + var appId = req.params.appId; + var config = new Config(appId); + + if (!token || !username) { + return Promise.resolve({ + status: 302, + location: config.invalidLinkURL + }); + } + + let userController = new UserController(appId); + return userController.verifyEmail(username, token, appId).then( () => { + return Promise.resolve({ + status: 302, + location: `${config.verifyEmailSuccessURL}?username=${username}` + }); + }, ()=> { + return Promise.resolve({ + status: 302, + location: config.invalidLinkURL + }); + }) + } + + mountRoutes() { + this.route('GET','/apps/:appId/verify_email', req => { return this.verifyEmail(req); }); + } + + expressApp() { + var router = express(); + router.use("/apps", express.static(path.resolve(__dirname, "../../public"))); + router.use(super.expressApp()); + return router; + } +} + +export default PublicAPIRouter; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 1e329734d1..70a76bf5f9 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -154,6 +154,11 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } + + handleReset(req) { + let userController = req.config.userController; + return userController.requestPasswordReset(); + } mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -164,9 +169,6 @@ 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 74a63bf994..219ec15681 100644 --- a/src/index.js +++ b/src/index.js @@ -15,7 +15,6 @@ import cache from './cache'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; import PromiseRouter from './PromiseRouter'; -import verifyEmail from './verifyEmail'; import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; @@ -27,8 +26,10 @@ import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { LogsRouter } from './Routers/LogsRouter'; import { HooksRouter } from './Routers/HooksRouter'; +import { PublicAPIRouter } from './Routers/PublicAPIRouter'; import { HooksController } from './Controllers/HooksController'; +import { UserController } from './Controllers/UserController'; import { InstallationsRouter } from './Routers/InstallationsRouter'; import { loadAdapter } from './Adapters/AdapterLoader'; import { LoggerController } from './Controllers/LoggerController'; @@ -134,16 +135,23 @@ function ParseServer({ const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); - + const emailControllerAdapter = loadAdapter(emailAdapter); // 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); + const filesController = new FilesController(filesControllerAdapter, appId); + const pushController = new PushController(pushControllerAdapter, appId); + const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); + const userController = new UserController(appId); + let mailController; + + if (verifyUserEmails) { + mailController = new MailController(loadAdapter(emailAdapter)); + } cache.apps.set(appId, { masterKey: masterKey, + serverURL: serverURL, collectionPrefix: collectionPrefix, clientKey: clientKey, javascriptKey: javascriptKey, @@ -155,18 +163,14 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, + mailController: mailController, + verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, oauth: oauth, appName: appName, }); - if (verifyUserEmails && (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1)) { - let mailController = new MailController(loadAdapter(emailAdapter)); - cache.apps[appId].mailController = mailController; - cache.apps[appId].verifyUserEmails = verifyUserEmails; - } - // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); @@ -175,18 +179,17 @@ function ParseServer({ // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. var api = express(); - + //api.use("/apps", express.static(__dirname + "/public")); // File handling needs to be before default middlewares are applied api.use('/', new FilesRouter().getExpressRouter({ maxUploadSize: maxUploadSize })); 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)); + api.use('/', new PublicAPIRouter().expressApp()); } + // TODO: separate this from the regular ParseServer object if (process.env.TESTING == 1) { api.use('/', require('./testing-routes').router); @@ -218,13 +221,16 @@ function ParseServer({ if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { routers.push(new HooksRouter()); } + + let routes = routers.reduce((memo, router) => { + return memo.concat(router.routes); + }, []); - let appRouter = new PromiseRouter(); - routers.forEach((router) => { - appRouter.merge(router); - }); + let appRouter = new PromiseRouter(routes); + batch.mountOnto(appRouter); + api.use(appRouter.expressApp()); appRouter.mountOnto(api); api.use(middlewares.handleParseErrors); diff --git a/src/verifyEmail.js b/src/verifyEmail.js deleted file mode 100644 index 5bd1da3269..0000000000 --- a/src/verifyEmail.js +++ /dev/null @@ -1,27 +0,0 @@ -function verifyEmail(appId, serverURL) { - var DatabaseAdapter = require('./DatabaseAdapter'); - var database = DatabaseAdapter.getDatabaseConnection(appId); - return (req, res) => { - var token = req.query.token; - var username = req.query.username; - 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); - } - }); - }); - } -} - -module.exports = verifyEmail; From f3bb2c99e0250c0ae2e1dce457b581c7f2f6ff31 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 10:51:12 -0500 Subject: [PATCH 4/9] Refactor and advancements - Drops mailController, centralized in UserController - Adds views folder for change_password - Improves PromiseRouter to support text results - Improves PromiseRouter to support empty responses for redirects - Adds options to AdaptableController - UsersController gracefully fails when no adapter is set - Refactors GlobalConfig into same style for Routers --- spec/MockEmailAdapter.js | 4 +- spec/MockEmailAdapterWithOptions.js | 1 + spec/ParseGlobalConfig.spec.js | 10 +- spec/ParseUser.spec.js | 14 ++ spec/PublicAPI.spec.js | 36 +++++ src/Adapters/Email/MailAdapter.js | 1 + src/Adapters/Email/SimpleMailgunAdapter.js | 13 ++ src/Config.js | 10 +- src/Controllers/AdaptableController.js | 7 +- src/Controllers/MailController.js | 30 ---- src/Controllers/UserController.js | 141 ++++++++++++++++-- src/PromiseRouter.js | 9 +- src/Routers/GlobalConfigRouter.js | 48 ++++++ src/Routers/PublicAPIRouter.js | 64 ++++++-- src/Routers/UsersRouter.js | 29 ++-- src/global_config.js | 46 ------ src/index.js | 15 +- .../choose_password | 3 +- 18 files changed, 346 insertions(+), 135 deletions(-) create mode 100644 spec/PublicAPI.spec.js delete mode 100644 src/Controllers/MailController.js create mode 100644 src/Routers/GlobalConfigRouter.js delete mode 100644 src/global_config.js rename public_html/choose_password.html => views/choose_password (97%) diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js index e06e27cb08..b143e37e6e 100644 --- a/spec/MockEmailAdapter.js +++ b/spec/MockEmailAdapter.js @@ -1,3 +1,5 @@ module.exports = { - sendVerificationEmail: () => Promise.resolve(); + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js index d5b6141afc..8a3095e21f 100644 --- a/spec/MockEmailAdapterWithOptions.js +++ b/spec/MockEmailAdapterWithOptions.js @@ -4,6 +4,7 @@ module.exports = options => { } return { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } } diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 8c29ee4835..8b739a785a 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -2,13 +2,12 @@ var request = require('request'); var Parse = require('parse/node').Parse; -var DatabaseAdapter = require('../src/DatabaseAdapter'); - -let database = DatabaseAdapter.getDatabaseConnection('test', 'test_'); +let Config = require('../src/Config'); describe('a GlobalConfig', () => { beforeEach(function(done) { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true })) .then(done()); }); @@ -61,7 +60,8 @@ describe('a GlobalConfig', () => { }); it('failed getting config when it is missing', (done) => { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) .then(_ => { request.get({ diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 475622cf3d..8698fa3688 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -52,6 +52,7 @@ describe('Parse.User testing', () => { it('sends verification email if email verification is enabled', done => { var emailAdapter = { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } setServerConfiguration({ @@ -91,6 +92,7 @@ describe('Parse.User testing', () => { it('does not send verification email if email verification is disabled', done => { var emailAdapter = { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } setServerConfiguration({ @@ -134,6 +136,7 @@ describe('Parse.User testing', () => { expect(options.user.get('email')).toEqual('user@parse.com'); done(); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -176,9 +179,14 @@ describe('Parse.User testing', () => { .then(() => { expect(user.get('emailVerified')).toEqual(true); done(); + }, (err) => { + console.error(err); + fail("this should not fail"); + done(); }); }); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -237,6 +245,7 @@ describe('Parse.User testing', () => { }); }); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -270,6 +279,11 @@ describe('Parse.User testing', () => { success: function(user) { Parse.User.logIn("non_existent_user", "asdf3", expectError(Parse.Error.OBJECT_NOT_FOUND, done)); + }, + error: function(err) { + console.error(err); + fail("Shit should not fail"); + done(); } }); }); diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js new file mode 100644 index 0000000000..a61537d0ec --- /dev/null +++ b/spec/PublicAPI.spec.js @@ -0,0 +1,36 @@ + +var request = require('request'); + + +describe("public API", () => { + + it("should get invalid_link.html", (done) => { + request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get verify_email_success.html", (done) => { + request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get password_reset_success.html", (done) => { + request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + +}) \ No newline at end of file diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index ceccf931c7..ab8f15715d 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,5 +1,6 @@ export class MailAdapter { sendVerificationEmail(options) {} + sendPasswordResetEmail(options) {} sendMail(options) {} } diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index f2460182a4..6720962f5d 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -37,6 +37,19 @@ let SimpleMailgunAdapter = mailgunOptions => { text: verifyMessage }); }, + + sendPasswordResetEmail: ({link,user, appName}) => { + let message = + "Hi,\n\n" + + "You requested to reset your password for " + appName + ".\n\n" + + "" + + "Click here to reset it:\n" + link; + return sendMail({ + to:user.email, + subject: 'Password Reset for ' + appName, + text: message + }); + }, sendMail: sendMail }); } diff --git a/src/Config.js b/src/Config.js index c31f62eb68..c3d7317d28 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,8 +24,6 @@ export class Config { this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); - this.mailController = cacheInfo.mailController; - this.serverURL = cacheInfo.serverURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -34,7 +32,7 @@ export class Config { this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; - this.mailController = cacheInfo.mailController; + this.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; this.mount = mount; @@ -49,7 +47,11 @@ export class Config { } get choosePasswordURL() { - return `${this.serverURL}/apps/choose_password`; + return `${this.serverURL}/apps/${this.applicationId}/choose_password`; + } + + get requestResetPasswordURL() { + return `${this.serverURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index cfb0b9afa1..bfe0705c32 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,11 +10,12 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); -import cache from '../cache'; +import Config from '../Config'; export class AdaptableController { - constructor(adapter, appId) { + constructor(adapter, appId, options) { + this.options = options; this.adapter = adapter; this.appId = appId; } @@ -29,7 +30,7 @@ export class AdaptableController { } get config() { - return cache.apps[this.appId]; + return new Config(this.appId); } expectedAdapterType() { diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js deleted file mode 100644 index 47d008cce8..0000000000 --- a/src/Controllers/MailController.js +++ /dev/null @@ -1,30 +0,0 @@ -import AdaptableController from './AdaptableController'; -import { MailAdapter } from '../Adapters/Email/MailAdapter'; -import { randomString } from '../cryptoUtils'; -import { inflate } from '../triggers'; - -export class MailController extends AdaptableController { - setEmailVerificationStatus(user, status) { - if (status == false) { - user._email_verify_token = randomString(25); - } - user.emailVerified = status; - } - sendVerificationEmail(user, config) { - const token = encodeURIComponent(user._email_verify_token); - const username = encodeURIComponent(user.username); - - let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; - this.adapter.sendVerificationEmail({ - appName: config.appName, - link: link, - user: inflate('_User', user), - }); - } - sendMail(options) { - this.adapter.sendMail(options); - } - expectedAdapterType() { - return MailAdapter; - } -} diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 62d6dd39d2..e9e0551d13 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -1,31 +1,142 @@ +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; +import AdaptableController from './AdaptableController'; +import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); -export class UserController { +export class UserController extends AdaptableController { + + constructor(adapter, appId, options = {}) { + super(adapter, appId, options); + } + + validateAdapter(adapter) { + // Allow no adapter + if (!adapter && !this.shouldVerifyEmails) { + return; + } + super.validateAdapter(adapter); + } - constructor(appId) { - this.appId = appId; + expectedAdapterType() { + return MailAdapter; } + get shouldVerifyEmails() { + return this.options.verifyUserEmails; + } + + setEmailVerifyToken(user) { + if (this.shouldVerifyEmails) { + user._email_verify_token = randomString(25); + user.emailVerified = false; + } + } + + verifyEmail(username, token) { - var database = DatabaseAdapter.getDatabaseConnection(this.appId); + + return new Promise((resolve, reject) => { + + // Trying to verify email when not enabled + if (!this.shouldVerifyEmails) { + reject(); + return; + } + + var database = this.config.database; + + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); + }); + + }); + } + + checkResetTokenValidity(username, token) { + var database = this.config.database; return new Promise((resolve, reject) => { database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field - return coll.findAndModify({ - username: username, - _email_verify_token: token, - }, null, {$set: {emailVerified: true}}, (err, doc) => { - if (err || !doc.value) { - reject(); - } else { - resolve(); - } + // Need direct database access because verification token is not a parse field + return coll.findOne({ + username: username, + _email_reset_token: token, + }, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); }); }); - + } + + setPasswordResetToken(email) { + var database = this.config.database; + var token = randomString(25); + return new Promise((resolve, reject) => { + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + email: email, + }, null, {$set: {_email_reset_token: token}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + console.log(doc); + resolve(token); + } + }); + }); + }); + } + + sendVerificationEmail(user, config = this.config) { + if (!this.shouldVerifyEmails) { + return; + } + + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + + let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; + this.adapter.sendVerificationEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), }); + } + + sendPasswordResetEmail(user, config = this.config) { + if (!this.adapter) { + return; + } + const token = encodeURIComponent(user._email_reset_token); + const username = encodeURIComponent(user.username); + + let link = `${config.requestPasswordResetURL}?token=${token}&username=${username}` + this.adapter.sendPasswordResetEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + + sendMail(options) { + this.adapter.sendMail(options); } } diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index c3ca10ec61..4070f70619 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -167,16 +167,21 @@ function makeExpressHandler(promiseHandler) { JSON.stringify(req.body, null, 2)); } promiseHandler(req).then((result) => { - if (!result.response && !result.location) { + if (!result.response && !result.location && !result.text) { console.log('BUG: the handler did not include a "response" or a "location" field'); throw 'control should not get here'; } if (PromiseRouter.verbose) { - console.log('response:', JSON.stringify(result.response, null, 2)); + console.log('response:', JSON.stringify(result, null, 2)); } var status = result.status || 200; res.status(status); + + if (result.text) { + return res.send(result.text); + } + if (result.location && !result.response) { return res.redirect(result.location); } diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js new file mode 100644 index 0000000000..1fbde2d531 --- /dev/null +++ b/src/Routers/GlobalConfigRouter.js @@ -0,0 +1,48 @@ +// global_config.js + +var Parse = require('parse/node').Parse; + +import PromiseRouter from '../PromiseRouter'; + +export class GlobalConfigRouter extends PromiseRouter { + getGlobalConfig(req) { + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOne({'_id': 1})) + .then(globalConfig => ({response: { params: globalConfig.params }})) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config does not exist', + } + })); + } + updateGlobalConfig(req) { + if (!req.auth.isMaster) { + return Promise.resolve({ + status: 401, + response: {error: 'unauthorized'}, + }); + } + + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) + .then(response => { + return { response: { result: true } } + }) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config cannot be updated', + } + })); + } + + mountRoutes() { + this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); + this.route('PUT', '/config', req => { return this.updateGlobalConfig(req) }); + } +} + +export default GlobalConfigRouter; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 2b75d3f539..40c6180b2c 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -3,6 +3,10 @@ import UserController from '../Controllers/UserController'; import Config from '../Config'; import express from 'express'; import path from 'path'; +import fs from 'fs'; + +let public_html = path.resolve(__dirname, "../../public_html"); +let views = path.resolve(__dirname, '../../views'); export class PublicAPIRouter extends PromiseRouter { @@ -13,33 +17,75 @@ export class PublicAPIRouter extends PromiseRouter { var config = new Config(appId); if (!token || !username) { - return Promise.resolve({ - status: 302, - location: config.invalidLinkURL - }); + return this.invalidLink(req); } - let userController = new UserController(appId); + let userController = config.userController; return userController.verifyEmail(username, token, appId).then( () => { return Promise.resolve({ status: 302, location: `${config.verifyEmailSuccessURL}?username=${username}` }); }, ()=> { + return this.invalidLink(req); + }) + } + + changePassword(req) { + return new Promise((resolve, reject) => { + var config = new Config(req.params.appId); + // Should we keep the file in memory or leave like that? + fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { + if (err) { + return reject(err); + } + data = data.replace("PARSE_SERVER_URL", `'${config.serverURL}'`); + resolve({ + text: data + }) + }); + }); + } + + resetPassword(req) { + var { username, token } = req.params; + + if (!username || !token) { + return this.invalidLink(req); + } + + let config = req.config; + return config.userController.checkResetTokenValidity(username, token).then( () => { return Promise.resolve({ status: 302, - location: config.invalidLinkURL - }); + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}` + }) + }, () => { + return this.invalidLink(req); }) } + + invalidLink(req) { + return Promise.resolve({ + status: 302, + location: req.config.invalidLinkURL + }); + } + + setConfig(req) { + req.config = new Config(req.params.appId); + return Promise.resolve(); + } mountRoutes() { - this.route('GET','/apps/:appId/verify_email', req => { return this.verifyEmail(req); }); + this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); + this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); + this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); } expressApp() { var router = express(); - router.use("/apps", express.static(path.resolve(__dirname, "../../public"))); + router.use("/apps", express.static(public_html)); router.use(super.expressApp()); return router; } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 70a76bf5f9..72d14b3a31 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -27,16 +27,14 @@ export class UsersRouter extends ClassesRouter { req.body = data; req.params.className = '_User'; - if (req.config.verifyUserEmails) { - req.config.mailController.setEmailVerificationStatus(req.body, false); - } + req.config.userController.setEmailVerifyToken(req.body); let p = super.handleCreate(req); - - if (req.config.verifyUserEmails) { + + if (req.config.verifyUserEmails) { // Send email as fire-and-forget once the user makes it into the DB. p.then(() => { - req.config.mailController.sendVerificationEmail(req.body, req.config); + req.config.userController.sendVerificationEmail(req.body, req.config); }); } return p; @@ -155,10 +153,23 @@ export class UsersRouter extends ClassesRouter { return Promise.resolve(success); } - handleReset(req) { + handleResetRequest(req) { + + let { email } = req.body.email; + if (!email) { + throw "Missing email"; + } let userController = req.config.userController; - return userController.requestPasswordReset(); + + return userController.sendPasswordResetEmail(email).then((token) => { + return Promise.resolve({ + response: {} + }) + }, (err) => { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`); + }); } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -169,7 +180,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', req => this.handleReset(req)); + this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }) } } diff --git a/src/global_config.js b/src/global_config.js deleted file mode 100644 index 0c005e4dc6..0000000000 --- a/src/global_config.js +++ /dev/null @@ -1,46 +0,0 @@ -// global_config.js - -var Parse = require('parse/node').Parse; - -import PromiseRouter from './PromiseRouter'; -var router = new PromiseRouter(); - -function getGlobalConfig(req) { - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOne({'_id': 1})) - .then(globalConfig => ({response: { params: globalConfig.params }})) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config does not exist', - } - })); -} - -function updateGlobalConfig(req) { - if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'unauthorized'}, - }); - } - - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) - .then(response => { - return { response: { result: true } } - }) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config cannot be updated', - } - })); -} - -router.route('GET', '/config', getGlobalConfig); -router.route('PUT', '/config', updateGlobalConfig); - -module.exports = router; diff --git a/src/index.js b/src/index.js index 219ec15681..f41463127b 100644 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,6 @@ import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; -import { MailController } from './Controllers/MailController'; import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; @@ -27,6 +26,7 @@ import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { LogsRouter } from './Routers/LogsRouter'; import { HooksRouter } from './Routers/HooksRouter'; import { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; import { HooksController } from './Controllers/HooksController'; import { UserController } from './Controllers/UserController'; @@ -142,12 +142,8 @@ function ParseServer({ const pushController = new PushController(pushControllerAdapter, appId); const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); - const userController = new UserController(appId); - let mailController; - - if (verifyUserEmails) { - mailController = new MailController(loadAdapter(emailAdapter)); - } + const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); + cache.apps.set(appId, { masterKey: masterKey, @@ -163,7 +159,7 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, - mailController: mailController, + userController: userController, verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, @@ -215,7 +211,7 @@ function ParseServer({ ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { - routers.push(require('./global_config')); + routers.push(new GlobalConfigRouter()); } if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { @@ -231,7 +227,6 @@ function ParseServer({ batch.mountOnto(appRouter); api.use(appRouter.expressApp()); - appRouter.mountOnto(api); api.use(middlewares.handleParseErrors); diff --git a/public_html/choose_password.html b/views/choose_password similarity index 97% rename from public_html/choose_password.html rename to views/choose_password index b487862a22..097cbd2077 100644 --- a/public_html/choose_password.html +++ b/views/choose_password @@ -158,7 +158,8 @@

Reset Your Password

})(); var id = urlParams['id']; - document.getElementById('form').setAttribute('action', '/apps/' + id + '/request_password_reset'); + var base = PARSE_SERVER_URL; + document.getElementById('form').setAttribute('action', base + '/apps/' + id + '/request_password_reset'); document.getElementById('username').value = urlParams['username']; document.getElementById('username_label').appendChild(document.createTextNode(urlParams['username'])); From 91d97241828a46d772a3a434a6728023994a9bd9 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 14:46:29 -0500 Subject: [PATCH 5/9] Adds reset password logic --- spec/ParseUser.spec.js | 225 -------------- spec/PublicAPI.spec.js | 2 +- spec/ValidationAndPasswordsReset.spec.js | 374 +++++++++++++++++++++++ src/Config.js | 2 +- src/Controllers/UserController.js | 103 ++++--- src/RestWrite.js | 1 + src/Routers/PublicAPIRouter.js | 51 +++- src/Routers/UsersRouter.js | 9 +- src/index.js | 3 +- src/transform.js | 3 + 10 files changed, 493 insertions(+), 280 deletions(-) create mode 100644 spec/ValidationAndPasswordsReset.spec.js diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 8698fa3688..58c9e8f319 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -49,231 +49,6 @@ describe('Parse.User testing', () => { }); }); - it('sends verification email if email verification is enabled', 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).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(), - 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: 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'); - done(); - } - }); - }); - - 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(); - }, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - 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/apps/verify_email_success.html?username=zxcv'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(true); - done(); - }, (err) => { - console.error(err); - fail("this should not fail"); - done(); - }); - }); - }, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - 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/apps/test/verify_email', { - followRedirect: false, - }, (error, response, body) => { - expect(response.statusCode).toEqual(302); - expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/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/apps/test/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/apps/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/apps/test/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/apps/invalid_link.html'); - user.fetch() - .then(() => { - expect(user.get('emailVerified')).toEqual(false); - done(); - }); - }); - }, - sendPasswordResetEmail: () => Promise.resolve(), - sendMail: () => {} - } - 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/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js index a61537d0ec..9979c04def 100644 --- a/spec/PublicAPI.spec.js +++ b/spec/PublicAPI.spec.js @@ -12,7 +12,7 @@ describe("public API", () => { }); it("should get choose_password", (done) => { - request('http://localhost:8378/1/apps/choose_password', (err, httpResponse, body) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { expect(httpResponse.statusCode).toBe(200); done(); }); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js new file mode 100644 index 0000000000..e5e07b3497 --- /dev/null +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -0,0 +1,374 @@ +"use strict"; + +var request = require('request'); + +describe("Email Verification", () => { + it('sends verification email if email verification is enabled', 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).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(), + 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: 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'); + done(); + } + }); + }); + + 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(); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + 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/apps/verify_email_success.html?username=zxcv'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(true); + done(); + }, (err) => { + console.error(err); + fail("this should not fail"); + done(); + }); + }); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + 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/apps/test/verify_email', { + followRedirect: false, + }, (error, response, body) => { + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/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/apps/test/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/apps/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/apps/test/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/apps/invalid_link.html'); + user.fetch() + .then(() => { + expect(user.get('emailVerified')).toEqual(false); + done(); + }); + }); + }, + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + } + 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(); + } + }); + }); +}); + +describe("Password Reset", () => { + + it('should send a password reset link', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to get the reset link"); + return; + } + expect(response.statusCode).toEqual(302); + var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=[a-zA-Z0-9]+\&id=test\&username=zxcv/; + expect(response.body.match(re)).not.toBe(null); + done(); + }); + }, + sendMail: () => {} + } + 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().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: (err) => { + console.error(err); + fail("Should not fail"); + done(); + } + }); + }); + }); + + it('redirects you to invalid link if you try to request password for a nonexistant users email', done => { + request.get('http://localhost:8378/1/apps/test/request_password_reset?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/apps/invalid_link.html'); + done(); + }); + }); + + it('should programatically reset password', done => { + var user = new Parse.User(); + var emailAdapter = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: options => { + request.get(options.link, { + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to get the reset link"); + return; + } + expect(response.statusCode).toEqual(302); + var re = /http:\/\/localhost:8378\/1\/apps\/choose_password\?token=([a-zA-Z0-9]+)\&id=test\&username=zxcv/; + var match = response.body.match(re); + if (!match) { + fail("should have a token"); + done(); + return; + } + var token = match[1]; + + request.post({ + url: "http://localhost:8378/1/apps/test/request_password_reset" , + body: `new_password=hello&token=${token}&username=zxcv`, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + followRedirect: false, + }, (error, response, body) => { + if (error) { + console.error(error); + fail("Failed to POST request password reset"); + return; + } + expect(response.statusCode).toEqual(302); + expect(response.body).toEqual('Found. Redirecting to http://localhost:8378/1/apps/password_reset_success.html'); + + Parse.User.logIn("zxcv", "hello").then(function(user){ + done(); + }, (err) => { + console.error(err); + fail("should login with new password"); + done(); + }); + + }); + }); + }, + sendMail: () => {} + } + 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().then(() => { + Parse.User.requestPasswordReset('user@parse.com', { + error: (err) => { + console.error(err); + fail("Should not fail"); + done(); + } + }); + }); + }); + +}) + diff --git a/src/Config.js b/src/Config.js index c3d7317d28..12059993d9 100644 --- a/src/Config.js +++ b/src/Config.js @@ -47,7 +47,7 @@ export class Config { } get choosePasswordURL() { - return `${this.serverURL}/apps/${this.applicationId}/choose_password`; + return `${this.serverURL}/apps/choose_password`; } get requestResetPasswordURL() { diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index e9e0551d13..2abd7f496c 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -4,6 +4,9 @@ import AdaptableController from './AdaptableController'; import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); +var RestWrite = require('../RestWrite'); +var hash = require('../password').hash; +var Auth = require('../Auth'); export class UserController extends AdaptableController { @@ -35,7 +38,7 @@ export class UserController extends AdaptableController { } - verifyEmail(username, token) { + verifyEmail(username, token, config = this.config) { return new Promise((resolve, reject) => { @@ -45,7 +48,7 @@ export class UserController extends AdaptableController { return; } - var database = this.config.database; + var database = config.database; database.collection('_User').then(coll => { // Need direct database access because verification token is not a parse field @@ -64,45 +67,24 @@ export class UserController extends AdaptableController { }); } - checkResetTokenValidity(username, token) { - var database = this.config.database; + checkResetTokenValidity(username, token, config = this.config) { return new Promise((resolve, reject) => { - database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field + return config.database.collection('_User').then(coll => { return coll.findOne({ username: username, - _email_reset_token: token, + _perishable_token: token, }, (err, doc) => { - if (err || !doc.value) { - reject(); - } else { - resolve(); - } - }); - }); - }); - } - - setPasswordResetToken(email) { - var database = this.config.database; - var token = randomString(25); - return new Promise((resolve, reject) => { - database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field - return coll.findAndModify({ - email: email, - }, null, {$set: {_email_reset_token: token}}, (err, doc) => { - if (err || !doc.value) { - reject(); + if (err || !doc) { + reject(err); } else { - console.log(doc); - resolve(token); + resolve(doc); } }); }); }); } + sendVerificationEmail(user, config = this.config) { if (!this.shouldVerifyEmails) { return; @@ -119,25 +101,68 @@ export class UserController extends AdaptableController { }); } - sendPasswordResetEmail(user, config = this.config) { + setPasswordResetToken(email, config = this.config) { + var database = config.database; + var token = randomString(25); + return new Promise((resolve, reject) => { + return database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + email: email, + }, null, {$set: {_perishable_token: token}}, (err, doc) => { + if (err || !doc.value) { + console.error(err); + reject(err); + } else { + doc.value._perishable_token = token; + resolve(doc.value); + } + }); + }); + }); + } + + sendPasswordResetEmail(email, config = this.config) { if (!this.adapter) { + throw "Trying to send a reset password but no adapter is set"; + // TODO: No adapter? return; } - const token = encodeURIComponent(user._email_reset_token); - const username = encodeURIComponent(user.username); - - let link = `${config.requestPasswordResetURL}?token=${token}&username=${username}` - this.adapter.sendPasswordResetEmail({ - appName: config.appName, - link: link, - user: inflate('_User', user), + return this.setPasswordResetToken(email).then((user) => { + + const token = encodeURIComponent(user._perishable_token); + const username = encodeURIComponent(user.username); + let link = `${config.requestResetPasswordURL}?token=${token}&username=${username}` + this.adapter.sendPasswordResetEmail({ + appName: 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); + }); + } sendMail(options) { this.adapter.sendMail(options); } } +// Mark this private +function updateUserPassword(username, token, password, config) { + var write = new RestWrite(config, Auth.master(config), '_User', { + username: username, + _perishable_token: token + }, {password: password, _perishable_token: null }, undefined); + return write.execute(); + } + export default UserController; diff --git a/src/RestWrite.js b/src/RestWrite.js index 66ea69ff64..31d8f125a3 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -832,4 +832,5 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; +export default RestWrite; module.exports = RestWrite; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 40c6180b2c..7856531152 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -21,7 +21,7 @@ export class PublicAPIRouter extends PromiseRouter { } let userController = config.userController; - return userController.verifyEmail(username, token, appId).then( () => { + return userController.verifyEmail(username, token).then( () => { return Promise.resolve({ status: 302, location: `${config.verifyEmailSuccessURL}?username=${username}` @@ -33,7 +33,13 @@ export class PublicAPIRouter extends PromiseRouter { changePassword(req) { return new Promise((resolve, reject) => { - var config = new Config(req.params.appId); + var config = new Config(req.query.id); + if (!config.serverURL) { + return Promise.resolve({ + status: 404, + text: 'Not found.' + }); + } // Should we keep the file in memory or leave like that? fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { if (err) { @@ -47,23 +53,51 @@ export class PublicAPIRouter extends PromiseRouter { }); } - resetPassword(req) { - var { username, token } = req.params; + requestResetPassword(req) { + + var { username, token } = req.query; if (!username || !token) { return this.invalidLink(req); } let config = req.config; - return config.userController.checkResetTokenValidity(username, token).then( () => { + return config.userController.checkResetTokenValidity(username, token).then( (user) => { return Promise.resolve({ status: 302, - location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}` + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&app=${config.appName}` }) }, () => { return this.invalidLink(req); }) } + + resetPassword(req) { + var { + username, + token, + new_password + } = req.body; + + if (!username || !token || !new_password) { + return this.invalidLink(req); + } + + let config = req.config; + return config.userController.updatePassword(username, token, new_password).then((result) => { + return Promise.resolve({ + status: 302, + location: config.passwordResetSuccessURL + }); + }, (err) => { + console.error(err); + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&error=${err}&app=${config.appName}` + }); + }); + + } invalidLink(req) { return Promise.resolve({ @@ -80,13 +114,14 @@ export class PublicAPIRouter extends PromiseRouter { mountRoutes() { this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); - this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); + this.route('POST','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); + this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.requestResetPassword(req); }); } expressApp() { var router = express(); router.use("/apps", express.static(public_html)); - router.use(super.expressApp()); + router.use("/", super.expressApp()); return router; } } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 72d14b3a31..2d63d70125 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -34,7 +34,7 @@ export class UsersRouter extends ClassesRouter { 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, req.config); + req.config.userController.sendVerificationEmail(req.body); }); } return p; @@ -154,17 +154,16 @@ export class UsersRouter extends ClassesRouter { } handleResetRequest(req) { - - let { email } = req.body.email; + let { email } = req.body; if (!email) { - throw "Missing email"; + throw new Parse.Error(Parse.Error.EMAIL_MISSING, "you must provide an email"); } let userController = req.config.userController; return userController.sendPasswordResetEmail(email).then((token) => { return Promise.resolve({ response: {} - }) + }); }, (err) => { throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`); }); diff --git a/src/index.js b/src/index.js index f41463127b..84ab3f5504 100644 --- a/src/index.js +++ b/src/index.js @@ -182,7 +182,8 @@ function ParseServer({ })); if (process.env.PARSE_EXPERIMENTAL_EMAIL_VERIFICATION_ENABLED || process.env.TESTING == 1) { - api.use('/', new PublicAPIRouter().expressApp()); + // need the body parser for the password reset + api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp()); } diff --git a/src/transform.js b/src/transform.js index 7ff570c063..8829f394ae 100644 --- a/src/transform.js +++ b/src/transform.js @@ -45,6 +45,9 @@ export function transformKeyValue(schema, className, restKey, restValue, options case '_email_verify_token': key = "_email_verify_token"; break; + case '_perishable_token': + key = "_perishable_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; From 3ecaa0aa4bb7b284590404d9717653693beed9ff Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 15:24:45 -0500 Subject: [PATCH 6/9] 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 f3ae2cdb84..a9b853d98d 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 e5e07b3497..0519b887c9 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 5c8bd49509..3d3c192f8f 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 ae5e828374..b92d00c53e 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 12059993d9..cfa53361c7 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 2abd7f496c..786d118e62 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 31d8f125a3..02815403ce 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 2d63d70125..21dc80ba3f 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 84ab3f5504..1fa39aa75e 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); From 2183b0be82b5bb0e0ce82dc4f0d75fce33aed6ae Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sat, 27 Feb 2016 20:01:12 -0500 Subject: [PATCH 7/9] Allows very simple mail adapters - Fix nasty bug when updating users email and sending verification --- spec/ValidationAndPasswordsReset.spec.js | 59 ++++++++++++++- src/Adapters/Email/MailAdapter.js | 20 ++++- src/Adapters/Email/SimpleMailgunAdapter.js | 25 ------ src/Controllers/UserController.js | 88 ++++++++++++++++++---- 4 files changed, 150 insertions(+), 42 deletions(-) diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 0519b887c9..91f7ddce24 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -150,10 +150,67 @@ describe("Email Verification", () => { 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); + // Wait as on update emai, we need to fetch the username + setTimeout(function(){ + expect(emailAdapter.sendVerificationEmail).toHaveBeenCalled(); + done(); + }, 200); + }); + }, + error: function(userAgain, error) { + fail('Failed to save user'); + done(); + } + }); + }); + + it('does send with a simple adapter', done => { + var calls = 0; + var emailAdapter = { + sendMail: function(options){ + expect(options.to).toBe('cool_guy@parse.com'); + if (calls == 0) { + expect(options.subject).toEqual('Please verify your e-mail for My Cool App'); + expect(options.text.match(/verify_email/)).not.toBe(null); + } else if (calls == 1) { + expect(options.subject).toEqual('Password Reset for My Cool App'); + expect(options.text.match(/request_password_reset/)).not.toBe(null); + } + calls++; + return Promise.resolve(); + } + } + setServerConfiguration({ + serverURL: 'http://localhost:8378/1', + appId: 'test', + appName: 'My Cool 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", "cool_guy@parse.com"); + user.signUp(null, { + success: function(user) { + expect(calls).toBe(1); + user.fetch() + .then((user) => { + return user.save(); + }).then((user) => { + return Parse.User.requestPasswordReset("cool_guy@parse.com"); + }).then(() => { + expect(calls).toBe(2); done(); }); }, diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index ab8f15715d..82ea8b34c3 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,7 +1,23 @@ + +/* + Mail Adapter prototype + A MailAdapter should implement at least sendMail() + */ export class MailAdapter { - sendVerificationEmail(options) {} - sendPasswordResetEmail(options) {} + /* + * A method for sending mail + * @param options would have the parameters + * - to: the recipient + * - text: the raw text of the message + * - subject: the subject of the email + */ sendMail(options) {} + + /* You can implement those methods if you want + * to provide HTML templates etc... + */ + // sendVerificationEmail({ link, appName, user }) {} + // sendPasswordResetEmail({ link, appName, user }) {} } export default MailAdapter; diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index 6720962f5d..a90a43d77b 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -25,31 +25,6 @@ let SimpleMailgunAdapter = mailgunOptions => { } return Object.freeze({ - 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; - return sendMail({ - to:user.email, - subject: 'Please verify your e-mail for ' + appName, - text: verifyMessage - }); - }, - - sendPasswordResetEmail: ({link,user, appName}) => { - let message = - "Hi,\n\n" + - "You requested to reset your password for " + appName + ".\n\n" + - "" + - "Click here to reset it:\n" + link; - return sendMail({ - to:user.email, - subject: 'Password Reset for ' + appName, - text: message - }); - }, sendMail: sendMail }); } diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 786d118e62..b707e124ae 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -5,6 +5,7 @@ import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); var RestWrite = require('../RestWrite'); +var RestQuery = require('../RestQuery'); var hash = require('../password').hash; var Auth = require('../Auth'); @@ -84,20 +85,47 @@ export class UserController extends AdaptableController { }); } + getUserIfNeeded(user) { + if (user.username && user.email) { + return Promise.resolve(user); + } + var where = {}; + if (user.username) { + where.username = user.username; + } + if (user.email) { + where.email = user.email; + } + + var query = new RestQuery(this.config, Auth.master(this.config), '_User', where); + return query.execute().then(function(result){ + if (result.results.length != 1) { + return Promise.reject(); + } + return result.results[0]; + }) + } + sendVerificationEmail(user) { if (!this.shouldVerifyEmails) { return; } - - const token = encodeURIComponent(user._email_verify_token); - const username = encodeURIComponent(user.username); - - let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; - this.adapter.sendVerificationEmail({ - appName: this.config.appName, - link: link, - user: inflate('_User', user), + // We may need to fetch the user in case of update email + this.getUserIfNeeded(user).then((user) => { + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + let link = `${this.config.verifyEmailURL}?token=${token}&username=${username}`; + let options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; + if (this.adapter.sendVerificationEmail) { + this.adapter.sendVerificationEmail(options); + } else { + this.adapter.sendMail(this.defaultVerificationEmail(options)); + } }); } @@ -134,11 +162,23 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._perishable_token); const username = encodeURIComponent(user.username); let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` - this.adapter.sendPasswordResetEmail({ - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }); + + if (!user.username) { + console.log('No username...'); + } + + let options = { + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; + + if (this.adapter.sendPasswordResetEmail) { + this.adapter.sendPasswordResetEmail(options); + } else { + this.adapter.sendMail(this.defaultResetPasswordEmail(options)); + } + return Promise.resolve(user); }); } @@ -148,6 +188,26 @@ export class UserController extends AdaptableController { return updateUserPassword(username, token, password, this.config); }); } + + defaultVerificationEmail({link, user, appName, }) { + let text = "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; + let to = user.get("email"); + let subject = 'Please verify your e-mail for ' + appName; + return { text, to, subject }; + } + + defaultResetPasswordEmail({link, user, appName, }) { + let text = "Hi,\n\n" + + "You requested to reset your password for " + appName + ".\n\n" + + "" + + "Click here to reset it:\n" + link; + let to = user.get("email"); + let subject = 'Password Reset for ' + appName; + return { text, to, subject }; + } } // Mark this private From 6aa38ea8ca544d87250116bb5c79e9ae7730a2c5 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Sun, 28 Feb 2016 00:15:59 -0500 Subject: [PATCH 8/9] Improves validation of email parameters in Configuration --- src/Config.js | 23 +++++++++++++++++++++++ src/Controllers/AdaptableController.js | 5 ++--- src/Controllers/UserController.js | 14 +++++--------- src/index.js | 22 ++++------------------ 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/src/Config.js b/src/Config.js index cfa53361c7..ae6560119f 100644 --- a/src/Config.js +++ b/src/Config.js @@ -39,6 +39,29 @@ export class Config { this.mount = mount; } + static validate(options) { + this.validateEmailConfiguration({verifyUserEmails: options.verifyUserEmails, + appName: options.appName, + publicServerURL: options.publicServerURL}) + } + + static validateEmailConfiguration({verifyUserEmails, appName, publicServerURL}) { + if (verifyUserEmails) { + if (typeof appName !== 'string') { + throw 'An app name is required when using email verification.'; + } + if (!process.env.TESTING && typeof publicServerURL !== 'string') { + if (process.env.NODE_ENV === 'production') { + throw 'A public server url is required when using email verification.'; + } else { + console.warn(""); + console.warn("You should set publicServerURL to serve the public pages"); + console.warn(""); + } + } + } + } + get linksServerURL() { return this.publicServerURL || this.serverURL; } diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index bfe0705c32..902a6eb349 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -16,8 +16,8 @@ export class AdaptableController { constructor(adapter, appId, options) { this.options = options; - this.adapter = adapter; this.appId = appId; + this.adapter = adapter; } set adapter(adapter) { @@ -62,8 +62,7 @@ export class AdaptableController { }, {}); if (Object.keys(mismatches).length > 0) { - console.error(adapter, mismatches); - throw new Error("Adapter prototype don't match expected prototype"); + throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches); } } } diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index b707e124ae..35da9a1f70 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -162,16 +162,12 @@ export class UserController extends AdaptableController { const token = encodeURIComponent(user._perishable_token); const username = encodeURIComponent(user.username); let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${username}` - - if (!user.username) { - console.log('No username...'); - } - + let options = { - appName: this.config.appName, - link: link, - user: inflate('_User', user), - }; + appName: this.config.appName, + link: link, + user: inflate('_User', user), + }; if (this.adapter.sendPasswordResetEmail) { this.adapter.sendPasswordResetEmail(options); diff --git a/src/index.js b/src/index.js index 1fa39aa75e..3eebb48317 100644 --- a/src/index.js +++ b/src/index.js @@ -11,6 +11,7 @@ var batch = require('./batch'), Parse = require('parse/node').Parse; import cache from './cache'; +import Config from './Config'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; //import passwordReset from './passwordReset'; @@ -72,17 +73,6 @@ 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'; - } - } -} - function ParseServer({ appId = requiredParameter('You must provide an appId!'), masterKey = requiredParameter('You must provide a masterKey!'), @@ -127,13 +117,7 @@ function ParseServer({ if (databaseURI) { 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') { @@ -186,6 +170,8 @@ function ParseServer({ if (process.env.FACEBOOK_APP_ID) { cache.apps.get(appId)['facebookAppIds'].push(process.env.FACEBOOK_APP_ID); } + + Config.validate(cache.apps.get(appId)); // This app serves the Parse API directly. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API. From 28d1a8afe4a843baff0b70e75dff0c0182fecd48 Mon Sep 17 00:00:00 2001 From: Florent Vilmart Date: Mon, 29 Feb 2016 20:51:13 -0500 Subject: [PATCH 9/9] Sends 404 when parseServerURL is not set on public pages - throws when verifyEmail = true && publicServerURL not set --- spec/PublicAPI.spec.js | 56 ++++++++++++++++++-- spec/ValidationAndPasswordsReset.spec.js | 66 ++++++++++++++++++++++++ spec/helper.js | 1 + spec/index.spec.js | 5 ++ src/Config.js | 28 ++++------ src/Routers/PublicAPIRouter.js | 66 +++++++++++++++++------- src/index.js | 2 +- src/middlewares.js | 3 ++ 8 files changed, 186 insertions(+), 41 deletions(-) diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js index 9979c04def..008d544ae4 100644 --- a/spec/PublicAPI.spec.js +++ b/spec/PublicAPI.spec.js @@ -1,9 +1,23 @@ var request = require('request'); - describe("public API", () => { - + beforeEach(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', + publicServerURL: 'http://localhost:8378/1' + }); + done(); + }) it("should get invalid_link.html", (done) => { request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { expect(httpResponse.statusCode).toBe(200); @@ -31,6 +45,42 @@ describe("public API", () => { done(); }); }); +}); + +describe("public API without publicServerURL", () => { + beforeEach(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', + }); + done(); + }) + it("should get 404 on verify_email", (done) => { + request('http://localhost:8378/1/apps/test/verify_email', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); + it("should get 404 choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); -}) \ No newline at end of file + it("should get 404 on request_password_reset", (done) => { + request('http://localhost:8378/1/apps/test/request_password_reset', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(404); + done(); + }); + }); +}); diff --git a/spec/ValidationAndPasswordsReset.spec.js b/spec/ValidationAndPasswordsReset.spec.js index 91f7ddce24..6ac874cd4d 100644 --- a/spec/ValidationAndPasswordsReset.spec.js +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -56,6 +56,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); spyOn(emailAdapter, 'sendVerificationEmail'); var user = new Parse.User(); @@ -97,6 +98,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); spyOn(emailAdapter, 'sendVerificationEmail'); var user = new Parse.User(); @@ -137,6 +139,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); spyOn(emailAdapter, 'sendVerificationEmail'); var user = new Parse.User(); @@ -196,6 +199,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); var user = new Parse.User(); user.setPassword("asdf"); @@ -284,6 +288,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); var user = new Parse.User(); user.setPassword("asdf"); @@ -334,6 +339,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); @@ -342,6 +348,25 @@ describe("Email Verification", () => { }); it('redirects you to invalid link if you try to verify email incorrecly', 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: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); request.get('http://localhost:8378/1/apps/test/verify_email', { followRedirect: false, }, (error, response, body) => { @@ -352,6 +377,25 @@ describe("Email Verification", () => { }); it('redirects you to invalid link if you try to validate a nonexistant users email', 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: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); request.get('http://localhost:8378/1/apps/test/verify_email?token=asdfasdf&username=sadfasga', { followRedirect: false, }, (error, response, body) => { @@ -393,6 +437,7 @@ describe("Email Verification", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); @@ -443,6 +488,7 @@ describe("Password Reset", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); @@ -459,6 +505,25 @@ describe("Password Reset", () => { }); it('redirects you to invalid link if you try to request password for a nonexistant users email', 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: { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => {} + }, + publicServerURL: "http://localhost:8378/1" + }); request.get('http://localhost:8378/1/apps/test/request_password_reset?token=asdfasdf&username=sadfasga', { followRedirect: false, }, (error, response, body) => { @@ -533,6 +598,7 @@ describe("Password Reset", () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: emailAdapter, + publicServerURL: "http://localhost:8378/1" }); user.setPassword("asdf"); user.setUsername("zxcv"); diff --git a/spec/helper.js b/spec/helper.js index 92231393ce..e2daa6ed25 100644 --- a/spec/helper.js +++ b/spec/helper.js @@ -250,3 +250,4 @@ global.arrayContains = arrayContains; global.jequal = jequal; global.range = range; global.setServerConfiguration = setServerConfiguration; +global.defaultConfiguration = defaultConfiguration; diff --git a/spec/index.spec.js b/spec/index.spec.js index 005b9c76e0..e3e2cb0bd3 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -56,6 +56,7 @@ describe('server', () => { apiKey: 'k', domain: 'd', }), + publicServerURL: 'http://localhost:8378/1' }); done(); }); @@ -80,6 +81,7 @@ describe('server', () => { domain: 'd', } }, + publicServerURL: 'http://localhost:8378/1' }); done(); }); @@ -104,6 +106,7 @@ describe('server', () => { domain: 'd', } }, + publicServerURL: 'http://localhost:8378/1' }); done(); }); @@ -122,6 +125,7 @@ describe('server', () => { fileKey: 'test', verifyUserEmails: true, emailAdapter: './Email/SimpleMailgunAdapter', + publicServerURL: 'http://localhost:8378/1' })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); done(); }); @@ -145,6 +149,7 @@ describe('server', () => { domain: 'd', } }, + publicServerURL: 'http://localhost:8378/1' })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); done(); }); diff --git a/src/Config.js b/src/Config.js index ae6560119f..8042d6db4d 100644 --- a/src/Config.js +++ b/src/Config.js @@ -50,44 +50,34 @@ export class Config { if (typeof appName !== 'string') { throw 'An app name is required when using email verification.'; } - if (!process.env.TESTING && typeof publicServerURL !== 'string') { - if (process.env.NODE_ENV === 'production') { - throw 'A public server url is required when using email verification.'; - } else { - console.warn(""); - console.warn("You should set publicServerURL to serve the public pages"); - console.warn(""); - } + if (typeof publicServerURL !== 'string') { + throw 'A public server url is required when using email verification.'; } } } - - get linksServerURL() { - return this.publicServerURL || this.serverURL; - } - + get invalidLinkURL() { - return this.customPages.invalidLink || `${this.linksServerURL}/apps/invalid_link.html`; + return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; } get verifyEmailSuccessURL() { - return this.customPages.verifyEmailSuccess || `${this.linksServerURL}/apps/verify_email_success.html`; + return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; } get choosePasswordURL() { - return this.customPages.choosePassword || `${this.linksServerURL}/apps/choose_password`; + return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`; } get requestResetPasswordURL() { - return `${this.linksServerURL}/apps/${this.applicationId}/request_password_reset`; + return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { - return this.customPages.passwordResetSuccess || `${this.linksServerURL}/apps/password_reset_success.html`; + return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; } get verifyEmailURL() { - return `${this.linksServerURL}/apps/${this.applicationId}/verify_email`; + return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; } }; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 7856531152..017caef395 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -11,10 +11,13 @@ let views = path.resolve(__dirname, '../../views'); export class PublicAPIRouter extends PromiseRouter { verifyEmail(req) { - var token = req.query.token; - var username = req.query.username; - var appId = req.params.appId; - var config = new Config(appId); + let { token, username }= req.query; + let appId = req.params.appId; + let config = new Config(appId); + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } if (!token || !username) { return this.invalidLink(req); @@ -33,9 +36,9 @@ export class PublicAPIRouter extends PromiseRouter { changePassword(req) { return new Promise((resolve, reject) => { - var config = new Config(req.query.id); - if (!config.serverURL) { - return Promise.resolve({ + let config = new Config(req.query.id); + if (!config.publicServerURL) { + return resolve({ status: 404, text: 'Not found.' }); @@ -45,7 +48,7 @@ export class PublicAPIRouter extends PromiseRouter { if (err) { return reject(err); } - data = data.replace("PARSE_SERVER_URL", `'${config.serverURL}'`); + data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`); resolve({ text: data }) @@ -55,13 +58,18 @@ export class PublicAPIRouter extends PromiseRouter { requestResetPassword(req) { - var { username, token } = req.query; + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { username, token } = req.query; if (!username || !token) { return this.invalidLink(req); } - let config = req.config; return config.userController.checkResetTokenValidity(username, token).then( (user) => { return Promise.resolve({ status: 302, @@ -73,7 +81,14 @@ export class PublicAPIRouter extends PromiseRouter { } resetPassword(req) { - var { + + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { username, token, new_password @@ -83,14 +98,12 @@ export class PublicAPIRouter extends PromiseRouter { return this.invalidLink(req); } - let config = req.config; return config.userController.updatePassword(username, token, new_password).then((result) => { return Promise.resolve({ status: 302, location: config.passwordResetSuccessURL }); }, (err) => { - console.error(err); return Promise.resolve({ status: 302, location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&error=${err}&app=${config.appName}` @@ -106,20 +119,37 @@ export class PublicAPIRouter extends PromiseRouter { }); } + missingPublicServerURL() { + return Promise.resolve({ + text: 'Not found.', + status: 404 + }); + } + setConfig(req) { req.config = new Config(req.params.appId); return Promise.resolve(); } mountRoutes() { - this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); - this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); - this.route('POST','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); - this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.requestResetPassword(req); }); + this.route('GET','/apps/:appId/verify_email', + req => { this.setConfig(req) }, + req => { return this.verifyEmail(req); }); + + this.route('GET','/apps/choose_password', + req => { return this.changePassword(req); }); + + this.route('POST','/apps/:appId/request_password_reset', + req => { this.setConfig(req) }, + req => { return this.resetPassword(req); }); + + this.route('GET','/apps/:appId/request_password_reset', + req => { this.setConfig(req) }, + req => { return this.requestResetPassword(req); }); } expressApp() { - var router = express(); + let router = express(); router.use("/apps", express.static(public_html)); router.use("/", super.expressApp()); return router; diff --git a/src/index.js b/src/index.js index 3eebb48317..4ee5d14074 100644 --- a/src/index.js +++ b/src/index.js @@ -182,7 +182,7 @@ function ParseServer({ maxUploadSize: maxUploadSize })); - 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) { diff --git a/src/middlewares.js b/src/middlewares.js index b9a8d6ec4b..8489cda02d 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -174,6 +174,9 @@ var handleParseErrors = function(err, req, res, next) { res.status(httpStatus); res.json({code: err.code, error: err.message}); + } else if (err.status && err.message) { + res.status(err.status); + res.json({error: err.message}); } else { console.log('Uncaught internal server error.', err, err.stack); res.status(500);