diff --git a/Parse-Dashboard/Authentication.js b/Parse-Dashboard/Authentication.js index 55e274f46..fef90b948 100644 --- a/Parse-Dashboard/Authentication.js +++ b/Parse-Dashboard/Authentication.js @@ -55,9 +55,10 @@ function initialize(app, options) { const cookieSessionSecret = options.cookieSessionSecret || require('crypto').randomBytes(64).toString('hex'); const cookieSessionMaxAge = options.cookieSessionMaxAge; + const cookieSessionStore = options.cookieSessionStore; app.use(require('body-parser').urlencoded({ extended: true })); - app.use(require('express-session')({ + const sessionConfig = { name: 'parse_dash', secret: cookieSessionSecret, resave: false, @@ -67,7 +68,14 @@ function initialize(app, options) { httpOnly: true, sameSite: 'lax', } - })); + }; + + // Add custom session store if provided + if (cookieSessionStore) { + sessionConfig.store = cookieSessionStore; + } + + app.use(require('express-session')(sessionConfig)); app.use(require('connect-flash')()); app.use(passport.initialize()); app.use(passport.session()); diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 144851c38..cc8e034fe 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -82,7 +82,11 @@ module.exports = function(config, options) { const users = config.users; const useEncryptedPasswords = config.useEncryptedPasswords ? true : false; const authInstance = new Authentication(users, useEncryptedPasswords, mountPath); - authInstance.initialize(app, { cookieSessionSecret: options.cookieSessionSecret, cookieSessionMaxAge: options.cookieSessionMaxAge }); + authInstance.initialize(app, { + cookieSessionSecret: options.cookieSessionSecret, + cookieSessionMaxAge: options.cookieSessionMaxAge, + cookieSessionStore: options.cookieSessionStore + }); // CSRF error handler app.use(function (err, req, res, next) { diff --git a/Parse-Dashboard/server.js b/Parse-Dashboard/server.js index 412b5a2eb..a04fa79ac 100644 --- a/Parse-Dashboard/server.js +++ b/Parse-Dashboard/server.js @@ -162,7 +162,13 @@ module.exports = (options) => { if (allowInsecureHTTP || trustProxy || dev) {app.enable('trust proxy');} config.data.trustProxy = trustProxy; - const dashboardOptions = { allowInsecureHTTP, cookieSessionSecret, dev, cookieSessionMaxAge }; + const dashboardOptions = { + allowInsecureHTTP, + cookieSessionSecret, + dev, + cookieSessionMaxAge, + cookieSessionStore: config.data.cookieSessionStore + }; app.use(mountPath, parseDashboard(config.data, dashboardOptions)); let server; if(!configSSLKey || !configSSLCert){ diff --git a/README.md b/README.md index 02b5394df..08e4d7419 100644 --- a/README.md +++ b/README.md @@ -803,6 +803,55 @@ If you create a new user by running `parse-dashboard --createUser`, you will be Parse Dashboard follows the industry standard and supports the common OTP algorithm `SHA-1` by default, to be compatible with most authenticator apps. If you have specific security requirements regarding TOTP characteristics (algorithm, digit length, time period) you can customize them by using the guided configuration mentioned above. +### Running Multiple Dashboard Replicas + +When deploying Parse Dashboard with multiple replicas behind a load balancer, you need to use a shared session store to ensure that CSRF tokens and user sessions work correctly across all replicas. Without a shared session store, login attempts may fail with "CSRF token validation failed" errors when requests are distributed across different replicas. + +#### Using a Custom Session Store + +Parse Dashboard supports using any session store compatible with [express-session](https://github.com/expressjs/session). The `sessionStore` option must be configured programmatically when initializing the dashboard. + +**Suggested Session Stores:** + +- [connect-redis](https://www.npmjs.com/package/connect-redis) - Redis session store +- [connect-mongo](https://www.npmjs.com/package/connect-mongo) - MongoDB session store +- [connect-pg-simple](https://www.npmjs.com/package/connect-pg-simple) - PostgreSQL session store +- [memorystore](https://www.npmjs.com/package/memorystore) - Memory session store with TTL support + +**Example using connect-redis:** + +```js +const express = require('express'); +const ParseDashboard = require('parse-dashboard'); +const { createClient } = require('redis'); +const RedisStore = require('connect-redis').default; + +// Instantiate Redis client +const redisClient = createClient({ url: 'redis://localhost:6379' }); +redisClient.connect(); + +// Instantiate Redis session store +const cookieSessionStore = new RedisStore({ client: redisClient }); + +// Configure dashboard with session store +const dashboard = new ParseDashboard({ + apps: [...], + users: [...], +}, { + cookieSessionStore, + cookieSessionSecret: 'your-secret-key', +}); + +**Important Notes:** + +- The `cookieSessionSecret` option must be set to the same value across all replicas to ensure session cookies work correctly. +- If `cookieSessionStore` is not provided, Parse Dashboard will use the default in-memory session store, which only works for single-instance deployments. +- For production deployments with multiple replicas, always configure a shared session store. + +#### Alternative: Using Sticky Sessions + +If you cannot use a shared session store, you can configure your load balancer to use sticky sessions (session affinity), which ensures that requests from the same user are always routed to the same replica. However, using a shared session store is the recommended approach as it provides better reliability and scalability. + ### Separating App Access Based on User Identity If you have configured your dashboard to manage multiple applications, you can restrict the management of apps based on user identity. diff --git a/src/lib/tests/SessionStore.test.js b/src/lib/tests/SessionStore.test.js new file mode 100644 index 000000000..813b6daa9 --- /dev/null +++ b/src/lib/tests/SessionStore.test.js @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +jest.dontMock('../../../Parse-Dashboard/Authentication.js'); +jest.dontMock('../../../Parse-Dashboard/app.js'); + +const express = require('express'); +const session = require('express-session'); +const Authentication = require('../../../Parse-Dashboard/Authentication'); + +describe('SessionStore Integration', () => { + it('uses default in-memory store when cookieSessionStore is not provided', () => { + const app = express(); + const users = [{ user: 'test', pass: 'password' }]; + const auth = new Authentication(users, false, '/'); + + // Mock app.use to capture session configuration + const useSpy = jest.fn(); + app.use = useSpy; + + auth.initialize(app, {}); + + // Find the call that sets up express-session + const sessionCall = useSpy.mock.calls.find(call => + call[0] && call[0].name === 'session' + ); + + expect(sessionCall).toBeDefined(); + // When no store is provided, express-session uses MemoryStore by default + // The session function should be called without a custom store + }); + + it('uses custom session store when cookieSessionStore is provided', () => { + const app = express(); + const users = [{ user: 'test', pass: 'password' }]; + const auth = new Authentication(users, false, '/'); + + // Create a mock session store that implements the Store interface + const Store = session.Store; + class MockStore extends Store { + constructor() { + super(); + } + get(sid, callback) { + callback(null, {}); + } + set(sid, session, callback) { + callback(null); + } + destroy(sid, callback) { + callback(null); + } + } + + const mockStore = new MockStore(); + + // Mock app.use to capture session configuration + const useSpy = jest.fn(); + app.use = useSpy; + + auth.initialize(app, { cookieSessionStore: mockStore }); + + // The session middleware should have been configured + expect(useSpy).toHaveBeenCalled(); + + // Find the call that sets up express-session + const sessionCall = useSpy.mock.calls.find(call => + call[0] && call[0].name === 'session' + ); + + expect(sessionCall).toBeDefined(); + }); + + it('passes cookieSessionStore through app.js to Authentication', () => { + const parseDashboard = require('../../../Parse-Dashboard/app.js'); + + // Create a mock session store that implements the Store interface + const Store = session.Store; + class MockStore extends Store { + constructor() { + super(); + } + get(sid, callback) { + callback(null, {}); + } + set(sid, session, callback) { + callback(null); + } + destroy(sid, callback) { + callback(null); + } + } + + const mockStore = new MockStore(); + + const config = { + apps: [ + { + serverURL: 'http://localhost:1337/parse', + appId: 'testAppId', + masterKey: 'testMasterKey', + appName: 'TestApp', + }, + ], + users: [ + { + user: 'testuser', + pass: 'testpass', + }, + ], + }; + + const options = { + cookieSessionStore: mockStore, + cookieSessionSecret: 'test-secret', + }; + + // Create dashboard app + const dashboardApp = parseDashboard(config, options); + + // The app should be created successfully with the session store + expect(dashboardApp).toBeDefined(); + expect(typeof dashboardApp).toBe('function'); // Express app is a function + }); + + it('maintains backward compatibility without cookieSessionStore option', () => { + const parseDashboard = require('../../../Parse-Dashboard/app.js'); + + const config = { + apps: [ + { + serverURL: 'http://localhost:1337/parse', + appId: 'testAppId', + masterKey: 'testMasterKey', + appName: 'TestApp', + }, + ], + users: [ + { + user: 'testuser', + pass: 'testpass', + }, + ], + }; + + const options = { + cookieSessionSecret: 'test-secret', + }; + + // Create dashboard app without cookieSessionStore option + const dashboardApp = parseDashboard(config, options); + + // The app should be created successfully even without session store + expect(dashboardApp).toBeDefined(); + expect(typeof dashboardApp).toBe('function'); + }); +});