diff --git a/spec/Auth.spec.js b/spec/Auth.spec.js index 5ed6bfe941..26421487df 100644 --- a/spec/Auth.spec.js +++ b/spec/Auth.spec.js @@ -94,6 +94,35 @@ describe('Auth', () => { }); }); + it('can use extendSessionOnUse', async () => { + await reconfigureServer({ + extendSessionOnUse: 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(); + expiry.setHours(expiry.getHours() + 1); + + await Parse.Server.database.update( + '_Session', + { objectId: session.id }, + { + expiresAt: { __type: 'Date', iso: expiry.toISOString() }, + updatedAt: updatedAt.toISOString(), + } + ); + 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..66654aaec4 100644 --- a/spec/index.spec.js +++ b/spec/index.spec.js @@ -367,6 +367,22 @@ describe('server', () => { }); }); + it('should throw when extendSessionOnUse is invalid', async () => { + await expectAsync( + reconfigureServer({ + extendSessionOnUse: 'yolo', + }) + ).toBeRejectedWith('extendSessionOnUse 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..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. @@ -66,6 +68,47 @@ function nobody(config) { return new Auth({ config, isMaster: false }); } +const throttle = {}; +const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { + if (!config?.extendSessionOnUse) { + return; + } + clearTimeout(throttle[sessionToken]); + throttle[sessionToken] = setTimeout(async () => { + try { + if (!session) { + const { results } = await new RestQuery( + config, + master(config), + '_Session', + { sessionToken }, + { limit: 1 } + ).execute(); + console.log({ results }); + session = results[0]; + } + const lastUpdated = new Date(session?.updatedAt); + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + if (lastUpdated > yesterday || !session) { + return; + } + const expiresAt = config.generateSessionExpiresAt(); + await new RestWrite( + config, + master(config), + '_Session', + { objectId: session.objectId }, + { expiresAt: Parse._encode(expiresAt) } + ).execute(); + } 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 +121,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 +156,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..747af78f82 100644 --- a/src/Config.js +++ b/src/Config.js @@ -86,6 +86,7 @@ export class Config { logLevels, rateLimit, databaseOptions, + extendSessionOnUse, }) { 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 extendSessionOnUse !== 'boolean') { + throw 'extendSessionOnUse 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 d6acc948e3..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', diff --git a/src/Options/docs.js b/src/Options/docs.js index 8ebf63b97d..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 diff --git a/src/Options/index.js b/src/Options/index.js index 59e040b57f..0411563a8a 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -203,6 +203,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 */ + extendSessionOnUse: ?boolean; /* Default value for limit option on queries, defaults to `100`. :DEFAULT: 100 */ defaultLimit: ?number;