Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: extendSessionOnUse to automatically renew Parse Sessions #8505

Merged
merged 6 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions spec/Auth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
16 changes: 16 additions & 0 deletions spec/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 48 additions & 2 deletions src/Auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throttle seems to be a simple object never cleared, and it's not an LRU, memory is exposed to DDOS issues (since it seems that every token will be stored in memory), and the memory will grow infinitely

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked also clearTimeout function do not unset in the record on the throttle object. So the timeout instance is cancelled but still there in memory

Copy link
Member Author

@dblythy dblythy Jul 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I’ll open a new issue and fix

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked also clearTimeout function do not unset in the record on the throttle object. So the timeout instance is cancelled but still there in memory

try {
if (!session) {
const { results } = await new RestQuery(
config,
master(config),
'_Session',
{ sessionToken },
{ limit: 1 }
).execute();
console.log({ results });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

console.log() here

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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions src/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export class Config {
logLevels,
rateLimit,
databaseOptions,
extendSessionOnUse,
}) {
if (masterKey === readOnlyMasterKey) {
throw new Error('masterKey and readOnlyMasterKey should be different');
Expand All @@ -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://';
Expand Down
6 changes: 6 additions & 0 deletions src/Options/Definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/Options/docs.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions src/Options/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down