From 4ddaac36bb4b0887878ae23298e6270818af9426 Mon Sep 17 00:00:00 2001 From: steven-supersolid Date: Wed, 10 Feb 2016 16:04:13 +0000 Subject: [PATCH 01/13] Expose installationId to Cloud Code request from req.info --- src/functions.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/functions.js b/src/functions.js index 09e43ed3..f8b8fbc9 100644 --- a/src/functions.js +++ b/src/functions.js @@ -22,6 +22,7 @@ function handleCloudFunction(req) { params: req.body || {}, master: req.auth && req.auth.isMaster, user: req.auth && req.auth.user, + installationId: req.info.installationId }; Parse.Cloud.Functions[req.params.functionName](request, response); }); From 90a4ac70acf780c5e328a9e6335c4d76e526111d Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Thu, 11 Feb 2016 20:32:31 -0500 Subject: [PATCH 02/13] Fix session token issue In _User collection a field _session_token is present and if you fetch the user data form server, this field override the sessionToken saved in your browser. If you don't fetch the user, all request to server contain the right sessionToken and if you fetch the user data from the server, all next requests will contain the wrong sessionToken come form the _session_token in user data fetched. --- src/RestQuery.js | 5 +++++ src/Routers/ClassesRouter.js | 5 +++++ src/users.js | 3 +++ 3 files changed, 13 insertions(+) diff --git a/src/RestQuery.js b/src/RestQuery.js index 91ebe536..7cf8074f 100644 --- a/src/RestQuery.js +++ b/src/RestQuery.js @@ -415,6 +415,11 @@ function includePath(config, auth, response, path) { for (var obj of includeResponse.results) { obj.__type = 'Object'; obj.className = className; + + if(className == "_User"){ + delete obj.sessionToken; + } + replace[obj.objectId] = obj; } var resp = { diff --git a/src/Routers/ClassesRouter.js b/src/Routers/ClassesRouter.js index 11666b20..a49d6d4a 100644 --- a/src/Routers/ClassesRouter.js +++ b/src/Routers/ClassesRouter.js @@ -51,6 +51,11 @@ export class ClassesRouter { if (!response.results || response.results.length == 0) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } + + if(req.params.className === "_User"){ + delete response.results[0].sessionToken; + } + return { response: response.results[0] }; }); } diff --git a/src/users.js b/src/users.js index 4205c666..9484ee64 100644 --- a/src/users.js +++ b/src/users.js @@ -133,6 +133,9 @@ function handleGet(req) { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found.'); } else { + if(req.params.className === "_User"){ + delete response.results[0].sessionToken; + } return {response: response.results[0]}; } }); From a75376523ca180bb6bae5effe8e45e492c2b6eea Mon Sep 17 00:00:00 2001 From: Wes Thomas Date: Wed, 10 Feb 2016 18:42:21 -0500 Subject: [PATCH 03/13] file DELETE support --- spec/ParseFile.spec.js | 89 ++++++++++++++++++++++++++ src/Adapters/Files/FilesAdapter.js | 2 + src/Adapters/Files/GridStoreAdapter.js | 11 ++++ src/Adapters/Files/S3Adapter.js | 14 ++++ src/Controllers/FilesController.js | 26 ++++++++ 5 files changed, 142 insertions(+) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index b65d8f34..7287dd14 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -33,6 +33,95 @@ describe('Parse.File testing', () => { }); }); + it('supports REST end-to-end file create, read, delete, read', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/testfile.txt', + body: 'check one two', + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.name).toMatch(/_testfile.txt$/); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*testfile.txt$/); + request.get(b.url, (error, response, body) => { + expect(error).toBe(null); + expect(body).toEqual('check one two'); + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'test' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(200); + request.get({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: b.url + }, (error, response, body) => { + expect(error).toBe(null); + expect(response.statusCode).toEqual(404); + done(); + }); + }); + }); + }); + }); + + it('blocks file deletions with missing or incorrect master-key header', done => { + var headers = { + 'Content-Type': 'image/jpeg', + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }; + request.post({ + headers: headers, + url: 'http://localhost:8378/1/files/thefile.jpg', + body: 'the file body' + }, (error, response, body) => { + expect(error).toBe(null); + var b = JSON.parse(body); + expect(b.url).toMatch(/^http:\/\/localhost:8378\/1\/files\/test\/.*thefile.jpg$/); + // missing X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b = JSON.parse(body); + expect(response.statusCode).toEqual(400); + expect(del_b.code).toEqual(119); + // incorrect X-Parse-Master-Key header + request.del({ + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'X-Parse-Master-Key': 'tryagain' + }, + url: 'http://localhost:8378/1/files/' + b.name + }, (error, response, body) => { + expect(error).toBe(null); + var del_b2 = JSON.parse(body); + expect(response.statusCode).toEqual(400); + expect(del_b2.code).toEqual(119); + done(); + }); + }); + }); + }); + it('handles other filetypes', done => { var headers = { 'Content-Type': 'image/jpeg', diff --git a/src/Adapters/Files/FilesAdapter.js b/src/Adapters/Files/FilesAdapter.js index 9daed517..a1d5955f 100644 --- a/src/Adapters/Files/FilesAdapter.js +++ b/src/Adapters/Files/FilesAdapter.js @@ -14,6 +14,8 @@ export class FilesAdapter { createFile(config, filename, data) { } + deleteFile(config, filename) { } + getFileData(config, filename) { } getFileLocation(config, filename) { } diff --git a/src/Adapters/Files/GridStoreAdapter.js b/src/Adapters/Files/GridStoreAdapter.js index 8c95319d..21934c9a 100644 --- a/src/Adapters/Files/GridStoreAdapter.js +++ b/src/Adapters/Files/GridStoreAdapter.js @@ -20,6 +20,17 @@ export class GridStoreAdapter extends FilesAdapter { }); } + deleteFile(config, filename) { + return config.database.connect().then(() => { + let gridStore = new GridStore(config.database.db, filename, 'w'); + return gridStore.open(); + }).then((gridStore) => { + return gridStore.unlink(); + }).then((gridStore) => { + return gridStore.close(); + }); + } + getFileData(config, filename) { return config.database.connect().then(() => { return GridStore.exist(config.database.db, filename); diff --git a/src/Adapters/Files/S3Adapter.js b/src/Adapters/Files/S3Adapter.js index 2c892246..b33b66f1 100644 --- a/src/Adapters/Files/S3Adapter.js +++ b/src/Adapters/Files/S3Adapter.js @@ -56,6 +56,20 @@ export class S3Adapter extends FilesAdapter { }); } + deleteFile(config, filename) { + return new Promise((resolve, reject) => { + let params = { + Key: this._bucketPrefix + filename + }; + this._s3Client.deleteObject(params, (err, data) =>{ + if(err !== null) { + return reject(err); + } + resolve(data); + }); + }); + } + // Search for and return a file if found by filename // Returns a promise that succeeds with the buffer result from S3 getFileData(config, filename) { diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 47454f07..321042b9 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -74,6 +74,26 @@ export class FilesController { }; } + deleteHandler() { + return (req, res, next) => { + // enforce use of master key for file deletions + if(!req.auth.isMaster){ + next(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, + 'Master key required for file deletion.')); + return; + } + + this._filesAdapter.deleteFile(req.config, req.params.filename).then(() => { + res.status(200); + // TODO: return useful JSON here? + res.end(); + }).catch((error) => { + next(new Parse.Error(Parse.Error.FILE_DELETE_ERROR, + 'Could not delete file.')); + }); + }; + } + /** * Find file references in REST-format object and adds the url key * with the current mount point and app id. @@ -119,6 +139,12 @@ export class FilesController { this.createHandler() ); + router.delete('/files/:filename', + Middlewares.allowCrossDomain, + Middlewares.handleParseHeaders, + this.deleteHandler() + ); + return router; } } From ab841b5ab475b32a11549c942a9e4663f63b9163 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 20:01:14 -0800 Subject: [PATCH 04/13] Refactor and deduplicate logic in UsersRouter. --- src/Routers/UsersRouter.js | 163 ++++++++++++++++++++++++++++ src/index.js | 3 +- src/users.js | 212 ------------------------------------- 3 files changed, 165 insertions(+), 213 deletions(-) create mode 100644 src/Routers/UsersRouter.js delete mode 100644 src/users.js diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js new file mode 100644 index 00000000..4f22e07a --- /dev/null +++ b/src/Routers/UsersRouter.js @@ -0,0 +1,163 @@ +// These methods handle the User-related routes. + +import hat from 'hat'; +import deepcopy from 'deepcopy'; + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; +import passwordCrypto from '../password'; +import RestWrite from '../RestWrite'; + +const rack = hat.rack(); + +export class UsersRouter extends ClassesRouter { + handleFind(req) { + req.params.className = '_User'; + return super.handleFind(req); + } + + handleGet(req) { + req.params.className = '_User'; + return super.handleGet(req); + } + + handleCreate(req) { + let data = deepcopy(req.body); + data.installationId = req.info.installationId; + req.body = data; + req.params.className = '_User'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_User'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_User'; + return super.handleDelete(req); + } + + handleMe(req) { + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken }, + { include: 'user' }) + .then((response) => { + if (!response.results || + response.results.length == 0 || + !response.results[0].user) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, + 'Object not found.'); + } else { + let user = response.results[0].user; + return { response: user }; + } + }); + } + + handleLogIn(req) { + // Use query parameters instead if provided in url + if (!req.body.username && req.query.username) { + req.body = req.query; + } + + // TODO: use the right error codes / descriptions. + if (!req.body.username) { + throw new Parse.Error(Parse.Error.USERNAME_MISSING, 'username is required.'); + } + if (!req.body.password) { + throw new Parse.Error(Parse.Error.PASSWORD_MISSING, 'password is required.'); + } + + let user; + return req.database.find('_User', { username: req.body.username }) + .then((results) => { + if (!results.length) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + user = results[0]; + return passwordCrypto.compare(req.body.password, user.password); + }).then((correct) => { + if (!correct) { + throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); + } + + let token = 'r:' + rack(); + user.sessionToken = token; + delete user.password; + + req.config.filesController.expandFilesInObject(req.config, user); + + let expiresAt = new Date(); + expiresAt.setFullYear(expiresAt.getFullYear() + 1); + + let sessionData = { + sessionToken: token, + user: { + __type: 'Pointer', + className: '_User', + objectId: user.objectId + }, + createdWith: { + 'action': 'login', + 'authProvider': 'password' + }, + restricted: false, + expiresAt: Parse._encode(expiresAt) + }; + + if (req.info.installationId) { + sessionData.installationId = req.info.installationId + } + + let create = new RestWrite(req.config, Auth.master(req.config), '_Session', null, sessionData); + return create.execute(); + }).then(() => { + return { response: user }; + }); + } + + handleLogOut(req) { + let success = {response: {}}; + if (req.info && req.info.sessionToken) { + return rest.find(req.config, Auth.master(req.config), '_Session', + { _session_token: req.info.sessionToken } + ).then((records) => { + if (records.results && records.results.length) { + return rest.del(req.config, Auth.master(req.config), '_Session', + records.results[0].objectId + ).then(() => { + return Promise.resolve(success); + }); + } + return Promise.resolve(success); + }); + } + return Promise.resolve(success); + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET', '/users', req => { return this.handleFind(req); }); + router.route('POST', '/users', req => { return this.handleCreate(req); }); + router.route('GET', '/users/:objectId', req => { return this.handleGet(req); }); + router.route('PUT', '/users/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE', '/users/:objectId', req => { return this.handleDelete(req); }); + router.route('GET', '/users/me', req => { return this.handleMe(req); }); + router.route('GET', '/login', req => { return this.handleLogIn(req); }); + router.route('POST', '/logout', req => { return this.handleLogOut(req); }); + router.route('POST', '/requestPasswordReset', () => { + throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, 'This path is not implemented yet.'); + }); + return router; + } +} + +export default UsersRouter; diff --git a/src/index.js b/src/index.js index c2993400..fcc573e2 100644 --- a/src/index.js +++ b/src/index.js @@ -20,6 +20,7 @@ import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { UsersRouter } from './Routers/UsersRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -129,7 +130,7 @@ function ParseServer(args) { let routers = [ new ClassesRouter().getExpressRouter(), - require('./users'), + new UsersRouter().getExpressRouter(), require('./sessions'), require('./roles'), require('./analytics'), diff --git a/src/users.js b/src/users.js deleted file mode 100644 index 4205c666..00000000 --- a/src/users.js +++ /dev/null @@ -1,212 +0,0 @@ -// These methods handle the User-related routes. - -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; -var rack = require('hat').rack(); - -var Auth = require('./Auth'); -var passwordCrypto = require('./password'); -var facebook = require('./facebook'); -var PromiseRouter = require('./PromiseRouter'); -var rest = require('./rest'); -var RestWrite = require('./RestWrite'); -var deepcopy = require('deepcopy'); - -var router = new PromiseRouter(); - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - var data = deepcopy(req.body); - data.installationId = req.info.installationId; - return rest.create(req.config, req.auth, - '_User', data); -} - -// Returns a promise for a {response} object. -function handleLogIn(req) { - - // Use query parameters instead if provided in url - if (!req.body.username && req.query.username) { - req.body = req.query; - } - - // TODO: use the right error codes / descriptions. - if (!req.body.username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'username is required.'); - } - if (!req.body.password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required.'); - } - - var user; - return req.database.find('_User', {username: req.body.username}) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - user = results[0]; - return passwordCrypto.compare(req.body.password, user.password); - }).then((correct) => { - if (!correct) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - var token = 'r:' + rack(); - user.sessionToken = token; - delete user.password; - - req.config.filesController.expandFilesInObject(req.config, user); - - var expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId - } - - var create = new RestWrite(req.config, Auth.master(req.config), - '_Session', null, sessionData); - return create.execute(); - }).then(() => { - return {response: user}; - }); -} - -// Returns a promise that resolves to a {response} object. -// TODO: share code with ClassesRouter.js -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - if (req.body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); - } - - return rest.find(req.config, req.auth, - '_User', req.body.where, options) - .then((response) => { - return {response: response}; - }); - -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, '_User', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -function handleMe(req) { - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken}, - {include: 'user'}) - .then((response) => { - if (!response.results || response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - var user = response.results[0].user; - return {response: user}; - } - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - req.params.className, req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleLogOut(req) { - var success = {response: {}}; - if (req.info && req.info.sessionToken) { - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken} - ).then((records) => { - if (records.results && records.results.length) { - return rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].objectId - ).then(() => { - return Promise.resolve(success); - }); - } - return Promise.resolve(success); - }); - } - return Promise.resolve(success); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_User', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); -} - -router.route('POST', '/users', handleCreate); -router.route('GET', '/login', handleLogIn); -router.route('POST', '/logout', handleLogOut); -router.route('GET', '/users/me', handleMe); -router.route('GET', '/users/:objectId', handleGet); -router.route('PUT', '/users/:objectId', handleUpdate); -router.route('GET', '/users', handleFind); -router.route('DELETE', '/users/:objectId', handleDelete); - -router.route('POST', '/requestPasswordReset', notImplementedYet); - -module.exports = router; From 99ac6c11610ea42bde65aac4b1b496d53c6e3bea Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 20:40:15 -0800 Subject: [PATCH 05/13] Refactor and deduplicate logic in SessionsRouter. --- src/Routers/SessionsRouter.js | 63 ++++++++++++++++++++++ src/index.js | 3 +- src/sessions.js | 98 ----------------------------------- 3 files changed, 65 insertions(+), 99 deletions(-) create mode 100644 src/Routers/SessionsRouter.js delete mode 100644 src/sessions.js diff --git a/src/Routers/SessionsRouter.js b/src/Routers/SessionsRouter.js new file mode 100644 index 00000000..ecffd80a --- /dev/null +++ b/src/Routers/SessionsRouter.js @@ -0,0 +1,63 @@ + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; +import Auth from '../Auth'; + +export class SessionsRouter extends ClassesRouter { + handleFind(req) { + req.params.className = '_Session'; + return super.handleFind(req); + } + + handleGet(req) { + req.params.className = '_Session'; + return super.handleGet(req); + } + + handleCreate(req) { + req.params.className = '_Session'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_Session'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_Session'; + return super.handleDelete(req); + } + + handleMe(req) { + // TODO: Verify correct behavior + if (!req.info || !req.info.sessionToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token required.'); + } + return rest.find(req.config, Auth.master(req.config), '_Session', { _session_token: req.info.sessionToken }) + .then((response) => { + if (!response.results || response.results.length == 0) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, + 'Session token not found.'); + } + return { + response: response.results[0] + }; + }); + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET','/sessions/me', req => { return this.handleMe(req); }); + router.route('GET', '/sessions', req => { return this.handleFind(req); }); + router.route('GET', '/sessions/:objectId', req => { return this.handleGet(req); }); + router.route('POST', '/sessions', req => { return this.handleCreate(req); }); + router.route('PUT', '/sessions/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE', '/sessions/:objectId', req => { return this.handleDelete(req); }); + return router; + } +} + +export default SessionsRouter; diff --git a/src/index.js b/src/index.js index fcc573e2..9d9910bd 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,7 @@ import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; import { UsersRouter } from './Routers/UsersRouter'; +import { SessionsRouter } from './Routers/SessionsRouter'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -131,7 +132,7 @@ function ParseServer(args) { let routers = [ new ClassesRouter().getExpressRouter(), new UsersRouter().getExpressRouter(), - require('./sessions'), + new SessionsRouter().getExpressRouter(), require('./roles'), require('./analytics'), new InstallationsRouter().getExpressRouter(), diff --git a/src/sessions.js b/src/sessions.js deleted file mode 100644 index b979de45..00000000 --- a/src/sessions.js +++ /dev/null @@ -1,98 +0,0 @@ -// sessions.js - -var Auth = require('./Auth'), - Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -function handleCreate(req) { - return rest.create(req.config, req.auth, - '_Session', req.body); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_Session', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - '_Session', req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleGet(req) { - return rest.find(req.config, req.auth, '_Session', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - - return rest.find(req.config, req.auth, - '_Session', req.body.where, options) - .then((response) => { - return {response: response}; - }); -} - -function handleMe(req) { - // TODO: Verify correct behavior - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token required.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - { _session_token: req.info.sessionToken}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, - 'Session token not found.'); - } - return { - response: response.results[0] - }; - }); -} - -router.route('POST','/sessions', handleCreate); -router.route('GET','/sessions/me', handleMe); -router.route('GET','/sessions/:objectId', handleGet); -router.route('PUT','/sessions/:objectId', handleUpdate); -router.route('GET','/sessions', handleFind); -router.route('DELETE','/sessions/:objectId', handleDelete); - -module.exports = router; From b2570a9af85a131f4f86f3683c603133f54557b7 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 23:17:20 -0800 Subject: [PATCH 06/13] Update style in InstallationsRouter. --- src/Routers/InstallationsRouter.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Routers/InstallationsRouter.js b/src/Routers/InstallationsRouter.js index 033366b7..fca703e9 100644 --- a/src/Routers/InstallationsRouter.js +++ b/src/Routers/InstallationsRouter.js @@ -51,12 +51,12 @@ export class InstallationsRouter extends ClassesRouter { } getExpressRouter() { - var router = new PromiseRouter(); - router.route('GET','/installations', (req) => { return this.handleFind(req); }); - router.route('GET','/installations/:objectId', (req) => { return this.handleGet(req); }); - router.route('POST','/installations', (req) => { return this.handleCreate(req); }); - router.route('PUT','/installations/:objectId', (req) => { return this.handleUpdate(req); }); - router.route('DELETE','/installations/:objectId', (req) => { return this.handleDelete(req); }); + let router = new PromiseRouter(); + router.route('GET','/installations', req => { return this.handleFind(req); }); + router.route('GET','/installations/:objectId', req => { return this.handleGet(req); }); + router.route('POST','/installations', req => { return this.handleCreate(req); }); + router.route('PUT','/installations/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE','/installations/:objectId', req => { return this.handleDelete(req); }); return router; } } From f53cb60d57f1eddedd3c4846b991ec5b8aa9ae62 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 21:53:32 -0800 Subject: [PATCH 07/13] Add enforceMasterKeyAccess middleware. --- spec/ParseFile.spec.js | 8 ++++---- src/Controllers/FilesController.js | 8 +------- src/middlewares.js | 13 +++++++++++-- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/spec/ParseFile.spec.js b/spec/ParseFile.spec.js index 7287dd14..8613f3a2 100644 --- a/spec/ParseFile.spec.js +++ b/spec/ParseFile.spec.js @@ -101,8 +101,8 @@ describe('Parse.File testing', () => { }, (error, response, body) => { expect(error).toBe(null); var del_b = JSON.parse(body); - expect(response.statusCode).toEqual(400); - expect(del_b.code).toEqual(119); + expect(response.statusCode).toEqual(403); + expect(del_b.error).toMatch(/unauthorized/); // incorrect X-Parse-Master-Key header request.del({ headers: { @@ -114,8 +114,8 @@ describe('Parse.File testing', () => { }, (error, response, body) => { expect(error).toBe(null); var del_b2 = JSON.parse(body); - expect(response.statusCode).toEqual(400); - expect(del_b2.code).toEqual(119); + expect(response.statusCode).toEqual(403); + expect(del_b2.error).toMatch(/unauthorized/); done(); }); }); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index 321042b9..dac6b684 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -76,13 +76,6 @@ export class FilesController { deleteHandler() { return (req, res, next) => { - // enforce use of master key for file deletions - if(!req.auth.isMaster){ - next(new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, - 'Master key required for file deletion.')); - return; - } - this._filesAdapter.deleteFile(req.config, req.params.filename).then(() => { res.status(200); // TODO: return useful JSON here? @@ -142,6 +135,7 @@ export class FilesController { router.delete('/files/:filename', Middlewares.allowCrossDomain, Middlewares.handleParseHeaders, + Middlewares.enforceMasterKeyAccess, this.deleteHandler() ); diff --git a/src/middlewares.js b/src/middlewares.js index bb251239..a07b2a1b 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -178,15 +178,24 @@ var handleParseErrors = function(err, req, res, next) { } }; +function enforceMasterKeyAccess(req, res, next) { + if (!req.auth.isMaster) { + res.status(403); + res.end('{"error":"unauthorized: master key is required"}'); + return; + } + next(); +} + function invalidRequest(req, res) { res.status(403); res.end('{"error":"unauthorized"}'); } - module.exports = { allowCrossDomain: allowCrossDomain, allowMethodOverride: allowMethodOverride, handleParseErrors: handleParseErrors, - handleParseHeaders: handleParseHeaders + handleParseHeaders: handleParseHeaders, + enforceMasterKeyAccess: enforceMasterKeyAccess }; From 62e671dd9e6e495da79f4d7051935960d2e442d0 Mon Sep 17 00:00:00 2001 From: Nikita Lutsenko Date: Thu, 11 Feb 2016 21:35:56 -0800 Subject: [PATCH 08/13] Refactor and deduplicate RolesRouter, fix missing query on /roles. --- src/Routers/RolesRouter.js | 43 ++++++++++++++++++++++++++++++++++ src/index.js | 3 ++- src/roles.js | 48 -------------------------------------- 3 files changed, 45 insertions(+), 49 deletions(-) create mode 100644 src/Routers/RolesRouter.js delete mode 100644 src/roles.js diff --git a/src/Routers/RolesRouter.js b/src/Routers/RolesRouter.js new file mode 100644 index 00000000..b20a91ee --- /dev/null +++ b/src/Routers/RolesRouter.js @@ -0,0 +1,43 @@ + +import ClassesRouter from './ClassesRouter'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; + +export class RolesRouter extends ClassesRouter { + handleFind(req) { + req.params.className = '_Role'; + return super.handleFind(req); + } + + handleGet(req) { + req.params.className = '_Role'; + return super.handleGet(req); + } + + handleCreate(req) { + req.params.className = '_Role'; + return super.handleCreate(req); + } + + handleUpdate(req) { + req.params.className = '_Role'; + return super.handleUpdate(req); + } + + handleDelete(req) { + req.params.className = '_Role'; + return super.handleDelete(req); + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET','/roles', req => { return this.handleFind(req); }); + router.route('GET','/roles/:objectId', req => { return this.handleGet(req); }); + router.route('POST','/roles', req => { return this.handleCreate(req); }); + router.route('PUT','/roles/:objectId', req => { return this.handleUpdate(req); }); + router.route('DELETE','/roles/:objectId', req => { return this.handleDelete(req); }); + return router; + } +} + +export default RolesRouter; diff --git a/src/index.js b/src/index.js index 9d9910bd..952c1c05 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,7 @@ 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'; // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -133,7 +134,7 @@ function ParseServer(args) { new ClassesRouter().getExpressRouter(), new UsersRouter().getExpressRouter(), new SessionsRouter().getExpressRouter(), - require('./roles'), + new RolesRouter().getExpressRouter(), require('./analytics'), new InstallationsRouter().getExpressRouter(), require('./functions'), diff --git a/src/roles.js b/src/roles.js deleted file mode 100644 index 6aaf8065..00000000 --- a/src/roles.js +++ /dev/null @@ -1,48 +0,0 @@ -// roles.js - -var Parse = require('parse/node').Parse, - PromiseRouter = require('./PromiseRouter'), - rest = require('./rest'); - -var router = new PromiseRouter(); - -function handleCreate(req) { - return rest.create(req.config, req.auth, - '_Role', req.body); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_Role', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - '_Role', req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleGet(req) { - return rest.find(req.config, req.auth, '_Role', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - return {response: response.results[0]}; - } - }); -} - -router.route('POST','/roles', handleCreate); -router.route('GET','/roles/:objectId', handleGet); -router.route('PUT','/roles/:objectId', handleUpdate); -router.route('DELETE','/roles/:objectId', handleDelete); - -module.exports = router; \ No newline at end of file From 56552537a1b6af6d6cb1680510aa75e287604446 Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Fri, 12 Feb 2016 08:15:58 -0500 Subject: [PATCH 09/13] Add test to the session token hasn't changed --- spec/ParseUser.spec.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 368bea22..787a8ecb 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -1358,6 +1358,25 @@ describe('Parse.User testing', () => { }); }); + it('retrieve user data from fetch, make sure the session token hasn\'t changed', (done) => { + var user = new Parse.User(); + user.setPassword("asdf"); + user.setUsername("zxcv"); + var currentSessionToken = ""; + Parse.Promise.as().then(function() { + return user.signUp(); + }).then(function(){ + currentSessionToken = user.getSessionToken(); + return user.fetch(); + }).then(function(u){ + expect(currentSessionToken).toEqual(u.getSessionToken()); + done(); + }, function(error) { + ok(false, error); + done(); + }) + }); + it('user save should fail with invalid email', (done) => { var user = new Parse.User(); user.set('username', 'teste'); From dc4859f561dfae078d4529d8cfa4405ac4daf4c9 Mon Sep 17 00:00:00 2001 From: Peter Shin Date: Thu, 4 Feb 2016 08:18:19 -0800 Subject: [PATCH 10/13] Logs support. Added /logs endpoint with basic logger and LoggerAdapter. --- package.json | 3 +- spec/FileLoggerAdapter.spec.js | 64 +++++++ spec/LoggerController.spec.js | 55 ++++++ src/Adapters/Logger/FileLoggerAdapter.js | 225 +++++++++++++++++++++++ src/Adapters/Logger/LoggerAdapter.js | 17 ++ src/Controllers/LoggerController.js | 78 ++++++++ src/index.js | 9 +- 7 files changed, 449 insertions(+), 2 deletions(-) create mode 100644 spec/FileLoggerAdapter.spec.js create mode 100644 spec/LoggerController.spec.js create mode 100644 src/Adapters/Logger/FileLoggerAdapter.js create mode 100644 src/Adapters/Logger/LoggerAdapter.js create mode 100644 src/Controllers/LoggerController.js diff --git a/package.json b/package.json index 521381f0..97467963 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "node-gcm": "^0.14.0", "parse": "^1.7.0", "randomstring": "^1.1.3", - "request": "^2.65.0" + "request": "^2.65.0", + "winston": "^2.1.1" }, "devDependencies": { "babel-cli": "^6.5.1", diff --git a/spec/FileLoggerAdapter.spec.js b/spec/FileLoggerAdapter.spec.js new file mode 100644 index 00000000..4466e087 --- /dev/null +++ b/spec/FileLoggerAdapter.spec.js @@ -0,0 +1,64 @@ +var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; +var Parse = require('parse/node').Parse; +var request = require('request'); +var fs = require('fs'); + +var LOGS_FOLDER = './test_logs/'; + +var deleteFolderRecursive = function(path) { + if( fs.existsSync(path) ) { + fs.readdirSync(path).forEach(function(file,index){ + var curPath = path + "/" + file; + if(fs.lstatSync(curPath).isDirectory()) { // recurse + deleteFolderRecursive(curPath); + } else { // delete file + fs.unlinkSync(curPath); + } + }); + fs.rmdirSync(path); + } +}; + +describe('info logs', () => { + + afterEach((done) => { + deleteFolderRecursive(LOGS_FOLDER); + done(); + }); + + it("Verify INFO logs", (done) => { + var fileLoggerAdapter = new FileLoggerAdapter({ + logsFolder: LOGS_FOLDER + }); + fileLoggerAdapter.info('testing info logs', () => { + fileLoggerAdapter.query({ + size: 1, + level: 'info' + }, (results) => { + expect(results[0].message).toEqual('testing info logs'); + done(); + }); + }); + }); +}); + +describe('error logs', () => { + + afterEach((done) => { + deleteFolderRecursive(LOGS_FOLDER); + done(); + }); + + it("Verify ERROR logs", (done) => { + var fileLoggerAdapter = new FileLoggerAdapter(); + fileLoggerAdapter.error('testing error logs', () => { + fileLoggerAdapter.query({ + size: 1, + level: 'error' + }, (results) => { + expect(results[0].message).toEqual('testing error logs'); + done(); + }); + }); + }); +}); diff --git a/spec/LoggerController.spec.js b/spec/LoggerController.spec.js new file mode 100644 index 00000000..f23004ab --- /dev/null +++ b/spec/LoggerController.spec.js @@ -0,0 +1,55 @@ +var LoggerController = require('../src/Controllers/LoggerController').LoggerController; +var FileLoggerAdapter = require('../src/Adapters/Logger/FileLoggerAdapter').FileLoggerAdapter; + +describe('LoggerController', () => { + it('can check valid master key of request', (done) => { + // Make mock request + var request = { + auth: { + isMaster: true + }, + query: {} + }; + + var loggerController = new LoggerController(new FileLoggerAdapter()); + + expect(() => { + loggerController.handleGET(request); + }).not.toThrow(); + done(); + }); + + it('can check invalid construction of controller', (done) => { + // Make mock request + var request = { + auth: { + isMaster: true + }, + query: {} + }; + + var loggerController = new LoggerController(); + + expect(() => { + loggerController.handleGET(request); + }).toThrow(); + done(); + }); + + it('can check invalid master key of request', (done) => { + // Make mock request + var request = { + auth: { + isMaster: false + }, + query: {} + }; + + var loggerController = new LoggerController(new FileLoggerAdapter()); + + expect(() => { + loggerController.handleGET(request); + }).toThrow(); + done(); + }); +}); diff --git a/src/Adapters/Logger/FileLoggerAdapter.js b/src/Adapters/Logger/FileLoggerAdapter.js new file mode 100644 index 00000000..4edc4122 --- /dev/null +++ b/src/Adapters/Logger/FileLoggerAdapter.js @@ -0,0 +1,225 @@ +// Logger +// +// Wrapper around Winston logging library with custom query +// +// expected log entry to be in the shape of: +// {"level":"info","message":"{ '0': 'Your Message' }","timestamp":"2016-02-04T05:59:27.412Z"} +// +import { LoggerAdapter } from './LoggerAdapter'; +import winston from 'winston'; +import fs from 'fs'; +import { Parse } from 'parse/node'; + +const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; +const CACHE_TIME = 1000 * 60; + +let LOGS_FOLDER = './logs/'; + +if (typeof process !== 'undefined' && process.env.NODE_ENV === 'test') { + LOGS_FOLDER = './test_logs/' +} + +let currentDate = new Date(); + +let simpleCache = { + timestamp: null, + from: null, + until: null, + order: null, + data: [], + level: 'info', +}; + +// returns Date object rounded to nearest day +let _getNearestDay = (date) => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +// returns Date object of previous day +let _getPrevDay = (date) => { + return new Date(date - MILLISECONDS_IN_A_DAY); +} + +// returns the iso formatted file name +let _getFileName = () => { + return _getNearestDay(currentDate).toISOString() +} + +// check for valid cache when both from and util match. +// cache valid for up to 1 minute +let _hasValidCache = (from, until, level) => { + if (String(from) === String(simpleCache.from) && + String(until) === String(simpleCache.until) && + new Date() - simpleCache.timestamp < CACHE_TIME && + level === simpleCache.level) { + return true; + } + return false; +} + +// renews transports to current date +let _renewTransports = ({infoLogger, errorLogger, logsFolder}) => { + if (infoLogger) { + infoLogger.add(winston.transports.File, { + filename: logsFolder + _getFileName() + '.info', + name: 'info-file', + level: 'info' + }); + } + if (errorLogger) { + errorLogger.add(winston.transports.File, { + filename: logsFolder + _getFileName() + '.error', + name: 'error-file', + level: 'error' + }); + } +}; + +// check that log entry has valid time stamp based on query +let _isValidLogEntry = (from, until, entry) => { + var _entry = JSON.parse(entry), + timestamp = new Date(_entry.timestamp); + return timestamp >= from && timestamp <= until + ? true + : false +}; + +// ensure that file name is up to date +let _verifyTransports = ({infoLogger, errorLogger, logsFolder}) => { + if (_getNearestDay(currentDate) !== _getNearestDay(new Date())) { + currentDate = new Date(); + if (infoLogger) { + infoLogger.remove('info-file'); + } + if (errorLogger) { + errorLogger.remove('error-file'); + } + _renewTransports({infoLogger, errorLogger, logsFolder}); + } +} + +export class FileLoggerAdapter extends LoggerAdapter { + constructor(options = {}) { + super(); + + this._logsFolder = options.logsFolder || LOGS_FOLDER; + + // check logs folder exists + if (!fs.existsSync(this._logsFolder)) { + fs.mkdirSync(this._logsFolder); + } + + this._errorLogger = new (winston.Logger)({ + exitOnError: false, + transports: [ + new (winston.transports.File)({ + filename: this._logsFolder + _getFileName() + '.error', + name: 'error-file', + level: 'error' + }) + ] + }); + + this._infoLogger = new (winston.Logger)({ + exitOnError: false, + transports: [ + new (winston.transports.File)({ + filename: this._logsFolder + _getFileName() + '.info', + name: 'info-file', + level: 'info' + }) + ] + }); + } + + info() { + _verifyTransports({infoLogger: this._infoLogger, logsFolder: this._logsFolder}); + return this._infoLogger.info.apply(undefined, arguments); + } + + error() { + _verifyTransports({errorLogger: this._errorLogger, logsFolder: this._logsFolder}); + return this._errorLogger.error.apply(undefined, arguments); + } + + // custom query as winston is currently limited + query(options, callback) { + if (!options) { + options = {}; + } + // defaults to 7 days prior + let from = options.from || new Date(Date.now() - (7 * MILLISECONDS_IN_A_DAY)); + let until = options.until || new Date(); + let size = options.size || 10; + let order = options.order || 'desc'; + let level = options.level || 'info'; + let roundedUntil = _getNearestDay(until); + let roundedFrom = _getNearestDay(from); + + if (_hasValidCache(roundedFrom, roundedUntil, level)) { + let logs = []; + if (order !== simpleCache.order) { + // reverse order of data + simpleCache.data.forEach((entry) => { + logs.unshift(entry); + }); + } else { + logs = simpleCache.data; + } + callback(logs.slice(0, size)); + return; + } + + let curDate = roundedUntil; + let curSize = 0; + let method = order === 'desc' ? 'push' : 'unshift'; + let files = []; + let promises = []; + + // current a batch call, all files with valid dates are read + while (curDate >= from) { + files[method](this._logsFolder + curDate.toISOString() + '.' + level); + curDate = _getPrevDay(curDate); + } + + // read each file and split based on newline char. + // limitation is message cannot contain newline + // TODO: strip out delimiter from logged message + files.forEach(function(file, i) { + let promise = new Parse.Promise(); + fs.readFile(file, 'utf8', function(err, data) { + if (err) { + promise.resolve([]); + } else { + let results = data.split('\n').filter((value) => { + return value.trim() !== ''; + }); + promise.resolve(results); + } + }); + promises[method](promise); + }); + + Parse.Promise.when(promises).then((results) => { + let logs = []; + results.forEach(function(logEntries, i) { + logEntries.forEach(function(entry) { + if (_isValidLogEntry(from, until, entry)) { + logs[method](JSON.parse(entry)); + } + }); + }); + simpleCache = { + timestamp: new Date(), + from: roundedFrom, + until: roundedUntil, + data: logs, + order, + level, + }; + callback(logs.slice(0, size)); + }); + } +} + +export default FileLoggerAdapter; diff --git a/src/Adapters/Logger/LoggerAdapter.js b/src/Adapters/Logger/LoggerAdapter.js new file mode 100644 index 00000000..b1fe31b8 --- /dev/null +++ b/src/Adapters/Logger/LoggerAdapter.js @@ -0,0 +1,17 @@ +// Logger Adapter +// +// Allows you to change the logger mechanism +// +// Adapter classes must implement the following functions: +// * info(obj1 [, obj2, .., objN]) +// * error(obj1 [, obj2, .., objN]) +// * query(options, callback) +// Default is FileLoggerAdapter.js + +export class LoggerAdapter { + info() {} + error() {} + query(options, callback) {} +} + +export default LoggerAdapter; diff --git a/src/Controllers/LoggerController.js b/src/Controllers/LoggerController.js new file mode 100644 index 00000000..d0b8bb28 --- /dev/null +++ b/src/Controllers/LoggerController.js @@ -0,0 +1,78 @@ +import { Parse } from 'parse/node'; +import PromiseRouter from '../PromiseRouter'; +import rest from '../rest'; + +const Promise = Parse.Promise; +const INFO = 'info'; +const ERROR = 'error'; +const MILLISECONDS_IN_A_DAY = 24 * 60 * 60 * 1000; + +// only allow request with master key +let enforceSecurity = (auth) => { + if (!auth || !auth.isMaster) { + throw new Parse.Error( + Parse.Error.OPERATION_FORBIDDEN, + 'Clients aren\'t allowed to perform the ' + + 'get' + ' operation on logs.' + ); + } +} + +// check that date input is valid +let isValidDateTime = (date) => { + if (!date || isNaN(Number(date))) { + return false; + } +} + +export class LoggerController { + + constructor(loggerAdapter) { + this._loggerAdapter = loggerAdapter; + } + + // Returns a promise for a {response} object. + // query params: + // level (optional) Level of logging you want to query for (info || error) + // from (optional) Start time for the search. Defaults to 1 week ago. + // until (optional) End time for the search. Defaults to current time. + // order (optional) Direction of results returned, either “asc” or “desc”. Defaults to “desc”. + // size (optional) Number of rows returned by search. Defaults to 10 + handleGET(req) { + if (!this._loggerAdapter) { + throw new Parse.Error(Parse.Error.PUSH_MISCONFIGURED, + 'Logger adapter is not availabe'); + } + + let promise = new Parse.Promise(); + let from = (isValidDateTime(req.query.from) && new Date(req.query.from)) || + new Date(Date.now() - 7 * MILLISECONDS_IN_A_DAY); + let until = (isValidDateTime(req.query.until) && new Date(req.query.until)) || new Date(); + let size = Number(req.query.size) || 10; + let order = req.query.order || 'desc'; + let level = req.query.level || INFO; + enforceSecurity(req.auth); + this._loggerAdapter.query({ + from, + until, + size, + order, + level, + }, (result) => { + promise.resolve({ + response: result + }); + }); + return promise; + } + + getExpressRouter() { + let router = new PromiseRouter(); + router.route('GET','/logs', (req) => { + return this.handleGET(req); + }); + return router; + } +} + +export default LoggerController; diff --git a/src/index.js b/src/index.js index c2993400..4458f8c0 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,9 @@ import { PushController } from './Controllers/PushController'; import { ClassesRouter } from './Routers/ClassesRouter'; import { InstallationsRouter } from './Routers/InstallationsRouter'; +import { FileLoggerAdapter } from './Adapters/Logger/FileLoggerAdapter'; +import { LoggerController } from './Controllers/LoggerController'; + // Mutate the Parse object to add the Cloud Code handlers addParseCloud(); @@ -69,6 +72,9 @@ function ParseServer(args) { pushAdapter = new ParsePushAdapter(pushConfig) } + // Make logger adapter + let loggerAdapter = args.loggerAdapter || new FileLoggerAdapter(); + if (args.databaseURI) { DatabaseAdapter.setAppDatabaseURI(args.appId, args.databaseURI); } @@ -136,7 +142,8 @@ function ParseServer(args) { new InstallationsRouter().getExpressRouter(), require('./functions'), require('./schemas'), - new PushController(pushAdapter).getExpressRouter() + new PushController(pushAdapter).getExpressRouter(), + new LoggerController(loggerAdapter).getExpressRouter() ]; if (process.env.PARSE_EXPERIMENTAL_CONFIG_ENABLED || process.env.TESTING) { routers.push(require('./global_config')); From 0c75f6022d32ead36c1ae44eb2dab7ae9ab74485 Mon Sep 17 00:00:00 2001 From: "Peter J. Shin" Date: Fri, 12 Feb 2016 10:30:04 -0800 Subject: [PATCH 11/13] Updating adapter docs. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index dfa8b0f0..92b7668f 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ The client keys used with Parse are no longer necessary with parse-server. If y #### Advanced options: -* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see `FilesAdapter.js`) +* filesAdapter - The default behavior (GridStore) can be changed by creating an adapter class (see [`FilesAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Files/FilesAdapter.js)) * databaseAdapter (unfinished) - The backing store can be changed by creating an adapter class (see `DatabaseAdapter.js`) +* loggerAdapter - The default behavior/transport (File) can be changed by creating an adapter class (see [`LoggerAdapter.js`](https://github.com/ParsePlatform/parse-server/blob/master/src/Adapters/Logger/LoggerAdapter.js)) --- From 62cbc451aa1fc1622064edabd36ceb016f36444b Mon Sep 17 00:00:00 2001 From: Dmitry Chestnykh Date: Fri, 12 Feb 2016 02:02:55 +0100 Subject: [PATCH 12/13] Generate tokens and ids with cryptoUtils module. Move object ID, token, and random string generation into their own module, cryptoUtils. Remove hat dependency, which was used to generate session and some other tokens, because it used non-cryptographic random number generator. Replace it with the cryptographically secure one. The result has the same format (32-character hex string, 128 bits of entropy). Remove randomstring dependency, as we already have this functionality. Add tests. --- package.json | 2 - spec/cryptoUtils.spec.js | 83 ++++++++++++++++++++++++++++++ src/Controllers/FilesController.js | 6 +-- src/GCM.js | 7 +-- src/RestWrite.js | 29 +++-------- src/Routers/UsersRouter.js | 6 +-- src/cryptoUtils.js | 44 ++++++++++++++++ src/testing-routes.js | 6 +-- 8 files changed, 142 insertions(+), 41 deletions(-) create mode 100644 spec/cryptoUtils.spec.js create mode 100644 src/cryptoUtils.js diff --git a/package.json b/package.json index 97467963..ae2b5331 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,11 @@ "body-parser": "^1.14.2", "deepcopy": "^0.6.1", "express": "^4.13.4", - "hat": "~0.0.3", "mime": "^1.3.4", "mongodb": "~2.1.0", "multer": "^1.1.0", "node-gcm": "^0.14.0", "parse": "^1.7.0", - "randomstring": "^1.1.3", "request": "^2.65.0", "winston": "^2.1.1" }, diff --git a/spec/cryptoUtils.spec.js b/spec/cryptoUtils.spec.js new file mode 100644 index 00000000..cd996770 --- /dev/null +++ b/spec/cryptoUtils.spec.js @@ -0,0 +1,83 @@ +var cryptoUtils = require('../src/cryptoUtils'); + +function givesUniqueResults(fn, iterations) { + var results = {}; + for (var i = 0; i < iterations; i++) { + var s = fn(); + if (results[s]) { + return false; + } + results[s] = true; + } + return true; +} + +describe('randomString', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.randomString(10)).toBe('string'); + }); + + it('returns result of the given length', () => { + expect(cryptoUtils.randomString(11).length).toBe(11); + expect(cryptoUtils.randomString(25).length).toBe(25); + }); + + it('throws if requested length is zero', () => { + expect(() => cryptoUtils.randomString(0)).toThrow(); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.randomString(10), 100)).toBe(true); + }); +}); + +describe('randomHexString', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.randomHexString(10)).toBe('string'); + }); + + it('returns result of the given length', () => { + expect(cryptoUtils.randomHexString(10).length).toBe(10); + expect(cryptoUtils.randomHexString(32).length).toBe(32); + }); + + it('throws if requested length is zero', () => { + expect(() => cryptoUtils.randomHexString(0)).toThrow(); + }); + + it('throws if requested length is not even', () => { + expect(() => cryptoUtils.randomHexString(11)).toThrow(); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.randomHexString(20), 100)).toBe(true); + }); +}); + +describe('newObjectId', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.newObjectId()).toBe('string'); + }); + + it('returns result with at least 10 characters', () => { + expect(cryptoUtils.newObjectId().length).toBeGreaterThan(9); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.newObjectId(), 100)).toBe(true); + }); +}); + +describe('newToken', () => { + it('returns a string', () => { + expect(typeof cryptoUtils.newToken()).toBe('string'); + }); + + it('returns result with at least 32 characters', () => { + expect(cryptoUtils.newToken().length).toBeGreaterThan(31); + }); + + it('returns unique results', () => { + expect(givesUniqueResults(() => cryptoUtils.newToken(), 100)).toBe(true); + }); +}); diff --git a/src/Controllers/FilesController.js b/src/Controllers/FilesController.js index dac6b684..6fde54b7 100644 --- a/src/Controllers/FilesController.js +++ b/src/Controllers/FilesController.js @@ -4,11 +4,9 @@ import express from 'express'; import mime from 'mime'; import { Parse } from 'parse/node'; import BodyParser from 'body-parser'; -import hat from 'hat'; import * as Middlewares from '../middlewares'; import Config from '../Config'; - -const rack = hat.rack(); +import { randomHexString } from '../cryptoUtils'; export class FilesController { constructor(filesAdapter) { @@ -61,7 +59,7 @@ export class FilesController { extension = '.' + mime.extension(contentType); } - let filename = rack() + '_' + req.params.filename + extension; + let filename = randomHexString(32) + '_' + req.params.filename + extension; this._filesAdapter.createFile(req.config, filename, req.body).then(() => { res.status(201); var location = this._filesAdapter.getFileLocation(req.config, filename); diff --git a/src/GCM.js b/src/GCM.js index be09f222..a13a6751 100644 --- a/src/GCM.js +++ b/src/GCM.js @@ -2,7 +2,7 @@ const Parse = require('parse/node').Parse; const gcm = require('node-gcm'); -const randomstring = require('randomstring'); +const cryptoUtils = require('./cryptoUtils'); const GCMTimeToLiveMax = 4 * 7 * 24 * 60 * 60; // GCM allows a max of 4 weeks const GCMRegistrationTokensMax = 1000; @@ -22,10 +22,7 @@ function GCM(args) { * @returns {Object} A promise which is resolved after we get results from gcm */ GCM.prototype.send = function(data, devices) { - let pushId = randomstring.generate({ - length: 10, - charset: 'alphanumeric' - }); + let pushId = cryptoUtils.newObjectId(); let timeStamp = Date.now(); let expirationTime; // We handle the expiration_time convertion in push.js, so expiration_time is a valid date diff --git a/src/RestWrite.js b/src/RestWrite.js index f4bb7353..2a2b0ed2 100644 --- a/src/RestWrite.js +++ b/src/RestWrite.js @@ -2,13 +2,12 @@ // that writes to the database. // This could be either a "create" or an "update". -var crypto = require('crypto'); var deepcopy = require('deepcopy'); -var rack = require('hat').rack(); var Auth = require('./Auth'); var cache = require('./cache'); var Config = require('./Config'); +var cryptoUtils = require('./cryptoUtils'); var passwordCrypto = require('./password'); var facebook = require('./facebook'); var Parse = require('parse/node'); @@ -56,7 +55,7 @@ function RestWrite(config, auth, className, query, data, originalData) { this.data.updatedAt = this.updatedAt; if (!this.query) { this.data.createdAt = this.updatedAt; - this.data.objectId = newStringId(10); + this.data.objectId = cryptoUtils.newObjectId(); } } } @@ -252,7 +251,7 @@ RestWrite.prototype.handleFacebookAuthData = function() { throw new Parse.Error(Parse.Error.ACCOUNT_ALREADY_LINKED, 'this auth is already used'); } else { - this.data.username = rack(); + this.data.username = cryptoUtils.newToken(); } // This FB auth does not already exist, so transform it to a @@ -273,7 +272,7 @@ RestWrite.prototype.transformUser = function() { var promise = Promise.resolve(); if (!this.query) { - var token = 'r:' + rack(); + var token = 'r:' + cryptoUtils.newToken(); this.storage['token'] = token; promise = promise.then(() => { var expiresAt = new Date(); @@ -319,7 +318,7 @@ RestWrite.prototype.transformUser = function() { // Check for username uniqueness if (!this.data.username) { if (!this.query) { - this.data.username = newStringId(25); + this.data.username = cryptoUtils.randomString(25); } return; } @@ -412,7 +411,7 @@ RestWrite.prototype.handleSession = function() { } if (!this.query && !this.auth.isMaster) { - var token = 'r:' + rack(); + var token = 'r:' + cryptoUtils.newToken(); var expiresAt = new Date(); expiresAt.setFullYear(expiresAt.getFullYear() + 1); var sessionData = { @@ -713,20 +712,4 @@ RestWrite.prototype.objectId = function() { return this.data.objectId || this.query.objectId; }; -// Returns a unique string that's usable as an object or other id. -function newStringId(size) { - var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + - 'abcdefghijklmnopqrstuvwxyz' + - '0123456789'); - var objectId = ''; - var bytes = crypto.randomBytes(size); - for (var i = 0; i < bytes.length; ++i) { - // Note: there is a slight modulo bias, because chars length - // of 62 doesn't divide the number of all bytes (256) evenly. - // It is acceptable for our purposes. - objectId += chars[bytes.readUInt8(i) % chars.length]; - } - return objectId; -} - module.exports = RestWrite; diff --git a/src/Routers/UsersRouter.js b/src/Routers/UsersRouter.js index 4f22e07a..5b894f75 100644 --- a/src/Routers/UsersRouter.js +++ b/src/Routers/UsersRouter.js @@ -1,6 +1,5 @@ // These methods handle the User-related routes. -import hat from 'hat'; import deepcopy from 'deepcopy'; import ClassesRouter from './ClassesRouter'; @@ -9,8 +8,7 @@ import rest from '../rest'; import Auth from '../Auth'; import passwordCrypto from '../password'; import RestWrite from '../RestWrite'; - -const rack = hat.rack(); +import { newToken } from '../cryptoUtils'; export class UsersRouter extends ClassesRouter { handleFind(req) { @@ -89,7 +87,7 @@ export class UsersRouter extends ClassesRouter { throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Invalid username/password.'); } - let token = 'r:' + rack(); + let token = 'r:' + newToken(); user.sessionToken = token; delete user.password; diff --git a/src/cryptoUtils.js b/src/cryptoUtils.js new file mode 100644 index 00000000..2f83defd --- /dev/null +++ b/src/cryptoUtils.js @@ -0,0 +1,44 @@ +import { randomBytes } from 'crypto'; + +// Returns a new random hex string of the given even size. +export function randomHexString(size) { + if (size === 0) { + throw new Error('Zero-length randomHexString is useless.'); + } + if (size % 2 !== 0) { + throw new Error('randomHexString size must be divisible by 2.') + } + return randomBytes(size/2).toString('hex'); +} + +// Returns a new random alphanumeric string of the given size. +// +// Note: to simplify implementation, the result has slight modulo bias, +// because chars length of 62 doesn't divide the number of all bytes +// (256) evenly. Such bias is acceptable for most cases when the output +// length is long enough and doesn't need to be uniform. +export function randomString(size) { + if (size === 0) { + throw new Error('Zero-length randomString is useless.'); + } + var chars = ('ABCDEFGHIJKLMNOPQRSTUVWXYZ' + + 'abcdefghijklmnopqrstuvwxyz' + + '0123456789'); + var objectId = ''; + var bytes = randomBytes(size); + for (var i = 0; i < bytes.length; ++i) { + objectId += chars[bytes.readUInt8(i) % chars.length]; + } + return objectId; +} + +// Returns a new random alphanumeric string suitable for object ID. +export function newObjectId() { + //TODO: increase length to better protect against collisions. + return randomString(10); +} + +// Returns a new random hex string suitable for secure tokens. +export function newToken() { + return randomHexString(32); +} diff --git a/src/testing-routes.js b/src/testing-routes.js index 85db1485..28b02cf4 100644 --- a/src/testing-routes.js +++ b/src/testing-routes.js @@ -3,13 +3,13 @@ var express = require('express'), cache = require('./cache'), middlewares = require('./middlewares'), - rack = require('hat').rack(); + cryptoUtils = require('./cryptoUtils'); var router = express.Router(); // creates a unique app in the cache, with a collection prefix function createApp(req, res) { - var appId = rack(); + var appId = cryptoUtils.randomHexString(32); cache.apps[appId] = { 'collectionPrefix': appId + '_', 'masterKey': 'master' @@ -70,4 +70,4 @@ router.post('/rest_configure_app', module.exports = { router: router -}; \ No newline at end of file +}; From 66efd0d0305cac1484b456e78878443c6e368741 Mon Sep 17 00:00:00 2001 From: Francis Lessard Date: Fri, 12 Feb 2016 15:57:37 -0500 Subject: [PATCH 13/13] Rebase --- src/users.js | 215 --------------------------------------------------- 1 file changed, 215 deletions(-) delete mode 100644 src/users.js diff --git a/src/users.js b/src/users.js deleted file mode 100644 index 9484ee64..00000000 --- a/src/users.js +++ /dev/null @@ -1,215 +0,0 @@ -// These methods handle the User-related routes. - -var mongodb = require('mongodb'); -var Parse = require('parse/node').Parse; -var rack = require('hat').rack(); - -var Auth = require('./Auth'); -var passwordCrypto = require('./password'); -var facebook = require('./facebook'); -var PromiseRouter = require('./PromiseRouter'); -var rest = require('./rest'); -var RestWrite = require('./RestWrite'); -var deepcopy = require('deepcopy'); - -var router = new PromiseRouter(); - -// Returns a promise for a {status, response, location} object. -function handleCreate(req) { - var data = deepcopy(req.body); - data.installationId = req.info.installationId; - return rest.create(req.config, req.auth, - '_User', data); -} - -// Returns a promise for a {response} object. -function handleLogIn(req) { - - // Use query parameters instead if provided in url - if (!req.body.username && req.query.username) { - req.body = req.query; - } - - // TODO: use the right error codes / descriptions. - if (!req.body.username) { - throw new Parse.Error(Parse.Error.USERNAME_MISSING, - 'username is required.'); - } - if (!req.body.password) { - throw new Parse.Error(Parse.Error.PASSWORD_MISSING, - 'password is required.'); - } - - var user; - return req.database.find('_User', {username: req.body.username}) - .then((results) => { - if (!results.length) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - user = results[0]; - return passwordCrypto.compare(req.body.password, user.password); - }).then((correct) => { - if (!correct) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Invalid username/password.'); - } - var token = 'r:' + rack(); - user.sessionToken = token; - delete user.password; - - req.config.filesController.expandFilesInObject(req.config, user); - - var expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - - var sessionData = { - sessionToken: token, - user: { - __type: 'Pointer', - className: '_User', - objectId: user.objectId - }, - createdWith: { - 'action': 'login', - 'authProvider': 'password' - }, - restricted: false, - expiresAt: Parse._encode(expiresAt) - }; - - if (req.info.installationId) { - sessionData.installationId = req.info.installationId - } - - var create = new RestWrite(req.config, Auth.master(req.config), - '_Session', null, sessionData); - return create.execute(); - }).then(() => { - return {response: user}; - }); -} - -// Returns a promise that resolves to a {response} object. -// TODO: share code with ClassesRouter.js -function handleFind(req) { - var options = {}; - if (req.body.skip) { - options.skip = Number(req.body.skip); - } - if (req.body.limit) { - options.limit = Number(req.body.limit); - } - if (req.body.order) { - options.order = String(req.body.order); - } - if (req.body.count) { - options.count = true; - } - if (typeof req.body.keys == 'string') { - options.keys = req.body.keys; - } - if (req.body.include) { - options.include = String(req.body.include); - } - if (req.body.redirectClassNameForKey) { - options.redirectClassNameForKey = String(req.body.redirectClassNameForKey); - } - - return rest.find(req.config, req.auth, - '_User', req.body.where, options) - .then((response) => { - return {response: response}; - }); - -} - -// Returns a promise for a {response} object. -function handleGet(req) { - return rest.find(req.config, req.auth, '_User', - {objectId: req.params.objectId}) - .then((response) => { - if (!response.results || response.results.length == 0) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - if(req.params.className === "_User"){ - delete response.results[0].sessionToken; - } - return {response: response.results[0]}; - } - }); -} - -function handleMe(req) { - if (!req.info || !req.info.sessionToken) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken}, - {include: 'user'}) - .then((response) => { - if (!response.results || response.results.length == 0 || - !response.results[0].user) { - throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, - 'Object not found.'); - } else { - var user = response.results[0].user; - return {response: user}; - } - }); -} - -function handleDelete(req) { - return rest.del(req.config, req.auth, - req.params.className, req.params.objectId) - .then(() => { - return {response: {}}; - }); -} - -function handleLogOut(req) { - var success = {response: {}}; - if (req.info && req.info.sessionToken) { - return rest.find(req.config, Auth.master(req.config), '_Session', - {_session_token: req.info.sessionToken} - ).then((records) => { - if (records.results && records.results.length) { - return rest.del(req.config, Auth.master(req.config), '_Session', - records.results[0].objectId - ).then(() => { - return Promise.resolve(success); - }); - } - return Promise.resolve(success); - }); - } - return Promise.resolve(success); -} - -function handleUpdate(req) { - return rest.update(req.config, req.auth, '_User', - req.params.objectId, req.body) - .then((response) => { - return {response: response}; - }); -} - -function notImplementedYet(req) { - throw new Parse.Error(Parse.Error.COMMAND_UNAVAILABLE, - 'This path is not implemented yet.'); -} - -router.route('POST', '/users', handleCreate); -router.route('GET', '/login', handleLogIn); -router.route('POST', '/logout', handleLogOut); -router.route('GET', '/users/me', handleMe); -router.route('GET', '/users/:objectId', handleGet); -router.route('PUT', '/users/:objectId', handleUpdate); -router.route('GET', '/users', handleFind); -router.route('DELETE', '/users/:objectId', handleDelete); - -router.route('POST', '/requestPasswordReset', notImplementedYet); - -module.exports = router;