Merge pull request #627 from flovilmart/mail-adapter

Email Validation
This commit is contained in:
Florent Vilmart
2016-02-29 21:16:03 -05:00
34 changed files with 1933 additions and 165 deletions

View File

@@ -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",

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<!-- This page is displayed when someone navigates to a verify email or reset password link
but their security token is wrong. This can either mean the user has clicked on a
stale link (i.e. re-click on a password reset link after resetting their password) or
(rarely) this could be a sign of a malicious user trying to tamper with your app.
-->
<html>
<head>
<title>Invalid Link</title>
<style type='text/css'>
.container {
border-width: 0px;
display: block;
font: inherit;
font-family: 'Helvetica Neue', Helvetica;
font-size: 16px;
height: 30px;
line-height: 16px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
position: relative;
vertical-align: baseline;
}
h1, h2, h3, h4, h5 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 0 0 15px 0;
padding: 0 0 0 0;
}
</style>
<body>
<div class="container">
<h1>Invalid Link</h1>
</div>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<!-- This page is displayed whenever someone has successfully reset their password.
Pro and Enterprise accounts may edit this page and tell Parse to use that custom
version in their Parse app. See the App Settigns page for more information.
This page will be called with the query param 'username'
-->
<head>
<title>Password Reset</title>
<style type='text/css'>
h1 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
}
</style>
<body>
<h1>Successfully updated your password!</h1>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<!-- This page is displayed whenever someone has successfully reset their password.
Pro and Enterprise accounts may edit this page and tell Parse to use that custom
version in their Parse app. See the App Settigns page for more information.
This page will be called with the query param 'username'
-->
<head>
<title>Email Verification</title>
<style type='text/css'>
h1 {
color: #0067AB;
display: block;
font: inherit;
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 45px 0px 0px 45px;
padding: 0px 8px 0px 8px;
}
</style>
<body>
<h1>Successfully verified your email!</h1>
</body>
</html>

View File

@@ -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();
});
});

View File

@@ -1,3 +1,5 @@
module.exports = function(options) {
this.options = options;
}
return {
options: options
};
};

5
spec/MockEmailAdapter.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve()
}

View File

@@ -0,0 +1,10 @@
module.exports = options => {
if (!options) {
throw "Options were not provided"
}
return {
sendVerificationEmail: () => Promise.resolve(),
sendPasswordResetEmail: () => Promise.resolve(),
sendMail: () => Promise.resolve()
}
}

View File

@@ -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');

View File

@@ -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({

View File

@@ -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() {

86
spec/PublicAPI.spec.js Normal file
View File

@@ -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();
});
});
});

View File

@@ -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();
}
});
});
});
})

View File

@@ -250,3 +250,4 @@ global.arrayContains = arrayContains;
global.jequal = jequal;
global.range = range;
global.setServerConfiguration = setServerConfiguration;
global.defaultConfiguration = defaultConfiguration;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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;

View File

@@ -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

View File

@@ -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'];

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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); })
}
}

View File

@@ -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;

View File

@@ -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 { FunctionsRouter } from './Routers/FunctionsRouter';
import { SchemasRouter } from './Routers/SchemasRouter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter';
import { PushRouter } from './Routers/PushRouter';
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 { 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 { GlobalConfigRouter } from './Routers/GlobalConfigRouter';
import { loadAdapter } from './Adapters/AdapterLoader';
import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { LoggerController } from './Controllers/LoggerController';
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';
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,
};

View File

@@ -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);

View File

@@ -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;

176
views/choose_password Normal file
View File

