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/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/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/MockEmailAdapter.js b/spec/MockEmailAdapter.js new file mode 100644 index 0000000000..b143e37e6e --- /dev/null +++ b/spec/MockEmailAdapter.js @@ -0,0 +1,5 @@ +module.exports = { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() +} diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js new file mode 100644 index 0000000000..8a3095e21f --- /dev/null +++ b/spec/MockEmailAdapterWithOptions.js @@ -0,0 +1,10 @@ +module.exports = options => { + if (!options) { + throw "Options were not provided" + } + return { + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() + } +} diff --git a/spec/OneSignalPushAdapter.spec.js b/spec/OneSignalPushAdapter.spec.js index 2c165c4588..a9b853d98d 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('cannot be initialized if options are missing', (done) => { + + expect(() => { + new OneSignalPushAdapter(); + }).toThrow("Trying to initialize 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/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 a36b3cdcba..58c9e8f319 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -54,6 +54,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(); } }); }); @@ -1704,7 +1709,7 @@ describe('Parse.User testing', () => { done(); }); }); - + it('test parse user become', (done) => { var sessionToken = null; Parse.Promise.as().then(function() { diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js new file mode 100644 index 0000000000..008d544ae4 --- /dev/null +++ b/spec/PublicAPI.spec.js @@ -0,0 +1,86 @@ + +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); + done(); + }); + }); + + it("should get choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password?id=test', (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(); + }); + }); +}); + +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(); + }); + }); + + 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 new file mode 100644 index 0000000000..6ac874cd4d --- /dev/null +++ b/spec/ValidationAndPasswordsReset.spec.js @@ -0,0 +1,618 @@ +"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 => { + 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, + publicServerURL: "http://localhost:8378/1" + }); + spyOn(emailAdapter, 'sendVerificationEmail'); + 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(); + 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 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, + publicServerURL: "http://localhost:8378/1" + }); + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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) => { + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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(); + }); + }, + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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 => { + 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) => { + 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 => { + 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) => { + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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 => { + 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) => { + 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, + publicServerURL: "http://localhost:8378/1" + }); + 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/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 8b55808997..e3e2cb0bd3 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,119 @@ 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', + }), + publicServerURL: 'http://localhost:8378/1' + }); + 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', + } + }, + publicServerURL: 'http://localhost:8378/1' + }); + 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', + } + }, + publicServerURL: 'http://localhost:8378/1' + }); + 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', + publicServerURL: 'http://localhost:8378/1' + })).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', + } + }, + publicServerURL: 'http://localhost:8378/1' + })).toThrow('SimpleMailgunAdapter requires an API Key and domain.'); + done(); + }); }); diff --git a/src/Adapters/AdapterLoader.js b/src/Adapters/AdapterLoader.js index cfe51ffd7e..5b46f22d2d 100644 --- a/src/Adapters/AdapterLoader.js +++ b/src/Adapters/AdapterLoader.js @@ -1,35 +1,43 @@ +export function loadAdapter(adapter, defaultAdapter, options) { -export function loadAdapter(options, defaultAdapter) { - let adapter; - - // 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; } + +export default loadAdapter; diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js new file mode 100644 index 0000000000..82ea8b34c3 --- /dev/null +++ b/src/Adapters/Email/MailAdapter.js @@ -0,0 +1,23 @@ + +/* + Mail Adapter prototype + A MailAdapter should implement at least sendMail() + */ +export class MailAdapter { + /* + * 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 new file mode 100644 index 0000000000..a90a43d77b --- /dev/null +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -0,0 +1,32 @@ +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 Object.freeze({ + 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..3d3c192f8f 100644 --- a/src/Adapters/Logger/FileLoggerAdapter.js +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -101,7 +101,6 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { export class FileLoggerAdapter extends LoggerAdapter { constructor(options = {}) { super(); - 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..b92d00c53e 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 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 988efb1e11..8042d6db4d 100644 --- a/src/Config.js +++ b/src/Config.js @@ -23,15 +23,63 @@ export class Config { this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); + + this.serverURL = cacheInfo.serverURL; + this.publicServerURL = cacheInfo.publicServerURL; + this.verifyUserEmails = cacheInfo.verifyUserEmails; + 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.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; - + this.customPages = cacheInfo.customPages || {}; 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 (typeof publicServerURL !== 'string') { + throw 'A public server url is required when using email verification.'; + } + } + } + + get invalidLinkURL() { + return this.customPages.invalidLink || `${this.publicServerURL}/apps/invalid_link.html`; + } + + get verifyEmailSuccessURL() { + return this.customPages.verifyEmailSuccess || `${this.publicServerURL}/apps/verify_email_success.html`; + } + + get choosePasswordURL() { + return this.customPages.choosePassword || `${this.publicServerURL}/apps/choose_password`; + } + + get requestResetPasswordURL() { + return `${this.publicServerURL}/apps/${this.applicationId}/request_password_reset`; + } + + get passwordResetSuccessURL() { + return this.customPages.passwordResetSuccess || `${this.publicServerURL}/apps/password_reset_success.html`; + } + + get verifyEmailURL() { + return `${this.publicServerURL}/apps/${this.applicationId}/verify_email`; + } +}; export default Config; module.exports = Config; diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index ef45b0225f..902a6eb349 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,10 +10,13 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); +import Config from '../Config'; export class AdaptableController { - constructor(adapter) { + constructor(adapter, appId, options) { + this.options = options; + this.appId = appId; this.adapter = adapter; } @@ -26,12 +29,15 @@ export class AdaptableController { return this[_adapter]; } + get config() { + return new Config(this.appId); + } + expectedAdapterType() { throw new Error("Subclasses should implement expectedAdapterType()"); } validateAdapter(adapter) { - if (!adapter) { throw new Error(this.constructor.name+" requires an adapter"); } @@ -56,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 new file mode 100644 index 0000000000..35da9a1f70 --- /dev/null +++ b/src/Controllers/UserController.js @@ -0,0 +1,218 @@ +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; +import AdaptableController from './AdaptableController'; +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'); + +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); + } + + 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) { + + 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(err); + } else { + resolve(doc.value); + } + }); + }); + + }); + } + + checkResetTokenValidity(username, token) { + return new Promise((resolve, reject) => { + return this.config.database.collection('_User').then(coll => { + return coll.findOne({ + username: username, + _perishable_token: token, + }, (err, doc) => { + if (err || !doc) { + reject(err); + } else { + resolve(doc); + } + }); + }); + }); + } + + 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; + } + // 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)); + } + }); + } + + setPasswordResetToken(email) { + var database = this.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) { + if (!this.adapter) { + throw "Trying to send a reset password but no adapter is set"; + // TODO: No adapter? + return; + } + + return this.setPasswordResetToken(email).then((user) => { + + const token = encodeURIComponent(user._perishable_token); + const username = encodeURIComponent(user.username); + let link = `${this.config.requestResetPasswordURL}?token=${token}&username=${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); + }); + } + + updatePassword(username, token, password, config) { + return this.checkResetTokenValidity(username, token).then(() => { + 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 +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/PromiseRouter.js b/src/PromiseRouter.js index 8155c7967b..4070f70619 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,24 @@ 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 && !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); + } if (result.location) { res.set('Location', result.location); } diff --git a/src/RestWrite.js b/src/RestWrite.js index 66ea69ff64..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. @@ -832,4 +845,5 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; +export default RestWrite; module.exports = RestWrite; 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 new file mode 100644 index 0000000000..017caef395 --- /dev/null +++ b/src/Routers/PublicAPIRouter.js @@ -0,0 +1,159 @@ +import PromiseRouter from '../PromiseRouter'; +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 { + + verifyEmail(req) { + 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); + } + + let userController = config.userController; + return userController.verifyEmail(username, token).then( () => { + return Promise.resolve({ + status: 302, + location: `${config.verifyEmailSuccessURL}?username=${username}` + }); + }, ()=> { + return this.invalidLink(req); + }) + } + + changePassword(req) { + return new Promise((resolve, reject) => { + let config = new Config(req.query.id); + if (!config.publicServerURL) { + return 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) { + return reject(err); + } + data = data.replace("PARSE_SERVER_URL", `'${config.publicServerURL}'`); + resolve({ + text: data + }) + }); + }); + } + + requestResetPassword(req) { + + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { username, token } = req.query; + + if (!username || !token) { + return this.invalidLink(req); + } + + return config.userController.checkResetTokenValidity(username, token).then( (user) => { + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}&app=${config.appName}` + }) + }, () => { + return this.invalidLink(req); + }) + } + + resetPassword(req) { + + let config = req.config; + + if (!config.publicServerURL) { + return this.missingPublicServerURL(); + } + + let { + username, + token, + new_password + } = req.body; + + if (!username || !token || !new_password) { + return this.invalidLink(req); + } + + return config.userController.updatePassword(username, token, new_password).then((result) => { + return Promise.resolve({ + status: 302, + location: config.passwordResetSuccessURL + }); + }, (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({ + status: 302, + location: req.config.invalidLinkURL + }); + } + + 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', + 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() { + let router = express(); + router.use("/apps", express.static(public_html)); + router.use("/", super.expressApp()); + return router; + } +} + +export default PublicAPIRouter; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4cba3edb0d..21dc80ba3f 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,18 @@ export class UsersRouter extends ClassesRouter { let data = deepcopy(req.body); req.body = data; req.params.className = '_User'; + + //req.config.userController.setEmailVerifyToken(req.body); + 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; } handleUpdate(req) { @@ -87,7 +99,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; @@ -140,6 +152,23 @@ export class UsersRouter extends ClassesRouter { } return Promise.resolve(success); } + + handleResetRequest(req) { + let { email } = req.body; + if (!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}`); + }); + } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -150,9 +179,7 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', () => { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); - }); + this.route('POST', '/requestPasswordReset', req => { 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 5062b6b03a..4ee5d14074 100644 --- a/src/index.js +++ b/src/index.js @@ -11,32 +11,36 @@ 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 Config from './Config'; 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 { 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 { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; +import { HooksController } from './Controllers/HooksController'; +import { UserController } from './Controllers/UserController'; +import { InstallationsRouter } from './Routers/InstallationsRouter'; import { loadAdapter } from './Adapters/AdapterLoader'; -import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { LoggerController } from './Controllers/LoggerController'; -import { HooksController } from './Controllers/HooksController'; +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'; @@ -72,6 +76,7 @@ addParseCloud(); function ParseServer({ appId = requiredParameter('You must provide an appId!'), masterKey = requiredParameter('You must provide a masterKey!'), + appName, databaseAdapter, filesAdapter, push, @@ -89,7 +94,16 @@ function ParseServer({ allowClientClassCreation = true, oauth = {}, serverURL = requiredParameter('You must provide a serverURL!'), - maxUploadSize = '20mb' + maxUploadSize = '20mb', + verifyUserEmails = false, + emailAdapter, + publicServerURL, + customPages = { + invalidLink: undefined, + verifyEmailSuccess: undefined, + choosePassword: undefined, + passwordResetSuccess: undefined + }, }) { // Initialize the node client SDK automatically @@ -103,7 +117,7 @@ function ParseServer({ if (databaseURI) { DatabaseAdapter.setAppDatabaseURI(appId, databaseURI); } - + if (cloud) { addParseCloud(); if (typeof cloud === 'function') { @@ -118,16 +132,19 @@ 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(emailControllerAdapter, appId, { verifyUserEmails }); + cache.apps.set(appId, { masterKey: masterKey, + serverURL: serverURL, collectionPrefix: collectionPrefix, clientKey: clientKey, javascriptKey: javascriptKey, @@ -139,25 +156,34 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, + userController: userController, + verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, - oauth: oauth + oauth: oauth, + appName: appName, + publicServerURL: publicServerURL, + customPages: customPages, }); - // To maintain compatibility. TODO: Remove in v2.1 + // To maintain compatibility. TODO: Remove in some version that breaks backwards compatability if (process.env.FACEBOOK_APP_ID) { cache.apps.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. 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 })); + 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); @@ -183,20 +209,22 @@ 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) { 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); - appRouter.mountOnto(api); + api.use(appRouter.expressApp()); api.use(middlewares.handleParseErrors); @@ -222,5 +250,5 @@ function addParseCloud() { module.exports = { ParseServer: ParseServer, - S3Adapter: S3Adapter + S3Adapter: S3Adapter, }; 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); diff --git a/src/transform.js b/src/transform.js index f254f0d464..8829f394ae 100644 --- a/src/transform.js +++ b/src/transform.js @@ -42,6 +42,12 @@ export function transformKeyValue(schema, className, restKey, restValue, options key = '_updated_at'; timeField = true; break; + case '_email_verify_token': + key = "_email_verify_token"; + break; + case '_perishable_token': + key = "_perishable_token"; + break; case 'sessionToken': case '_session_token': key = '_session_token'; @@ -649,7 +655,7 @@ function untransformObject(schema, className, mongoObject, isNestedObject = fals restObject['authData'][provider] = mongoObject[key]; break; } - + if (key.indexOf('_p_') == 0) { var newKey = key.substring(3); var expected; diff --git a/views/choose_password b/views/choose_password new file mode 100644 index 0000000000..097cbd2077 --- /dev/null +++ b/views/choose_password @@ -0,0 +1,176 @@ + + + + + Password Reset + + + +

Reset Your Password

+ +
+
+ + + + + + +
+ + +