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", "commander": "^2.9.0",
"deepcopy": "^0.6.1", "deepcopy": "^0.6.1",
"express": "^4.13.4", "express": "^4.13.4",
"mailgun-js": "^0.7.7",
"mime": "^1.3.4", "mime": "^1.3.4",
"mongodb": "~2.1.0", "mongodb": "~2.1.0",
"multer": "^1.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 loadAdapter = require("../src/Adapters/AdapterLoader").loadAdapter;
var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default; var FilesAdapter = require("../src/Adapters/Files/FilesAdapter").default;
describe("AdaptableController", ()=>{ describe("AdapterLoader", ()=>{
it("should instantiate an adapter from string in object", (done) => { it("should instantiate an adapter from string in object", (done) => {
var adapterPath = require('path').resolve("./spec/MockAdapter"); var adapterPath = require('path').resolve("./spec/MockAdapter");
var adapter = loadAdapter({ var adapter = loadAdapter({
adapter: adapterPath, adapter: adapterPath,
key: "value", options: {
foo: "bar" key: "value",
foo: "bar"
}
}); });
expect(adapter instanceof Object).toBe(true); expect(adapter instanceof Object).toBe(true);
@@ -24,7 +26,6 @@ describe("AdaptableController", ()=>{
var adapter = loadAdapter(adapterPath); var adapter = loadAdapter(adapterPath);
expect(adapter instanceof Object).toBe(true); expect(adapter instanceof Object).toBe(true);
expect(adapter.options).toBe(adapterPath);
done(); done();
}); });
@@ -65,4 +66,22 @@ describe("AdaptableController", ()=>{
expect(adapter).toBe(originalAdapter); expect(adapter).toBe(originalAdapter);
done(); 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) { 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 OneSignalPushAdapter = require('../src/Adapters/Push/OneSignalPushAdapter');
var classifyInstallations = require('../src/Adapters/Push/PushAdapterUtils').classifyInstallations; var classifyInstallations = require('../src/Adapters/Push/PushAdapterUtils').classifyInstallations;
// Make mock config
var pushConfig = {
oneSignalAppId:"APP ID",
oneSignalApiKey:"API KEY"
};
describe('OneSignalPushAdapter', () => { describe('OneSignalPushAdapter', () => {
it('can be initialized', (done) => { it('can be initialized', (done) => {
// Make mock config
var pushConfig = {
oneSignalAppId:"APP ID",
oneSignalApiKey:"API KEY"
};
var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig);
@@ -18,8 +20,16 @@ describe('OneSignalPushAdapter', () => {
done(); 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) => { it('can get valid push types', (done) => {
var oneSignalPushAdapter = new OneSignalPushAdapter(); var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig);
expect(oneSignalPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']); expect(oneSignalPushAdapter.getValidPushTypes()).toEqual(['ios', 'android']);
done(); done();
@@ -56,7 +66,7 @@ describe('OneSignalPushAdapter', () => {
it('can send push notifications', (done) => { it('can send push notifications', (done) => {
var oneSignalPushAdapter = new OneSignalPushAdapter(); var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig);
// Mock android ios senders // Mock android ios senders
var androidSender = jasmine.createSpy('send') var androidSender = jasmine.createSpy('send')
@@ -108,7 +118,7 @@ describe('OneSignalPushAdapter', () => {
}); });
it("can send iOS notifications", (done) => { it("can send iOS notifications", (done) => {
var oneSignalPushAdapter = new OneSignalPushAdapter(); var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig);
var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); var sendToOneSignal = jasmine.createSpy('sendToOneSignal');
oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; oneSignalPushAdapter.sendToOneSignal = sendToOneSignal;
@@ -135,7 +145,7 @@ describe('OneSignalPushAdapter', () => {
}); });
it("can send Android notifications", (done) => { it("can send Android notifications", (done) => {
var oneSignalPushAdapter = new OneSignalPushAdapter(); var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig);
var sendToOneSignal = jasmine.createSpy('sendToOneSignal'); var sendToOneSignal = jasmine.createSpy('sendToOneSignal');
oneSignalPushAdapter.sendToOneSignal = sendToOneSignal; oneSignalPushAdapter.sendToOneSignal = sendToOneSignal;
@@ -157,10 +167,7 @@ describe('OneSignalPushAdapter', () => {
}); });
it("can post the correct data", (done) => { it("can post the correct data", (done) => {
var pushConfig = {
oneSignalAppId:"APP ID",
oneSignalApiKey:"API KEY"
};
var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig); var oneSignalPushAdapter = new OneSignalPushAdapter(pushConfig);
var write = jasmine.createSpy('write'); var write = jasmine.createSpy('write');

View File

@@ -2,13 +2,12 @@
var request = require('request'); var request = require('request');
var Parse = require('parse/node').Parse; var Parse = require('parse/node').Parse;
var DatabaseAdapter = require('../src/DatabaseAdapter'); let Config = require('../src/Config');
let database = DatabaseAdapter.getDatabaseConnection('test', 'test_');
describe('a GlobalConfig', () => { describe('a GlobalConfig', () => {
beforeEach(function(done) { 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(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true }))
.then(done()); .then(done());
}); });
@@ -61,7 +60,8 @@ describe('a GlobalConfig', () => {
}); });
it('failed getting config when it is missing', (done) => { 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(coll => coll.deleteOne({ '_id': 1}, {}, {}))
.then(_ => { .then(_ => {
request.get({ request.get({

View File

@@ -54,6 +54,11 @@ describe('Parse.User testing', () => {
success: function(user) { success: function(user) {
Parse.User.logIn("non_existent_user", "asdf3", Parse.User.logIn("non_existent_user", "asdf3",
expectError(Parse.Error.OBJECT_NOT_FOUND, done)); expectError(Parse.Error.OBJECT_NOT_FOUND, done));
},
error: function(err) {
console.error(err);
fail("Shit should not fail");
done();
} }
}); });
}); });

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.jequal = jequal;
global.range = range; global.range = range;
global.setServerConfiguration = setServerConfiguration; global.setServerConfiguration = setServerConfiguration;
global.defaultConfiguration = defaultConfiguration;

View File

@@ -1,4 +1,5 @@
var request = require('request'); var request = require('request');
var MockEmailAdapterWithOptions = require('./MockEmailAdapterWithOptions');
describe('server', () => { describe('server', () => {
it('requires a master key and app id', done => { it('requires a master key and app id', done => {
@@ -37,4 +38,119 @@ describe('server', () => {
done(); 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) { if (!adapter)
let adapter; {
if (!defaultAdapter) {
// We have options and options have adapter key return options;
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 (options.adapter) { // Load from the default adapter when no adapter is set
adapter = options.adapter; return loadAdapter(defaultAdapter, undefined, options);
} else if (typeof adapter === "function") {
try {
return adapter(options);
} catch(e) {
var Adapter = adapter;
return new Adapter(options);
} }
} } else if (typeof adapter === "string") {
if (!adapter) {
adapter = defaultAdapter;
}
// This is a string, require the module
if (typeof adapter === "string") {
adapter = require(adapter); adapter = require(adapter);
// If it's define as a module, get the default // If it's define as a module, get the default
if (adapter.default) { if (adapter.default) {
adapter = 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 // return the adapter as is as it's unusable otherwise
// if it's an function, instanciate and pass the options
if (typeof adapter === "function") {
var Adapter = adapter;
adapter = new Adapter(options);
}
return adapter; 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,21 +4,36 @@
import * as AWS from 'aws-sdk'; import * as AWS from 'aws-sdk';
import { FilesAdapter } from './FilesAdapter'; import { FilesAdapter } from './FilesAdapter';
import requiredParameter from '../../requiredParameter';
const DEFAULT_S3_REGION = "us-east-1"; 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 { export class S3Adapter extends FilesAdapter {
// Creates an S3 session. // Creates an S3 session.
// Providing AWS access and secret keys is mandatory // Providing AWS access and secret keys is mandatory
// Region and bucket will use sane defaults if omitted // Region and bucket will use sane defaults if omitted
constructor( constructor(
accessKey, accessKey = requiredParameter('S3Adapter requires an accessKey'),
secretKey, secretKey = requiredParameter('S3Adapter requires a secretKey'),
bucket, bucket,
{ region = DEFAULT_S3_REGION, { region = DEFAULT_S3_REGION,
bucketPrefix = '', bucketPrefix = '',
directAccess = false } = {} directAccess = false } = {}) {
) {
super(); super();
this._region = region; this._region = region;

View File

@@ -101,7 +101,6 @@ let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => {
export class FileLoggerAdapter extends LoggerAdapter { export class FileLoggerAdapter extends LoggerAdapter {
constructor(options = {}) { constructor(options = {}) {
super(); super();
this._logsFolder = options.logsFolder || LOGS_FOLDER; this._logsFolder = options.logsFolder || LOGS_FOLDER;
// check logs folder exists // check logs folder exists

View File

@@ -18,6 +18,10 @@ export class OneSignalPushAdapter extends PushAdapter {
this.validPushTypes = ['ios', 'android']; this.validPushTypes = ['ios', 'android'];
this.senderMap = {}; this.senderMap = {};
this.OneSignalConfig = {}; 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['appId'] = pushConfig['oneSignalAppId'];
this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey']; this.OneSignalConfig['apiKey'] = pushConfig['oneSignalApiKey'];

View File

@@ -23,15 +23,63 @@ export class Config {
this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers; this.enableAnonymousUsers = cacheInfo.enableAnonymousUsers;
this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.allowClientClassCreation = cacheInfo.allowClientClassCreation;
this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); 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.hooksController = cacheInfo.hooksController;
this.filesController = cacheInfo.filesController; this.filesController = cacheInfo.filesController;
this.pushController = cacheInfo.pushController; this.pushController = cacheInfo.pushController;
this.loggerController = cacheInfo.loggerController; this.loggerController = cacheInfo.loggerController;
this.userController = cacheInfo.userController;
this.oauth = cacheInfo.oauth; this.oauth = cacheInfo.oauth;
this.customPages = cacheInfo.customPages || {};
this.mount = mount; 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; export default Config;
module.exports = Config; module.exports = Config;

View File

@@ -10,10 +10,13 @@ based on the parameters passed
// _adapter is private, use Symbol // _adapter is private, use Symbol
var _adapter = Symbol(); var _adapter = Symbol();
import Config from '../Config';
export class AdaptableController { export class AdaptableController {
constructor(adapter) { constructor(adapter, appId, options) {
this.options = options;
this.appId = appId;
this.adapter = adapter; this.adapter = adapter;
} }
@@ -26,12 +29,15 @@ export class AdaptableController {
return this[_adapter]; return this[_adapter];
} }
get config() {
return new Config(this.appId);
}
expectedAdapterType() { expectedAdapterType() {
throw new Error("Subclasses should implement expectedAdapterType()"); throw new Error("Subclasses should implement expectedAdapterType()");
} }
validateAdapter(adapter) { validateAdapter(adapter) {
if (!adapter) { if (!adapter) {
throw new Error(this.constructor.name+" requires an adapter"); throw new Error(this.constructor.name+" requires an adapter");
} }
@@ -56,8 +62,7 @@ export class AdaptableController {
}, {}); }, {});
if (Object.keys(mismatches).length > 0) { if (Object.keys(mismatches).length > 0) {
console.error(adapter, mismatches); throw new Error("Adapter prototype don't match expected prototype", adapter, mismatches);
throw new Error("Adapter prototype don't match expected prototype");
} }
} }
} }

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 // themselves use our routing information, without disturbing express
// components that external developers may be modifying. // components that external developers may be modifying.
import express from 'express';
export default class PromiseRouter { export default class PromiseRouter {
// Each entry should be an object with: // Each entry should be an object with:
// path: the path to route, in express format // 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 // status: optional. the http status code. defaults to 200
// response: a json object with the content of the response // response: a json object with the content of the response
// location: optional. a location header // location: optional. a location header
constructor() { constructor(routes = []) {
this.routes = []; this.routes = routes;
this.mountRoutes(); 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. // 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)); JSON.stringify(req.body, null, 2));
} }
promiseHandler(req).then((result) => { promiseHandler(req).then((result) => {
if (!result.response) { if (!result.response && !result.location && !result.text) {
console.log('BUG: the handler did not include a "response" field'); console.log('BUG: the handler did not include a "response" or a "location" field');
throw 'control should not get here'; throw 'control should not get here';
} }
if (PromiseRouter.verbose) { 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; var status = result.status || 200;
res.status(status); res.status(status);
if (result.text) {
return res.send(result.text);
}
if (result.location && !result.response) {
return res.redirect(result.location);
}
if (result.location) { if (result.location) {
res.set('Location', result.location); res.set('Location', result.location);
} }

View File

@@ -465,12 +465,18 @@ RestWrite.prototype.transformUser = function() {
'address'); 'address');
} }
return Promise.resolve(); 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 // Handles any followup logic
RestWrite.prototype.handleFollowup = function() { RestWrite.prototype.handleFollowup = function() {
if (this.storage && this.storage['clearSessions']) { if (this.storage && this.storage['clearSessions']) {
var sessionQuery = { var sessionQuery = {
user: { user: {
@@ -480,9 +486,16 @@ RestWrite.prototype.handleFollowup = function() {
} }
}; };
delete this.storage['clearSessions']; delete this.storage['clearSessions'];
return this.config.database.destroy('_Session', sessionQuery) this.config.database.destroy('_Session', sessionQuery)
.then(this.handleFollowup.bind(this)); .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. // Handles the _Role class specialness.
@@ -832,4 +845,5 @@ RestWrite.prototype.objectId = function() {
return this.data.objectId || this.query.objectId; return this.data.objectId || this.query.objectId;
}; };
export default RestWrite;
module.exports = 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. // These methods handle the User-related routes.
import deepcopy from 'deepcopy'; import deepcopy from 'deepcopy';
import ClassesRouter from './ClassesRouter'; import ClassesRouter from './ClassesRouter';
import PromiseRouter from '../PromiseRouter'; import PromiseRouter from '../PromiseRouter';
import rest from '../rest'; import rest from '../rest';
import Auth from '../Auth'; import Auth from '../Auth';
import passwordCrypto from '../password'; import passwordCrypto from '../password';
import RestWrite from '../RestWrite'; import RestWrite from '../RestWrite';
import { newToken } from '../cryptoUtils'; let cryptoUtils = require('../cryptoUtils');
let triggers = require('../triggers');
export class UsersRouter extends ClassesRouter { export class UsersRouter extends ClassesRouter {
handleFind(req) { handleFind(req) {
@@ -25,7 +26,18 @@ export class UsersRouter extends ClassesRouter {
let data = deepcopy(req.body); let data = deepcopy(req.body);
req.body = data; req.body = data;
req.params.className = '_User'; req.params.className = '_User';
//req.config.userController.setEmailVerifyToken(req.body);
return super.handleCreate(req); return super.handleCreate(req);
// if (req.config.verifyUserEmails) {
// // Send email as fire-and-forget once the user makes it into the DB.
// p.then(() => {
// req.config.userController.sendVerificationEmail(req.body);
// });
// }
// return p;
} }
handleUpdate(req) { handleUpdate(req) {
@@ -87,7 +99,7 @@ export class UsersRouter extends ClassesRouter {
throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.');
} }
let token = 'r:' + newToken(); let token = 'r:' + cryptoUtils.newToken();
user.sessionToken = token; user.sessionToken = token;
delete user.password; delete user.password;
@@ -141,6 +153,23 @@ export class UsersRouter extends ClassesRouter {
return Promise.resolve(success); 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() { mountRoutes() {
this.route('GET', '/users', req => { return this.handleFind(req); }); this.route('GET', '/users', req => { return this.handleFind(req); });
this.route('POST', '/users', req => { return this.handleCreate(req); }); this.route('POST', '/users', req => { return this.handleCreate(req); });
@@ -150,9 +179,7 @@ export class UsersRouter extends ClassesRouter {
this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); });
this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); });
this.route('POST', '/logout', req => { return this.handleLogOut(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); });
this.route('POST', '/requestPasswordReset', () => { this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); })
throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.');
});
} }
} }

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; Parse = require('parse/node').Parse;
import cache from './cache'; import cache from './cache';
import PromiseRouter from './PromiseRouter'; import Config from './Config';
import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter';
import { S3Adapter } from './Adapters/Files/S3Adapter';
import { FilesController } from './Controllers/FilesController';
import ParsePushAdapter from './Adapters/Push/ParsePushAdapter'; import ParsePushAdapter from './Adapters/Push/ParsePushAdapter';
import { PushController } from './Controllers/PushController'; //import passwordReset from './passwordReset';
import PromiseRouter from './PromiseRouter';
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 { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { AnalyticsRouter } from './Routers/AnalyticsRouter';
import { FunctionsRouter } from './Routers/FunctionsRouter'; import { ClassesRouter } from './Routers/ClassesRouter';
import { SchemasRouter } from './Routers/SchemasRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter';
import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { FilesController } from './Controllers/FilesController';
import { PushRouter } from './Routers/PushRouter';
import { FilesRouter } from './Routers/FilesRouter'; 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 { LogsRouter } from './Routers/LogsRouter';
import { HooksRouter } from './Routers/HooksRouter'; 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 { 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 requiredParameter from './requiredParameter';
import { randomString } from './cryptoUtils'; import { randomString } from './cryptoUtils';
@@ -72,6 +76,7 @@ addParseCloud();
function ParseServer({ function ParseServer({
appId = requiredParameter('You must provide an appId!'), appId = requiredParameter('You must provide an appId!'),
masterKey = requiredParameter('You must provide a masterKey!'), masterKey = requiredParameter('You must provide a masterKey!'),
appName,
databaseAdapter, databaseAdapter,
filesAdapter, filesAdapter,
push, push,
@@ -89,7 +94,16 @@ function ParseServer({
allowClientClassCreation = true, allowClientClassCreation = true,
oauth = {}, oauth = {},
serverURL = requiredParameter('You must provide a serverURL!'), 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 // Initialize the node client SDK automatically
@@ -118,16 +132,19 @@ function ParseServer({
const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter); const filesControllerAdapter = loadAdapter(filesAdapter, GridStoreAdapter);
const pushControllerAdapter = loadAdapter(push, ParsePushAdapter); const pushControllerAdapter = loadAdapter(push, ParsePushAdapter);
const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter); const loggerControllerAdapter = loadAdapter(loggerAdapter, FileLoggerAdapter);
const emailControllerAdapter = loadAdapter(emailAdapter);
// We pass the options and the base class for the adatper, // We pass the options and the base class for the adatper,
// Note that passing an instance would work too // Note that passing an instance would work too
const filesController = new FilesController(filesControllerAdapter); const filesController = new FilesController(filesControllerAdapter, appId);
const pushController = new PushController(pushControllerAdapter); const pushController = new PushController(pushControllerAdapter, appId);
const loggerController = new LoggerController(loggerControllerAdapter); const loggerController = new LoggerController(loggerControllerAdapter, appId);
const hooksController = new HooksController(appId, collectionPrefix); const hooksController = new HooksController(appId, collectionPrefix);
const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails });
cache.apps.set(appId, { cache.apps.set(appId, {
masterKey: masterKey, masterKey: masterKey,
serverURL: serverURL,
collectionPrefix: collectionPrefix, collectionPrefix: collectionPrefix,
clientKey: clientKey, clientKey: clientKey,
javascriptKey: javascriptKey, javascriptKey: javascriptKey,
@@ -139,25 +156,34 @@ function ParseServer({
pushController: pushController, pushController: pushController,
loggerController: loggerController, loggerController: loggerController,
hooksController: hooksController, hooksController: hooksController,
userController: userController,
verifyUserEmails: verifyUserEmails,
enableAnonymousUsers: enableAnonymousUsers, enableAnonymousUsers: enableAnonymousUsers,
allowClientClassCreation: allowClientClassCreation, 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) { if (process.env.FACEBOOK_APP_ID) {
cache.apps.get(appId)['facebookAppIds'].push(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. // This app serves the Parse API directly.
// It's the equivalent of https://api.parse.com/1 in the hosted Parse API. // It's the equivalent of https://api.parse.com/1 in the hosted Parse API.
var api = express(); var api = express();
//api.use("/apps", express.static(__dirname + "/public"));
// File handling needs to be before default middlewares are applied // File handling needs to be before default middlewares are applied
api.use('/', new FilesRouter().getExpressRouter({ api.use('/', new FilesRouter().getExpressRouter({
maxUploadSize: maxUploadSize maxUploadSize: maxUploadSize
})); }));
api.use('/', bodyParser.urlencoded({extended: false}), new PublicAPIRouter().expressApp());
// TODO: separate this from the regular ParseServer object // TODO: separate this from the regular ParseServer object
if (process.env.TESTING == 1) { if (process.env.TESTING == 1) {
api.use('/', require('./testing-routes').router); api.use('/', require('./testing-routes').router);
@@ -183,20 +209,22 @@ function ParseServer({
]; ];
if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { 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) { if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) {
routers.push(new HooksRouter()); routers.push(new HooksRouter());
} }
let appRouter = new PromiseRouter(); let routes = routers.reduce((memo, router) => {
routers.forEach((router) => { return memo.concat(router.routes);
appRouter.merge(router); }, []);
});
let appRouter = new PromiseRouter(routes);
batch.mountOnto(appRouter); batch.mountOnto(appRouter);
appRouter.mountOnto(api); api.use(appRouter.expressApp());
api.use(middlewares.handleParseErrors); api.use(middlewares.handleParseErrors);
@@ -222,5 +250,5 @@ function addParseCloud() {
module.exports = { module.exports = {
ParseServer: ParseServer, ParseServer: ParseServer,
S3Adapter: S3Adapter S3Adapter: S3Adapter,
}; };

View File

@@ -174,6 +174,9 @@ var handleParseErrors = function(err, req, res, next) {
res.status(httpStatus); res.status(httpStatus);
res.json({code: err.code, error: err.message}); res.json({code: err.code, error: err.message});
} else if (err.status && err.message) {
res.status(err.status);
res.json({error: err.message});
} else { } else {
console.log('Uncaught internal server error.', err, err.stack); console.log('Uncaught internal server error.', err, err.stack);
res.status(500); res.status(500);

View File

@@ -42,6 +42,12 @@ export function transformKeyValue(schema, className, restKey, restValue, options
key = '_updated_at'; key = '_updated_at';
timeField = true; timeField = true;
break; break;
case '_email_verify_token':
key = "_email_verify_token";
break;
case '_perishable_token':
key = "_perishable_token";
break;
case 'sessionToken': case 'sessionToken':
case '_session_token': case '_session_token':
key = '_session_token'; key = '_session_token';

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>