@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html>
<!-- This page is displayed when someone clicks a valid 'reset password' link.
Users should feel free to add to this page (i.e. branding or security widgets)
but should be sure not to delete any of the form inputs or the javascript from the
template file. This javascript is what adds the necessary values to authenticate
this session with Parse.
The query params 'username' and 'app' hold the friendly names for your current user and
your app. You should feel free to incorporate their values to make the page more personal.
If you are missing form parameters in your POST, Parse will navigate back to this page and
add an 'error' query parameter.
-->
<head>
<title>Password Reset</title>
<style type='text/css'>
h1 {
display: block;
font: inherit;
font-size: 30px;
font-weight: 600;
height: 30px;
line-height: 30px;
margin: 45px 0px 45px 0px;
padding: 0px 8px 0px 8px;
}
.error {
color: red;
padding: 0px 8px 0px 8px;
margin: -25px 0px -20px 0px;
}
body {
font-family: 'Open Sans', 'Helvetica Neue', Helvetica;
color: #0067AB;
margin: 15px 99px 0px 98px;
}
label {
color: #666666;
}
form {
margin: 0px 0px 45px 0px;
padding: 0px 8px 0px 8px;
}
form > * {
display: block;
margin-top: 25px;
margin-bottom: 7px;
}
button {
font-size: 22px;
color: white;
background: #0067AB;
-moz-border-radius: 5px;
-webkit-border-radius: 5px;
-o-border-radius: 5px;
-ms-border-radius: 5px;
-khtml-border-radius: 5px;
border-radius: 5px;
background-image: -webkit-gradient(linear,50% 0,50% 100%,color-stop(0%,#0070BA),color-stop(100%,#00558C));
background-image: -webkit-linear-gradient(#0070BA,#00558C);
background-image: -moz-linear-gradient(#0070BA,#00558C);
background-image: -o-linear-gradient(#0070BA,#00558C);
background-image: -ms-linear-gradient(#0070BA,#00558C);
background-image: linear-gradient(#0070BA,#00558C);
-moz-box-shadow: inset 0 1px 0 0 #0076c4;
-webkit-box-shadow: inset 0 1px 0 0 #0076c4;
-o-box-shadow: inset 0 1px 0 0 #0076c4;
box-shadow: inset 0 1px 0 0 #0076c4;
border: 1px solid #005E9C;
padding: 10px 14px;
cursor: pointer;
outline: none;
display: block;
font-family: "Helvetica Neue",Helvetica;
-webkit-box-align: center;
text-align: center;
box-sizing: border-box;
letter-spacing: normal;
word-spacing: normal;
line-height: normal;
text-transform: none;
text-indent: 0px;
text-shadow: none;
}
button:hover {
background-image: -webkit-gradient(linear,50% 0,50% 100%,color-stop(0%,#0079CA),color-stop(100%,#005E9C));
background-image: -webkit-linear-gradient(#0079CA,#005E9C);
background-image: -moz-linear-gradient(#0079CA,#005E9C);
background-image: -o-linear-gradient(#0079CA,#005E9C);
background-image: -ms-linear-gradient(#0079CA,#005E9C);
background-image: linear-gradient(#0079CA,#005E9C);
-moz-box-shadow: inset 0 0 0 0 #0076c4;
-webkit-box-shadow: inset 0 0 0 0 #0076c4;
-o-box-shadow: inset 0 0 0 0 #0076c4;
box-shadow: inset 0 0 0 0 #0076c4;
}
button:active {
background-image: -webkit-gradient(linear,50% 0,50% 100%,color-stop(0%,#00395E),color-stop(100%,#005891));
background-image: -webkit-linear-gradient(#00395E,#005891);
background-image: -moz-linear-gradient(#00395E,#005891);
background-image: -o-linear-gradient(#00395E,#005891);
background-image: -ms-linear-gradient(#00395E,#005891);
background-image: linear-gradient(#00395E,#005891);
}
input {
color: black;
cursor: auto;
display: inline-block;
font-family: 'Helvetica Neue', Helvetica;
font-size: 25px;
height: 30px;
letter-spacing: normal;
line-height: normal;
margin: 2px 0px 2px 0px;
padding: 5px;
text-transform: none;
vertical-align: baseline;
width: 500px;
word-spacing: 0px;
}
</style>
</head>
<body>
<h1>Reset Your Password<span id='app'></span></h1>
<noscript>We apologize, but resetting your password requires javascript</noscript>
<div class='error' id='error'></div>
<form id='form' action='#' method='POST'>
<label>New Password for <span id='username_label'></span></label>
<input name="new_password" type="password" />
<input name='utf-8' type='hidden' value='✓' />
<input name="username" id="username" type="hidden" />
<input name="token" id="token" type="hidden" />
<button>Change Password</button>
</form>
<script language='javascript' type='text/javascript'>
<!--
window.onload = function() {
var urlParams = {};
(function () {
var pair, // Really a match. Index 0 is the full match; 1 & 2 are the key & val.
tokenize = /([^&=]+)=?([^&]*)/g,
// decodeURIComponents escapes everything but will leave +s that should be ' '
re_space = function (s) { return decodeURIComponent(s.replace(/\+/g, " ")); },
// Substring to cut off the leading '?'
querystring = window.location.search.substring(1);
while (pair = tokenize.exec(querystring))
urlParams[re_space(pair[1])] = re_space(pair[2]);
})();
var id = urlParams['id'];
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']));
document.getElementById('token').value = urlParams['token'];
if (urlParams['error']) {
document.getElementById('error').appendChild(document.createTextNode(urlParams['error']));
}
if (urlParams['app']) {
document.getElementById('app').appendChild(document.createTextNode(' for ' + urlParams['app']));
}
}
//-->
</script>
</body>