diff --git a/spec/MockEmailAdapter.js b/spec/MockEmailAdapter.js index e06e27cb..b143e37e 100644 --- a/spec/MockEmailAdapter.js +++ b/spec/MockEmailAdapter.js @@ -1,3 +1,5 @@ module.exports = { - sendVerificationEmail: () => Promise.resolve(); + sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), + sendMail: () => Promise.resolve() } diff --git a/spec/MockEmailAdapterWithOptions.js b/spec/MockEmailAdapterWithOptions.js index d5b6141a..8a3095e2 100644 --- a/spec/MockEmailAdapterWithOptions.js +++ b/spec/MockEmailAdapterWithOptions.js @@ -4,6 +4,7 @@ module.exports = options => { } return { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } } diff --git a/spec/ParseGlobalConfig.spec.js b/spec/ParseGlobalConfig.spec.js index 8c29ee48..8b739a78 100644 --- a/spec/ParseGlobalConfig.spec.js +++ b/spec/ParseGlobalConfig.spec.js @@ -2,13 +2,12 @@ var request = require('request'); var Parse = require('parse/node').Parse; -var DatabaseAdapter = require('../src/DatabaseAdapter'); - -let database = DatabaseAdapter.getDatabaseConnection('test', 'test_'); +let Config = require('../src/Config'); describe('a GlobalConfig', () => { beforeEach(function(done) { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.updateOne({ '_id': 1}, { $set: { params: { companies: ['US', 'DK'] } } }, { upsert: true })) .then(done()); }); @@ -61,7 +60,8 @@ describe('a GlobalConfig', () => { }); it('failed getting config when it is missing', (done) => { - database.rawCollection('_GlobalConfig') + let config = new Config('test'); + config.database.rawCollection('_GlobalConfig') .then(coll => coll.deleteOne({ '_id': 1}, {}, {})) .then(_ => { request.get({ diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 475622cf..8698fa36 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -52,6 +52,7 @@ describe('Parse.User testing', () => { it('sends verification email if email verification is enabled', done => { var emailAdapter = { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } setServerConfiguration({ @@ -91,6 +92,7 @@ describe('Parse.User testing', () => { it('does not send verification email if email verification is disabled', done => { var emailAdapter = { sendVerificationEmail: () => Promise.resolve(), + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => Promise.resolve() } setServerConfiguration({ @@ -134,6 +136,7 @@ describe('Parse.User testing', () => { expect(options.user.get('email')).toEqual('user@parse.com'); done(); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -176,9 +179,14 @@ describe('Parse.User testing', () => { .then(() => { expect(user.get('emailVerified')).toEqual(true); done(); + }, (err) => { + console.error(err); + fail("this should not fail"); + done(); }); }); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -237,6 +245,7 @@ describe('Parse.User testing', () => { }); }); }, + sendPasswordResetEmail: () => Promise.resolve(), sendMail: () => {} } setServerConfiguration({ @@ -270,6 +279,11 @@ describe('Parse.User testing', () => { success: function(user) { Parse.User.logIn("non_existent_user", "asdf3", expectError(Parse.Error.OBJECT_NOT_FOUND, done)); + }, + error: function(err) { + console.error(err); + fail("Shit should not fail"); + done(); } }); }); diff --git a/spec/PublicAPI.spec.js b/spec/PublicAPI.spec.js new file mode 100644 index 00000000..a61537d0 --- /dev/null +++ b/spec/PublicAPI.spec.js @@ -0,0 +1,36 @@ + +var request = require('request'); + + +describe("public API", () => { + + it("should get invalid_link.html", (done) => { + request('http://localhost:8378/1/apps/invalid_link.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get choose_password", (done) => { + request('http://localhost:8378/1/apps/choose_password', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get verify_email_success.html", (done) => { + request('http://localhost:8378/1/apps/verify_email_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + it("should get password_reset_success.html", (done) => { + request('http://localhost:8378/1/apps/password_reset_success.html', (err, httpResponse, body) => { + expect(httpResponse.statusCode).toBe(200); + done(); + }); + }); + + +}) \ No newline at end of file diff --git a/src/Adapters/Email/MailAdapter.js b/src/Adapters/Email/MailAdapter.js index ceccf931..ab8f1571 100644 --- a/src/Adapters/Email/MailAdapter.js +++ b/src/Adapters/Email/MailAdapter.js @@ -1,5 +1,6 @@ export class MailAdapter { sendVerificationEmail(options) {} + sendPasswordResetEmail(options) {} sendMail(options) {} } diff --git a/src/Adapters/Email/SimpleMailgunAdapter.js b/src/Adapters/Email/SimpleMailgunAdapter.js index f2460182..6720962f 100644 --- a/src/Adapters/Email/SimpleMailgunAdapter.js +++ b/src/Adapters/Email/SimpleMailgunAdapter.js @@ -37,6 +37,19 @@ let SimpleMailgunAdapter = mailgunOptions => { text: verifyMessage }); }, + + sendPasswordResetEmail: ({link,user, appName}) => { + let message = + "Hi,\n\n" + + "You requested to reset your password for " + appName + ".\n\n" + + "" + + "Click here to reset it:\n" + link; + return sendMail({ + to:user.email, + subject: 'Password Reset for ' + appName, + text: message + }); + }, sendMail: sendMail }); } diff --git a/src/Config.js b/src/Config.js index c31f62eb..c3d7317d 100644 --- a/src/Config.js +++ b/src/Config.js @@ -24,8 +24,6 @@ export class Config { this.allowClientClassCreation = cacheInfo.allowClientClassCreation; this.database = DatabaseAdapter.getDatabaseConnection(applicationId, cacheInfo.collectionPrefix); - this.mailController = cacheInfo.mailController; - this.serverURL = cacheInfo.serverURL; this.verifyUserEmails = cacheInfo.verifyUserEmails; this.appName = cacheInfo.appName; @@ -34,7 +32,7 @@ export class Config { this.filesController = cacheInfo.filesController; this.pushController = cacheInfo.pushController; this.loggerController = cacheInfo.loggerController; - this.mailController = cacheInfo.mailController; + this.userController = cacheInfo.userController; this.oauth = cacheInfo.oauth; this.mount = mount; @@ -49,7 +47,11 @@ export class Config { } get choosePasswordURL() { - return `${this.serverURL}/apps/choose_password`; + return `${this.serverURL}/apps/${this.applicationId}/choose_password`; + } + + get requestResetPasswordURL() { + return `${this.serverURL}/apps/${this.applicationId}/request_password_reset`; } get passwordResetSuccessURL() { diff --git a/src/Controllers/AdaptableController.js b/src/Controllers/AdaptableController.js index cfb0b9af..bfe0705c 100644 --- a/src/Controllers/AdaptableController.js +++ b/src/Controllers/AdaptableController.js @@ -10,11 +10,12 @@ based on the parameters passed // _adapter is private, use Symbol var _adapter = Symbol(); -import cache from '../cache'; +import Config from '../Config'; export class AdaptableController { - constructor(adapter, appId) { + constructor(adapter, appId, options) { + this.options = options; this.adapter = adapter; this.appId = appId; } @@ -29,7 +30,7 @@ export class AdaptableController { } get config() { - return cache.apps[this.appId]; + return new Config(this.appId); } expectedAdapterType() { diff --git a/src/Controllers/MailController.js b/src/Controllers/MailController.js deleted file mode 100644 index 47d008cc..00000000 --- a/src/Controllers/MailController.js +++ /dev/null @@ -1,30 +0,0 @@ -import AdaptableController from './AdaptableController'; -import { MailAdapter } from '../Adapters/Email/MailAdapter'; -import { randomString } from '../cryptoUtils'; -import { inflate } from '../triggers'; - -export class MailController extends AdaptableController { - setEmailVerificationStatus(user, status) { - if (status == false) { - user._email_verify_token = randomString(25); - } - user.emailVerified = status; - } - sendVerificationEmail(user, config) { - const token = encodeURIComponent(user._email_verify_token); - const username = encodeURIComponent(user.username); - - let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; - this.adapter.sendVerificationEmail({ - appName: config.appName, - link: link, - user: inflate('_User', user), - }); - } - sendMail(options) { - this.adapter.sendMail(options); - } - expectedAdapterType() { - return MailAdapter; - } -} diff --git a/src/Controllers/UserController.js b/src/Controllers/UserController.js index 62d6dd39..e9e0551d 100644 --- a/src/Controllers/UserController.js +++ b/src/Controllers/UserController.js @@ -1,31 +1,142 @@ +import { randomString } from '../cryptoUtils'; +import { inflate } from '../triggers'; +import AdaptableController from './AdaptableController'; +import MailAdapter from '../Adapters/Email/MailAdapter'; var DatabaseAdapter = require('../DatabaseAdapter'); -export class UserController { - - constructor(appId) { - this.appId = appId; +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) { - var database = DatabaseAdapter.getDatabaseConnection(this.appId); + + return new Promise((resolve, reject) => { + + // Trying to verify email when not enabled + if (!this.shouldVerifyEmails) { + reject(); + return; + } + + var database = this.config.database; + + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + username: username, + _email_verify_token: token, + }, null, {$set: {emailVerified: true}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); + }); + + }); + } + + checkResetTokenValidity(username, token) { + var database = this.config.database; return new Promise((resolve, reject) => { database.collection('_User').then(coll => { - // Need direct database access because verification token is not a parse field - return coll.findAndModify({ - username: username, - _email_verify_token: token, - }, null, {$set: {emailVerified: true}}, (err, doc) => { - if (err || !doc.value) { - reject(); - } else { - resolve(); - } + // Need direct database access because verification token is not a parse field + return coll.findOne({ + username: username, + _email_reset_token: token, + }, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + resolve(); + } + }); }); }); - + } + + setPasswordResetToken(email) { + var database = this.config.database; + var token = randomString(25); + return new Promise((resolve, reject) => { + database.collection('_User').then(coll => { + // Need direct database access because verification token is not a parse field + return coll.findAndModify({ + email: email, + }, null, {$set: {_email_reset_token: token}}, (err, doc) => { + if (err || !doc.value) { + reject(); + } else { + console.log(doc); + resolve(token); + } + }); + }); }); + } + + sendVerificationEmail(user, config = this.config) { + if (!this.shouldVerifyEmails) { + return; + } + const token = encodeURIComponent(user._email_verify_token); + const username = encodeURIComponent(user.username); + + let link = `${config.verifyEmailURL}?token=${token}&username=${username}`; + this.adapter.sendVerificationEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + + sendPasswordResetEmail(user, config = this.config) { + if (!this.adapter) { + return; + } + + const token = encodeURIComponent(user._email_reset_token); + const username = encodeURIComponent(user.username); + + let link = `${config.requestPasswordResetURL}?token=${token}&username=${username}` + this.adapter.sendPasswordResetEmail({ + appName: config.appName, + link: link, + user: inflate('_User', user), + }); + } + + sendMail(options) { + this.adapter.sendMail(options); } } diff --git a/src/PromiseRouter.js b/src/PromiseRouter.js index c3ca10ec..4070f706 100644 --- a/src/PromiseRouter.js +++ b/src/PromiseRouter.js @@ -167,16 +167,21 @@ function makeExpressHandler(promiseHandler) { JSON.stringify(req.body, null, 2)); } promiseHandler(req).then((result) => { - if (!result.response && !result.location) { + if (!result.response && !result.location && !result.text) { console.log('BUG: the handler did not include a "response" or a "location" field'); throw 'control should not get here'; } if (PromiseRouter.verbose) { - console.log('response:', JSON.stringify(result.response, null, 2)); + console.log('response:', JSON.stringify(result, null, 2)); } var status = result.status || 200; res.status(status); + + if (result.text) { + return res.send(result.text); + } + if (result.location && !result.response) { return res.redirect(result.location); } diff --git a/src/Routers/GlobalConfigRouter.js b/src/Routers/GlobalConfigRouter.js new file mode 100644 index 00000000..1fbde2d5 --- /dev/null +++ b/src/Routers/GlobalConfigRouter.js @@ -0,0 +1,48 @@ +// global_config.js + +var Parse = require('parse/node').Parse; + +import PromiseRouter from '../PromiseRouter'; + +export class GlobalConfigRouter extends PromiseRouter { + getGlobalConfig(req) { + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOne({'_id': 1})) + .then(globalConfig => ({response: { params: globalConfig.params }})) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config does not exist', + } + })); + } + updateGlobalConfig(req) { + if (!req.auth.isMaster) { + return Promise.resolve({ + status: 401, + response: {error: 'unauthorized'}, + }); + } + + return req.config.database.rawCollection('_GlobalConfig') + .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) + .then(response => { + return { response: { result: true } } + }) + .catch(() => ({ + status: 404, + response: { + code: Parse.Error.INVALID_KEY_NAME, + error: 'config cannot be updated', + } + })); + } + + mountRoutes() { + this.route('GET', '/config', req => { return this.getGlobalConfig(req) }); + this.route('PUT', '/config', req => { return this.updateGlobalConfig(req) }); + } +} + +export default GlobalConfigRouter; diff --git a/src/Routers/PublicAPIRouter.js b/src/Routers/PublicAPIRouter.js index 2b75d3f5..40c6180b 100644 --- a/src/Routers/PublicAPIRouter.js +++ b/src/Routers/PublicAPIRouter.js @@ -3,6 +3,10 @@ import UserController from '../Controllers/UserController'; import Config from '../Config'; import express from 'express'; import path from 'path'; +import fs from 'fs'; + +let public_html = path.resolve(__dirname, "../../public_html"); +let views = path.resolve(__dirname, '../../views'); export class PublicAPIRouter extends PromiseRouter { @@ -13,33 +17,75 @@ export class PublicAPIRouter extends PromiseRouter { var config = new Config(appId); if (!token || !username) { - return Promise.resolve({ - status: 302, - location: config.invalidLinkURL - }); + return this.invalidLink(req); } - let userController = new UserController(appId); + let userController = config.userController; return userController.verifyEmail(username, token, appId).then( () => { return Promise.resolve({ status: 302, location: `${config.verifyEmailSuccessURL}?username=${username}` }); }, ()=> { - return Promise.resolve({ - status: 302, - location: config.invalidLinkURL - }); + return this.invalidLink(req); }) } + changePassword(req) { + return new Promise((resolve, reject) => { + var config = new Config(req.params.appId); + // Should we keep the file in memory or leave like that? + fs.readFile(path.resolve(views, "choose_password"), 'utf-8', (err, data) => { + if (err) { + return reject(err); + } + data = data.replace("PARSE_SERVER_URL", `'${config.serverURL}'`); + resolve({ + text: data + }) + }); + }); + } + + resetPassword(req) { + var { username, token } = req.params; + + if (!username || !token) { + return this.invalidLink(req); + } + + let config = req.config; + return config.userController.checkResetTokenValidity(username, token).then( () => { + return Promise.resolve({ + status: 302, + location: `${config.choosePasswordURL}?token=${token}&id=${config.applicationId}&username=${username}` + }) + }, () => { + return this.invalidLink(req); + }) + } + + invalidLink(req) { + return Promise.resolve({ + status: 302, + location: req.config.invalidLinkURL + }); + } + + setConfig(req) { + req.config = new Config(req.params.appId); + return Promise.resolve(); + } + mountRoutes() { - this.route('GET','/apps/:appId/verify_email', req => { return this.verifyEmail(req); }); + this.route('GET','/apps/:appId/verify_email', this.setConfig, req => { return this.verifyEmail(req); }); + this.route('GET','/apps/choose_password', req => { return this.changePassword(req); }); + this.route('GET','/apps/:appId/request_password_reset', this.setConfig, req => { return this.resetPassword(req); }); } expressApp() { var router = express(); - router.use("/apps", express.static(path.resolve(__dirname, "../../public"))); + router.use("/apps", express.static(public_html)); router.use(super.expressApp()); return router; } diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 70a76bf5..72d14b3a 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -27,16 +27,14 @@ export class UsersRouter extends ClassesRouter { req.body = data; req.params.className = '_User'; - if (req.config.verifyUserEmails) { - req.config.mailController.setEmailVerificationStatus(req.body, false); - } + req.config.userController.setEmailVerifyToken(req.body); let p = super.handleCreate(req); - - if (req.config.verifyUserEmails) { + + if (req.config.verifyUserEmails) { // Send email as fire-and-forget once the user makes it into the DB. p.then(() => { - req.config.mailController.sendVerificationEmail(req.body, req.config); + req.config.userController.sendVerificationEmail(req.body, req.config); }); } return p; @@ -155,10 +153,23 @@ export class UsersRouter extends ClassesRouter { return Promise.resolve(success); } - handleReset(req) { + handleResetRequest(req) { + + let { email } = req.body.email; + if (!email) { + throw "Missing email"; + } let userController = req.config.userController; - return userController.requestPasswordReset(); + + return userController.sendPasswordResetEmail(email).then((token) => { + return Promise.resolve({ + response: {} + }) + }, (err) => { + throw new Parse.Error(Parse.Error.EMAIL_NOT_FOUND, `no user found with email ${email}`); + }); } + mountRoutes() { this.route('GET', '/users', req => { return this.handleFind(req); }); @@ -169,7 +180,7 @@ export class UsersRouter extends ClassesRouter { this.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); this.route('GET', '/login', req => { return this.handleLogIn(req); }); this.route('POST', '/logout', req => { return this.handleLogOut(req); }); - this.route('POST', '/requestPasswordReset', req => this.handleReset(req)); + this.route('POST', '/requestPasswordReset', req => { return this.handleResetRequest(req); }) } } diff --git a/src/global_config.js b/src/global_config.js deleted file mode 100644 index 0c005e4d..00000000 --- a/src/global_config.js +++ /dev/null @@ -1,46 +0,0 @@ -// global_config.js - -var Parse = require('parse/node').Parse; - -import PromiseRouter from './PromiseRouter'; -var router = new PromiseRouter(); - -function getGlobalConfig(req) { - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOne({'_id': 1})) - .then(globalConfig => ({response: { params: globalConfig.params }})) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config does not exist', - } - })); -} - -function updateGlobalConfig(req) { - if (!req.auth.isMaster) { - return Promise.resolve({ - status: 401, - response: {error: 'unauthorized'}, - }); - } - - return req.config.database.rawCollection('_GlobalConfig') - .then(coll => coll.findOneAndUpdate({ _id: 1 }, { $set: req.body })) - .then(response => { - return { response: { result: true } } - }) - .catch(() => ({ - status: 404, - response: { - code: Parse.Error.INVALID_KEY_NAME, - error: 'config cannot be updated', - } - })); -} - -router.route('GET', '/config', getGlobalConfig); -router.route('PUT', '/config', updateGlobalConfig); - -module.exports = router; diff --git a/src/index.js b/src/index.js index 219ec156..f4146312 100644 --- a/src/index.js +++ b/src/index.js @@ -19,7 +19,6 @@ import { AnalyticsRouter } from './Routers/AnalyticsRouter'; import { ClassesRouter } from './Routers/ClassesRouter'; import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; import { FilesController } from './Controllers/FilesController'; -import { MailController } from './Controllers/MailController'; import { FilesRouter } from './Routers/FilesRouter'; import { FunctionsRouter } from './Routers/FunctionsRouter'; import { GridStoreAdapter } from './Adapters/Files/GridStoreAdapter'; @@ -27,6 +26,7 @@ import { IAPValidationRouter } from './Routers/IAPValidationRouter'; import { LogsRouter } from './Routers/LogsRouter'; import { HooksRouter } from './Routers/HooksRouter'; import { PublicAPIRouter } from './Routers/PublicAPIRouter'; +import { GlobalConfigRouter } from './Routers/GlobalConfigRouter'; import { HooksController } from './Controllers/HooksController'; import { UserController } from './Controllers/UserController'; @@ -142,12 +142,8 @@ function ParseServer({ const pushController = new PushController(pushControllerAdapter, appId); const loggerController = new LoggerController(loggerControllerAdapter, appId); const hooksController = new HooksController(appId, collectionPrefix); - const userController = new UserController(appId); - let mailController; - - if (verifyUserEmails) { - mailController = new MailController(loadAdapter(emailAdapter)); - } + const userController = new UserController(emailControllerAdapter, appId, { verifyUserEmails }); + cache.apps.set(appId, { masterKey: masterKey, @@ -163,7 +159,7 @@ function ParseServer({ pushController: pushController, loggerController: loggerController, hooksController: hooksController, - mailController: mailController, + userController: userController, verifyUserEmails: verifyUserEmails, enableAnonymousUsers: enableAnonymousUsers, allowClientClassCreation: allowClientClassCreation, @@ -215,7 +211,7 @@ function ParseServer({ ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { - routers.push(require('./global_config')); + routers.push(new GlobalConfigRouter()); } if (process.env.PARSE_EXPERIMENTAL_HOOKS_ENABLED || process.env.TESTING) { @@ -231,7 +227,6 @@ function ParseServer({ batch.mountOnto(appRouter); api.use(appRouter.expressApp()); - appRouter.mountOnto(api); api.use(middlewares.handleParseErrors); diff --git a/public_html/choose_password.html b/views/choose_password similarity index 97% rename from public_html/choose_password.html rename to views/choose_password index b487862a..097cbd20 100644 --- a/public_html/choose_password.html +++ b/views/choose_password @@ -158,7 +158,8 @@ })(); var id = urlParams['id']; - document.getElementById('form').setAttribute('action', '/apps/' + id + '/request_password_reset'); + var base = PARSE_SERVER_URL; + document.getElementById('form').setAttribute('action', base + '/apps/' + id + '/request_password_reset'); document.getElementById('username').value = urlParams['username']; document.getElementById('username_label').appendChild(document.createTextNode(urlParams['username']));