From 04e9f9fe3670bf6f5dc616b31b458bf4de2915ad Mon Sep 17 00:00:00 2001 From: dblythy Date: Wed, 12 Apr 2023 17:34:32 +1000 Subject: [PATCH 1/5] feat: `renewSessions` to automatically renew Parse Sessions --- spec/Auth.spec.js | 25 ++++++++++++++++++++ spec/index.spec.js | 16 +++++++++++++ src/Auth.js | 48 ++++++++++++++++++++++++++++++++++++-- src/Config.js | 5 ++++ src/Options/Definitions.js | 6 +++++ src/Options/docs.js | 1 + src/Options/index.js | 3 +++ 7 files changed, 102 insertions(+), 2 deletions(-) diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index 5ed6bfe941..fa66a6e99e 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -94,6 +94,31 @@ describe('Auth', () => { }); }); + it('can use renewSessions', async () => { + await reconfigureServer({ + renewSessions: true, + }); + + const user = new Parse.User(); + await user.signUp({ + username: 'hello', + password: 'password', + }); + const session = await new Parse.Query(Parse.Session).first(); + const updatedAt = new Date('2010'); + const expiry = new Date(); + + await Parse.Server.database.update( + '_Session', + { objectId: session.id }, + { updatedAt: Parse._encode(updatedAt), expiresAt: Parse._encode(expiry) } + ); + await session.fetch(); + await new Promise(resolve => setTimeout(resolve, 1000)); + await session.fetch(); + expect(session.get('expiresAt') > expiry).toBeTrue(); + }); + it('should load auth without a config', async () => { const user = new Parse.User(); await user.signUp({ diff --git a/spec/index.spec.js b/spec/index.spec.js index 08ef16a77b..309721618f 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -367,6 +367,22 @@ describe('server', () => { }); }); + it('should throw when renewSessions is invalid', async () => { + await expectAsync( + reconfigureServer({ + renewSessions: 'yolo', + }) + ).toBeRejectedWith('renewSessions must be a boolean value'); + }); + + it('should throw when revokeSessionOnPasswordReset is invalid', async () => { + await expectAsync( + reconfigureServer({ + revokeSessionOnPasswordReset: 'yolo', + }) + ).toBeRejectedWith('revokeSessionOnPasswordReset must be a boolean value'); + }); + it('fails if the session length is not a number', done => { reconfigureServer({ sessionLength: 'test' }) .then(done.fail) diff --git a/src/Auth.js b/src/Auth.js index abd14391db..ef9d8cd4ba 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -66,6 +66,47 @@ function nobody(config) { return new Auth({ config, isMaster: false }); } +const throttle = {}; +const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { + if (!config?.renewSessions) { + return; + } + clearTimeout(throttle[sessionToken]); + throttle[sessionToken] = setTimeout(async () => { + try { + if (!session) { + const RestQuery = require('./RestQuery'); + const { results } = await new RestQuery( + config, + master(config), + '_Session', + { sessionToken }, + { limit: 1 } + ).execute(); + session = results[0]; + } + const lastUpdated = new Date(session?.updatedAt); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + if (lastUpdated > yesterday || !session) { + return; + } + await config.database.update( + '_Session', + { objectId: session.objectId }, + { + expiresAt: Parse._encode(config.generateSessionExpiresAt()), + updatedAt: Parse._encode(new Date()), + } + ); + } catch (e) { + if (e?.code !== Parse.Error.OBJECT_NOT_FOUND) { + logger.error('Could not update session expiry: ', e); + } + } + }, 500); +}; + // Returns a promise that resolves to an Auth object const getAuthForSessionToken = async function ({ config, @@ -78,6 +119,7 @@ const getAuthForSessionToken = async function ({ const userJSON = await cacheController.user.get(sessionToken); if (userJSON) { const cachedUser = Parse.Object.fromJSON(userJSON); + renewSessionIfNeeded({ config, sessionToken }); return Promise.resolve( new Auth({ config, @@ -112,18 +154,20 @@ const getAuthForSessionToken = async function ({ if (results.length !== 1 || !results[0]['user']) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); } + const session = results[0]; const now = new Date(), - expiresAt = results[0].expiresAt ? new Date(results[0].expiresAt.iso) : undefined; + expiresAt = session.expiresAt ? new Date(session.expiresAt.iso) : undefined; if (expiresAt < now) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.'); } - const obj = results[0]['user']; + const obj = session.user; delete obj.password; obj['className'] = '_User'; obj['sessionToken'] = sessionToken; if (cacheController) { cacheController.user.put(sessionToken, obj); } + renewSessionIfNeeded({ config, session, sessionToken }); const userObject = Parse.Object.fromJSON(obj); return new Auth({ config, diff --git a/src/Config.js b/src/Config.js index 812d28c367..1cfb10dd29 100644 --- a/src/Config.js +++ b/src/Config.js @@ -86,6 +86,7 @@ export class Config { logLevels, rateLimit, databaseOptions, + renewSessions, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -103,6 +104,10 @@ export class Config { throw 'revokeSessionOnPasswordReset must be a boolean value'; } + if (typeof renewSessions !== 'boolean') { + throw 'renewSessions must be a boolean value'; + } + if (publicServerURL) { if (!publicServerURL.startsWith('http://') && !publicServerURL.startsWith('https://')) { throw 'publicServerURL should be a valid HTTPS URL starting with https://'; diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index b2f0542256..0733482950 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -435,6 +435,12 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', help: 'Read-only key, which has the same capabilities as MasterKey without writes', }, + renewSessions: { + env: 'PARSE_SERVER_RENEW_SESSIONS', + help: 'Whether Parse Server should automatically extend a valid session by the sessionLength', + action: parsers.booleanParser, + default: false, + }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 1ab8c03d58..64cbd58645 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -81,6 +81,7 @@ * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes + * @property {Boolean} renewSessions Whether Parse Server should automatically extend a valid session by the sessionLength * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. diff --git a/src/Options/index.js b/src/Options/index.js index a4d83f94fc..9a4050e8dc 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -202,6 +202,9 @@ export interface ParseServerOptions { /* Session duration, in seconds, defaults to 1 year :DEFAULT: 31536000 */ sessionLength: ?number; + /* Whether Parse Server should automatically extend a valid session by the sessionLength + :DEFAULT: false */ + renewSessions: ?boolean; /* Default value for limit option on queries, defaults to `100`. :DEFAULT: 100 */ defaultLimit: ?number; From 05dfdc85d6bee6af94ef51f2b6dd62007c57e2c3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Wed, 12 Apr 2023 19:47:00 +1000 Subject: [PATCH 2/5] Update Auth.spec.js --- spec/Auth.spec.js | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index fa66a6e99e..10c65b455b 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -107,6 +107,7 @@ describe('Auth', () => { const session = await new Parse.Query(Parse.Session).first(); const updatedAt = new Date('2010'); const expiry = new Date(); + expiry.setHours(expiry.getHours() + 1); await Parse.Server.database.update( '_Session', From dea13342ce7981d514d6ae635ed9cce2c4e5529d Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 13:41:21 +1000 Subject: [PATCH 3/5] renew sessions --- spec/Auth.spec.js | 4 ++-- spec/index.spec.js | 6 +++--- src/Auth.js | 2 +- src/Config.js | 6 +++--- src/Options/Definitions.js | 2 +- src/Options/docs.js | 2 +- src/Options/index.js | 2 +- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index 10c65b455b..a53b8a6937 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -94,9 +94,9 @@ describe('Auth', () => { }); }); - it('can use renewSessions', async () => { + it('can use extendSessionOnUse', async () => { await reconfigureServer({ - renewSessions: true, + extendSessionOnUse: true, }); const user = new Parse.User(); diff --git a/spec/index.spec.js b/spec/index.spec.js index 309721618f..66654aaec4 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -367,12 +367,12 @@ describe('server', () => { }); }); - it('should throw when renewSessions is invalid', async () => { + it('should throw when extendSessionOnUse is invalid', async () => { await expectAsync( reconfigureServer({ - renewSessions: 'yolo', + extendSessionOnUse: 'yolo', }) - ).toBeRejectedWith('renewSessions must be a boolean value'); + ).toBeRejectedWith('extendSessionOnUse must be a boolean value'); }); it('should throw when revokeSessionOnPasswordReset is invalid', async () => { diff --git a/src/Auth.js b/src/Auth.js index ef9d8cd4ba..8bedb2a684 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -68,7 +68,7 @@ function nobody(config) { const throttle = {}; const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { - if (!config?.renewSessions) { + if (!config?.extendSessionOnUse) { return; } clearTimeout(throttle[sessionToken]); diff --git a/src/Config.js b/src/Config.js index 1cfb10dd29..747af78f82 100644 --- a/src/Config.js +++ b/src/Config.js @@ -86,7 +86,7 @@ export class Config { logLevels, rateLimit, databaseOptions, - renewSessions, + extendSessionOnUse, }) { if (masterKey === readOnlyMasterKey) { throw new Error('masterKey and readOnlyMasterKey should be different'); @@ -104,8 +104,8 @@ export class Config { throw 'revokeSessionOnPasswordReset must be a boolean value'; } - if (typeof renewSessions !== 'boolean') { - throw 'renewSessions must be a boolean value'; + if (typeof extendSessionOnUse !== 'boolean') { + throw 'extendSessionOnUse must be a boolean value'; } if (publicServerURL) { diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 2eaa948370..bdc912e92e 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -437,7 +437,7 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', help: 'Read-only key, which has the same capabilities as MasterKey without writes', }, - renewSessions: { + extendSessionOnUse: { env: 'PARSE_SERVER_RENEW_SESSIONS', help: 'Whether Parse Server should automatically extend a valid session by the sessionLength', action: parsers.booleanParser, diff --git a/src/Options/docs.js b/src/Options/docs.js index fca3602f5c..1aded1ea5a 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -81,7 +81,7 @@ * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes - * @property {Boolean} renewSessions Whether Parse Server should automatically extend a valid session by the sessionLength + * @property {Boolean} extendSessionOnUse Whether Parse Server should automatically extend a valid session by the sessionLength * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions. diff --git a/src/Options/index.js b/src/Options/index.js index 75532ee989..0411563a8a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -205,7 +205,7 @@ export interface ParseServerOptions { sessionLength: ?number; /* Whether Parse Server should automatically extend a valid session by the sessionLength :DEFAULT: false */ - renewSessions: ?boolean; + extendSessionOnUse: ?boolean; /* Default value for limit option on queries, defaults to `100`. :DEFAULT: 100 */ defaultLimit: ?number; From 0b4bc9871d7a302dea2158c65661a031217823b7 Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 15:43:51 +1000 Subject: [PATCH 4/5] tests --- spec/Auth.spec.js | 5 ++++- src/Auth.js | 16 +++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index a53b8a6937..26421487df 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -112,7 +112,10 @@ describe('Auth', () => { await Parse.Server.database.update( '_Session', { objectId: session.id }, - { updatedAt: Parse._encode(updatedAt), expiresAt: Parse._encode(expiry) } + { + expiresAt: { __type: 'Date', iso: expiry.toISOString() }, + updatedAt: updatedAt.toISOString(), + } ); await session.fetch(); await new Promise(resolve => setTimeout(resolve, 1000)); diff --git a/src/Auth.js b/src/Auth.js index 8bedb2a684..0617301d69 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -3,6 +3,8 @@ import { isDeepStrictEqual } from 'util'; import { getRequestObject, resolveError } from './triggers'; import Deprecator from './Deprecator/Deprecator'; import { logger } from './logger'; +import RestQuery from './RestQuery'; +import RestWrite from './RestWrite'; // An Auth object tells you who is requesting something and whether // the master key was used. @@ -75,7 +77,6 @@ const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { throttle[sessionToken] = setTimeout(async () => { try { if (!session) { - const RestQuery = require('./RestQuery'); const { results } = await new RestQuery( config, master(config), @@ -83,6 +84,7 @@ const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { { sessionToken }, { limit: 1 } ).execute(); + console.log({ results }); session = results[0]; } const lastUpdated = new Date(session?.updatedAt); @@ -91,14 +93,14 @@ const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { if (lastUpdated > yesterday || !session) { return; } - await config.database.update( + const expiresAt = config.generateSessionExpiresAt(); + await new RestWrite( + config, + master(config), '_Session', { objectId: session.objectId }, - { - expiresAt: Parse._encode(config.generateSessionExpiresAt()), - updatedAt: Parse._encode(new Date()), - } - ); + { expiresAt: Parse._encode(expiresAt) } + ).execute(); } catch (e) { if (e?.code !== Parse.Error.OBJECT_NOT_FOUND) { logger.error('Could not update session expiry: ', e); From 373ce33f9e3dedb12f69f4a3c4ca96a3cfc54e5a Mon Sep 17 00:00:00 2001 From: dblythy Date: Tue, 16 May 2023 16:00:13 +1000 Subject: [PATCH 5/5] defs --- src/Options/Definitions.js | 12 ++++++------ src/Options/docs.js | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index bdc912e92e..a583c38c24 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -227,6 +227,12 @@ module.exports.ParseServerOptions = { action: parsers.booleanParser, default: true, }, + extendSessionOnUse: { + env: 'PARSE_SERVER_EXTEND_SESSION_ON_USE', + help: 'Whether Parse Server should automatically extend a valid session by the sessionLength', + action: parsers.booleanParser, + default: false, + }, fileKey: { env: 'PARSE_SERVER_FILE_KEY', help: 'Key for your files', @@ -437,12 +443,6 @@ module.exports.ParseServerOptions = { env: 'PARSE_SERVER_READ_ONLY_MASTER_KEY', help: 'Read-only key, which has the same capabilities as MasterKey without writes', }, - extendSessionOnUse: { - env: 'PARSE_SERVER_RENEW_SESSIONS', - help: 'Whether Parse Server should automatically extend a valid session by the sessionLength', - action: parsers.booleanParser, - default: false, - }, requestKeywordDenylist: { env: 'PARSE_SERVER_REQUEST_KEYWORD_DENYLIST', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 1aded1ea5a..856707e0fa 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -43,6 +43,7 @@ * @property {String} encryptionKey Key for encrypting your files * @property {Boolean} enforcePrivateUsers Set to true if new users should be created without public read and write access. * @property {Boolean} expireInactiveSessions Sets whether we should expire the inactive sessions, defaults to true. If false, all new sessions are created with no expiration date. + * @property {Boolean} extendSessionOnUse Whether Parse Server should automatically extend a valid session by the sessionLength * @property {String} fileKey Key for your files * @property {Adapter} filesAdapter Adapter module for the files sub-system * @property {FileUploadOptions} fileUpload Options for file uploads @@ -81,7 +82,6 @@ * @property {Any} push Configuration for push, as stringified JSON. See http://docs.parseplatform.org/parse-server/guide/#push-notifications * @property {RateLimitOptions[]} rateLimit Options to limit repeated requests to Parse Server APIs. This can be used to protect sensitive endpoints such as `/requestPasswordReset` from brute-force attacks or Parse Server as a whole from denial-of-service (DoS) attacks.

ℹ️ Mind the following limitations:
- rate limits applied per IP address; this limits protection against distributed denial-of-service (DDoS) attacks where many requests are coming from various IP addresses
- if multiple Parse Server instances are behind a load balancer or ran in a cluster, each instance will calculate it's own request rates, independent from other instances; this limits the applicability of this feature when using a load balancer and another rate limiting solution that takes requests across all instances into account may be more suitable
- this feature provides basic protection against denial-of-service attacks, but a more sophisticated solution works earlier in the request flow and prevents a malicious requests to even reach a server instance; it's therefore recommended to implement a solution according to architecture and user case. * @property {String} readOnlyMasterKey Read-only key, which has the same capabilities as MasterKey without writes - * @property {Boolean} extendSessionOnUse Whether Parse Server should automatically extend a valid session by the sessionLength * @property {RequestKeywordDenylist[]} requestKeywordDenylist An array of keys and values that are prohibited in database read and write requests to prevent potential security vulnerabilities. It is possible to specify only a key (`{"key":"..."}`), only a value (`{"value":"..."}`) or a key-value pair (`{"key":"...","value":"..."}`). The specification can use the following types: `boolean`, `numeric` or `string`, where `string` will be interpreted as a regex notation. Request data is deep-scanned for matching definitions to detect also any nested occurrences. Defaults are patterns that are likely to be used in malicious requests. Setting this option will override the default patterns. * @property {String} restAPIKey Key for REST calls * @property {Boolean} revokeSessionOnPasswordReset When a user changes their password, either through the reset password email or while logged in, all sessions are revoked if this is true. Set to false if you don't want to revoke sessions.