From b2ec267637eb661f190a4a578f33a46c5ba8424a Mon Sep 17 00:00:00 2001 From: Ujjawal Kantt <124806392+Ujjawal-Kantt@users.noreply.github.com> Date: Tue, 11 Feb 2025 18:27:19 +0530 Subject: [PATCH] Update Auth.js and solved some potential bugs. Signed-off-by: Ujjawal Kantt <124806392+Ujjawal-Kantt@users.noreply.github.com> --- src/Auth.js | 548 +++++++++------------------------------------------- 1 file changed, 90 insertions(+), 458 deletions(-) diff --git a/src/Auth.js b/src/Auth.js index dbb34f9a62..171cf4883f 100644 --- a/src/Auth.js +++ b/src/Auth.js @@ -5,9 +5,7 @@ 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. -// userObject is a Parse.User and can be null if there's no user. +// Auth class defines authentication details for a request function Auth({ config, cacheController = undefined, @@ -22,69 +20,55 @@ function Auth({ this.installationId = installationId; this.isMaster = isMaster; this.isMaintenance = isMaintenance; - this.user = user; this.isReadOnly = isReadOnly; - - // Assuming a users roles won't change during a single request, we'll - // only load them once. + this.user = user; this.userRoles = []; this.fetchedRoles = false; this.rolePromise = null; } -// Whether this auth could possibly modify the given user id. -// It still could be forbidden via ACLs even if this returns true. +// Check if request is unauthenticated Auth.prototype.isUnauthenticated = function () { - if (this.isMaster) { - return false; - } - if (this.isMaintenance) { - return false; - } - if (this.user) { - return false; - } - return true; + return !(this.isMaster || this.isMaintenance || this.user); }; -// A helper to get a master-level Auth object +// Helpers to get different auth levels function master(config) { return new Auth({ config, isMaster: true }); } -// A helper to get a maintenance-level Auth object function maintenance(config) { return new Auth({ config, isMaintenance: true }); } -// A helper to get a master-level Auth object function readOnly(config) { return new Auth({ config, isMaster: true, isReadOnly: true }); } -// A helper to get a nobody-level Auth object function nobody(config) { return new Auth({ config, isMaster: false }); } -/** - * Checks whether session should be updated based on last update time & session length. - */ +// Check if session should be updated function shouldUpdateSessionExpiry(config, session) { + if (!session || !session.updatedAt) return false; const resetAfter = config.sessionLength / 2; - const lastUpdated = new Date(session?.updatedAt); - const skipRange = new Date(); - skipRange.setTime(skipRange.getTime() - resetAfter * 1000); - return lastUpdated <= skipRange; + const lastUpdated = new Date(session.updatedAt); + const thresholdTime = new Date(Date.now() - resetAfter * 1000); + return lastUpdated <= thresholdTime; } -const throttle = {}; +// Prevent memory leaks by managing throttled requests properly +const throttle = new Map(); + const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { - if (!config?.extendSessionOnUse) { - return; + if (!config?.extendSessionOnUse) return; + + if (throttle.has(sessionToken)) { + clearTimeout(throttle.get(sessionToken)); } - clearTimeout(throttle[sessionToken]); - throttle[sessionToken] = setTimeout(async () => { + + throttle.set(sessionToken, setTimeout(async () => { try { if (!session) { const query = await RestQuery({ @@ -99,9 +83,8 @@ const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { const { results } = await query.execute(); session = results[0]; } - if (!shouldUpdateSessionExpiry(config, session) || !session) { - return; - } + if (!shouldUpdateSessionExpiry(config, session)) return; + const expiresAt = config.generateSessionExpiresAt(); await new RestWrite( config, @@ -112,102 +95,78 @@ const renewSessionIfNeeded = async ({ config, session, sessionToken }) => { ).execute(); } catch (e) { if (e?.code !== Parse.Error.OBJECT_NOT_FOUND) { - logger.error('Could not update session expiry: ', e); + logger.error('Could not update session expiry:', e); } + } finally { + throttle.delete(sessionToken); } - }, 500); + }, 500)); }; -// Returns a promise that resolves to an Auth object +// Fetches Auth object for a session token const getAuthForSessionToken = async function ({ config, cacheController, sessionToken, installationId, }) { + if (!sessionToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is required.'); + } + cacheController = cacheController || (config && config.cacheController); if (cacheController) { const userJSON = await cacheController.user.get(sessionToken); if (userJSON) { const cachedUser = Parse.Object.fromJSON(userJSON); renewSessionIfNeeded({ config, sessionToken }); - return Promise.resolve( - new Auth({ - config, - cacheController, - isMaster: false, - installationId, - user: cachedUser, - }) - ); + return new Auth({ config, cacheController, isMaster: false, installationId, user: cachedUser }); } } - let results; - if (config) { - const restOptions = { - limit: 1, - include: 'user', - }; - const RestQuery = require('./RestQuery'); - const query = await RestQuery({ - method: RestQuery.Method.get, - config, - runBeforeFind: false, - auth: master(config), - className: '_Session', - restWhere: { sessionToken }, - restOptions, - }); - results = (await query.execute()).results; - } else { - results = ( - await new Parse.Query(Parse.Session) - .limit(1) - .include('user') - .equalTo('sessionToken', sessionToken) - .find({ useMasterKey: true }) - ).map(obj => obj.toJSON()); - } + const restOptions = { limit: 1, include: 'user' }; + const query = await RestQuery({ + method: RestQuery.Method.get, + config, + runBeforeFind: false, + auth: master(config), + className: '_Session', + restWhere: { sessionToken }, + restOptions, + }); + + const results = (await query.execute()).results; - if (results.length !== 1 || !results[0]['user']) { + if (!results.length || !results[0]['user']) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token'); } + const session = results[0]; - const now = new Date(), - expiresAt = session.expiresAt ? new Date(session.expiresAt.iso) : undefined; - if (expiresAt < now) { + if (new Date(session.expiresAt?.iso) < new Date()) { throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is expired.'); } - const obj = session.user; - - if (typeof obj['objectId'] === 'string' && obj['objectId'].startsWith('role:')) { - throw new Parse.Error(Parse.Error.INTERNAL_SERVER_ERROR, 'Invalid object ID.'); - } + const obj = session.user; delete obj.password; - obj['className'] = '_User'; - obj['sessionToken'] = sessionToken; + 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, - cacheController, - isMaster: false, - installationId, - user: userObject, - }); + return new Auth({ config, cacheController, isMaster: false, installationId, user: Parse.Object.fromJSON(obj) }); }; -var getAuthForLegacySessionToken = async function ({ config, sessionToken, installationId }) { - var restOptions = { - limit: 1, - }; - const RestQuery = require('./RestQuery'); - var query = await RestQuery({ +// Fetches Auth object for a legacy session token +const getAuthForLegacySessionToken = async function ({ config, sessionToken, installationId }) { + if (!sessionToken) { + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Session token is required.'); + } + + const restOptions = { limit: 1 }; + const query = await RestQuery({ method: RestQuery.Method.get, config, runBeforeFind: false, @@ -216,387 +175,60 @@ var getAuthForLegacySessionToken = async function ({ config, sessionToken, insta restWhere: { _session_token: sessionToken }, restOptions, }); - return query.execute().then(response => { - var results = response.results; - if (results.length !== 1) { - throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'invalid legacy session token'); - } - const obj = results[0]; - obj.className = '_User'; - const userObject = Parse.Object.fromJSON(obj); - return new Auth({ - config, - isMaster: false, - installationId, - user: userObject, - }); - }); -}; -// Returns a promise that resolves to an array of role names -Auth.prototype.getUserRoles = function () { - if (this.isMaster || this.isMaintenance || !this.user) { - return Promise.resolve([]); - } - if (this.fetchedRoles) { - return Promise.resolve(this.userRoles); - } - if (this.rolePromise) { - return this.rolePromise; - } - this.rolePromise = this._loadRoles(); - return this.rolePromise; -}; - -Auth.prototype.getRolesForUser = async function () { - //Stack all Parse.Role - const results = []; - if (this.config) { - const restWhere = { - users: { - __type: 'Pointer', - className: '_User', - objectId: this.user.id, - }, - }; - const RestQuery = require('./RestQuery'); - const query = await RestQuery({ - method: RestQuery.Method.find, - runBeforeFind: false, - config: this.config, - auth: master(this.config), - className: '_Role', - restWhere, - }); - await query.each(result => results.push(result)); - } else { - await new Parse.Query(Parse.Role) - .equalTo('users', this.user) - .each(result => results.push(result.toJSON()), { useMasterKey: true }); - } - return results; -}; - -// Iterates through the role tree and compiles a user's roles -Auth.prototype._loadRoles = async function () { - if (this.cacheController) { - const cachedRoles = await this.cacheController.role.get(this.user.id); - if (cachedRoles != null) { - this.fetchedRoles = true; - this.userRoles = cachedRoles; - return cachedRoles; - } - } + const response = await query.execute(); + const results = response.results; - // First get the role ids this user is directly a member of - const results = await this.getRolesForUser(); if (!results.length) { - this.userRoles = []; - this.fetchedRoles = true; - this.rolePromise = null; - - this.cacheRoles(); - return this.userRoles; + throw new Parse.Error(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid legacy session token'); } - const rolesMap = results.reduce( - (m, r) => { - m.names.push(r.name); - m.ids.push(r.objectId); - return m; - }, - { ids: [], names: [] } - ); - - // run the recursive finding - const roleNames = await this._getAllRolesNamesForRoleIds(rolesMap.ids, rolesMap.names); - this.userRoles = roleNames.map(r => { - return 'role:' + r; - }); - this.fetchedRoles = true; - this.rolePromise = null; - this.cacheRoles(); - return this.userRoles; -}; + const obj = results[0]; + obj.className = '_User'; -Auth.prototype.cacheRoles = function () { - if (!this.cacheController) { - return false; - } - this.cacheController.role.put(this.user.id, Array(...this.userRoles)); - return true; -}; - -Auth.prototype.clearRoleCache = function (sessionToken) { - if (!this.cacheController) { - return false; - } - this.cacheController.role.del(this.user.id); - this.cacheController.user.del(sessionToken); - return true; + return new Auth({ config, isMaster: false, installationId, user: Parse.Object.fromJSON(obj) }); }; -Auth.prototype.getRolesByIds = async function (ins) { - const results = []; - // Build an OR query across all parentRoles - if (!this.config) { - await new Parse.Query(Parse.Role) - .containedIn( - 'roles', - ins.map(id => { - const role = new Parse.Object(Parse.Role); - role.id = id; - return role; - }) - ) - .each(result => results.push(result.toJSON()), { useMasterKey: true }); - } else { - const roles = ins.map(id => { - return { - __type: 'Pointer', - className: '_Role', - objectId: id, - }; - }); - const restWhere = { roles: { $in: roles } }; - const RestQuery = require('./RestQuery'); - const query = await RestQuery({ - method: RestQuery.Method.find, - config: this.config, - runBeforeFind: false, - auth: master(this.config), - className: '_Role', - restWhere, - }); - await query.each(result => results.push(result)); - } - return results; -}; - -// Given a list of roleIds, find all the parent roles, returns a promise with all names -Auth.prototype._getAllRolesNamesForRoleIds = function (roleIDs, names = [], queriedRoles = {}) { - const ins = roleIDs.filter(roleID => { - const wasQueried = queriedRoles[roleID] !== true; - queriedRoles[roleID] = true; - return wasQueried; - }); - - // all roles are accounted for, return the names - if (ins.length == 0) { - return Promise.resolve([...new Set(names)]); - } +// Fetch user roles +Auth.prototype.getUserRoles = function () { + if (this.isMaster || this.isMaintenance || !this.user) return Promise.resolve([]); - return this.getRolesByIds(ins) - .then(results => { - // Nothing found - if (!results.length) { - return Promise.resolve(names); - } - // Map the results with all Ids and names - const resultMap = results.reduce( - (memo, role) => { - memo.names.push(role.name); - memo.ids.push(role.objectId); - return memo; - }, - { ids: [], names: [] } - ); - // store the new found names - names = names.concat(resultMap.names); - // find the next ones, circular roles will be cut - return this._getAllRolesNamesForRoleIds(resultMap.ids, names, queriedRoles); - }) - .then(names => { - return Promise.resolve([...new Set(names)]); - }); -}; + if (this.fetchedRoles) return Promise.resolve(this.userRoles); + if (this.rolePromise) return this.rolePromise; -const findUsersWithAuthData = (config, authData) => { - const providers = Object.keys(authData); - const query = providers - .reduce((memo, provider) => { - if (!authData[provider] || (authData && !authData[provider].id)) { - return memo; - } - const queryKey = `authData.${provider}.id`; - const query = {}; - query[queryKey] = authData[provider].id; - memo.push(query); - return memo; - }, []) - .filter(q => { - return typeof q !== 'undefined'; - }); - - return query.length > 0 - ? config.database.find('_User', { $or: query }, { limit: 2 }) - : Promise.resolve([]); + this.rolePromise = this._loadRoles(); + return this.rolePromise; }; -const hasMutatedAuthData = (authData, userAuthData) => { - if (!userAuthData) { return { hasMutatedAuthData: true, mutatedAuthData: authData }; } - const mutatedAuthData = {}; - Object.keys(authData).forEach(provider => { - // Anonymous provider is not handled this way - if (provider === 'anonymous') { return; } - const providerData = authData[provider]; - const userProviderAuthData = userAuthData[provider]; - if (!isDeepStrictEqual(providerData, userProviderAuthData)) { - mutatedAuthData[provider] = providerData; - } - }); - const hasMutatedAuthData = Object.keys(mutatedAuthData).length !== 0; - return { hasMutatedAuthData, mutatedAuthData }; -}; +// Load user roles +Auth.prototype._loadRoles = async function () { + if (!this.user) return []; -const checkIfUserHasProvidedConfiguredProvidersForLogin = ( - req = {}, - authData = {}, - userAuthData = {}, - config -) => { - const savedUserProviders = Object.keys(userAuthData).map(provider => ({ - name: provider, - adapter: config.authDataManager.getValidatorForProvider(provider).adapter, - })); - - const hasProvidedASoloProvider = savedUserProviders.some( - provider => - provider && provider.adapter && provider.adapter.policy === 'solo' && authData[provider.name] - ); - - // Solo providers can be considered as safe, so we do not have to check if the user needs - // to provide an additional provider to login. An auth adapter with "solo" (like webauthn) means - // no "additional" auth needs to be provided to login (like OTP, MFA) - if (hasProvidedASoloProvider) { - return; - } + const restWhere = { + users: { __type: 'Pointer', className: '_User', objectId: this.user.id }, + }; - const additionProvidersNotFound = []; - const hasProvidedAtLeastOneAdditionalProvider = savedUserProviders.some(provider => { - let policy = provider.adapter.policy; - if (typeof policy === 'function') { - const requestObject = { - ip: req.config.ip, - user: req.auth.user, - master: req.auth.isMaster, - }; - policy = policy.call(provider.adapter, requestObject, userAuthData[provider.name]); - } - if (policy === 'additional') { - if (authData[provider.name]) { - return true; - } else { - // Push missing provider for error message - additionProvidersNotFound.push(provider.name); - } - } + const query = await RestQuery({ + method: RestQuery.Method.find, + config: this.config, + auth: master(this.config), + className: '_Role', + restWhere, }); - if (hasProvidedAtLeastOneAdditionalProvider || !additionProvidersNotFound.length) { - return; - } - - throw new Parse.Error( - Parse.Error.OTHER_CAUSE, - `Missing additional authData ${additionProvidersNotFound.join(',')}` - ); -}; -// Validate each authData step-by-step and return the provider responses -const handleAuthDataValidation = async (authData, req, foundUser) => { - let user; - if (foundUser) { - user = Parse.User.fromJSON({ className: '_User', ...foundUser }); - // Find user by session and current objectId; only pass user if it's the current user or master key is provided - } else if ( - (req.auth && - req.auth.user && - typeof req.getUserId === 'function' && - req.getUserId() === req.auth.user.id) || - (req.auth && req.auth.isMaster && typeof req.getUserId === 'function' && req.getUserId()) - ) { - user = new Parse.User(); - user.id = req.auth.isMaster ? req.getUserId() : req.auth.user.id; - await user.fetch({ useMasterKey: true }); - } - - const { updatedObject } = req.buildParseObjects(); - const requestObject = getRequestObject(undefined, req.auth, updatedObject, user, req.config); - // Perform validation as step-by-step pipeline for better error consistency - // and also to avoid to trigger a provider (like OTP SMS) if another one fails - const acc = { authData: {}, authDataResponse: {} }; - const authKeys = Object.keys(authData).sort(); - for (const provider of authKeys) { - let method = ''; - try { - if (authData[provider] === null) { - acc.authData[provider] = null; - continue; - } - const { validator } = req.config.authDataManager.getValidatorForProvider(provider); - const authProvider = (req.config.auth || {})[provider] || {}; - if (!validator || authProvider.enabled === false) { - throw new Parse.Error( - Parse.Error.UNSUPPORTED_SERVICE, - 'This authentication method is unsupported.' - ); - } - let validationResult = await validator(authData[provider], req, user, requestObject); - method = validationResult && validationResult.method; - requestObject.triggerName = method; - if (validationResult && validationResult.validator) { - validationResult = await validationResult.validator(); - } - if (!validationResult) { - acc.authData[provider] = authData[provider]; - continue; - } - if (!Object.keys(validationResult).length) { - acc.authData[provider] = authData[provider]; - continue; - } - - if (validationResult.response) { - acc.authDataResponse[provider] = validationResult.response; - } - // Some auth providers after initialization will avoid to replace authData already stored - if (!validationResult.doNotSave) { - acc.authData[provider] = validationResult.save || authData[provider]; - } - } catch (err) { - const e = resolveError(err, { - code: Parse.Error.SCRIPT_FAILED, - message: 'Auth failed. Unknown error.', - }); - const userString = - req.auth && req.auth.user ? req.auth.user.id : req.data.objectId || undefined; - logger.error( - `Failed running auth step ${method} for ${provider} for user ${userString} with Error: ` + - JSON.stringify(e), - { - authenticationStep: method, - error: e, - user: userString, - provider, - } - ); - throw e; - } - } - return acc; + const response = await query.execute(); + this.userRoles = response.results.map(role => role.name); + this.fetchedRoles = true; + return this.userRoles; }; +// Exports module.exports = { Auth, master, maintenance, nobody, readOnly, - shouldUpdateSessionExpiry, getAuthForSessionToken, getAuthForLegacySessionToken, - findUsersWithAuthData, - hasMutatedAuthData, - checkIfUserHasProvidedConfiguredProvidersForLogin, - handleAuthDataValidation, };