diff --git a/client/pages/account.vue b/client/pages/account.vue index 4bb68727..f531a34d 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -82,19 +82,33 @@ export default { this.$setLanguageCode(lang) }, logout() { - var rootSocket = this.$root.socket || {} - const logoutPayload = { - socketId: rootSocket.id + // Disconnect from socket + if (this.$root.socket) { + console.log('Disconnecting from socket', this.$root.socket.id) + this.$root.socket.removeAllListeners() + this.$root.socket.disconnect() } - this.$axios.$post('/logout', logoutPayload).catch((error) => { - console.error(error) - }) + if (localStorage.getItem('token')) { localStorage.removeItem('token') } this.$store.commit('libraries/setUserPlaylists', []) this.$store.commit('libraries/setCollections', []) - this.$router.push('/login') + + this.$axios + .$post('/logout') + .then((logoutPayload) => { + const redirect_url = logoutPayload.redirect_url + + if (redirect_url) { + window.location.href = redirect_url + } else { + this.$router.push('/login') + } + }) + .catch((error) => { + console.error(error) + }) }, resetForm() { this.password = null diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 199701b4..3373e287 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -206,7 +206,7 @@ export default { function isValidRedirectURI(uri) { // Check for somestring://someother/string - const pattern = new RegExp('^\\w+://[\\w\\.-]+$', 'i') + const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i') return pattern.test(uri) } diff --git a/server/Auth.js b/server/Auth.js index 88b25c66..cec3bc33 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -81,7 +81,8 @@ class Auth { authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, token_endpoint: global.ServerSettings.authOpenIDTokenURL, userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, - jwks_uri: global.ServerSettings.authOpenIDJwksURL + jwks_uri: global.ServerSettings.authOpenIDJwksURL, + end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL }).Client const openIdClient = new openIdIssuerClient({ client_id: global.ServerSettings.authOpenIDClientID, @@ -153,6 +154,9 @@ class Auth { return } + // We also have to save the id_token for later (used for logout) because we cannot set cookies here + user.openid_id_token = tokenset.id_token + // permit login return done(null, user) })) @@ -183,49 +187,48 @@ class Auth { } /** - * Stores the client's choice how the login callback should happen in temp cookies + * Returns if the given auth method is API based. + * + * @param {string} authMethod + * @returns {boolean} + */ + isAuthMethodAPIBased(authMethod) { + return ['api', 'openid-mobile'].includes(authMethod) + } + + /** + * Stores the client's choice of login callback method in temporary cookies. + * + * The `authMethod` parameter specifies the authentication strategy and can have the following values: + * - 'local': Standard authentication, + * - 'api': Authentication for API use + * - 'openid': OpenID authentication directly over web + * - 'openid-mobile': OpenID authentication, but done via an mobile device * * @param {import('express').Request} req * @param {import('express').Response} res + * @param {string} authMethod - The authentication method, default is 'local'. */ - paramsToCookies(req, res) { - // Set if isRest flag is set or if mobile oauth flow is used - if (req.query.isRest?.toLowerCase() == 'true' || req.query.redirect_uri) { - // store the isRest flag to the is_rest cookie - res.cookie('is_rest', 'true', { - maxAge: 120000, // 2 min - httpOnly: true - }) - } else { - // no isRest-flag set -> set is_rest cookie to false - res.cookie('is_rest', 'false', { - maxAge: 120000, // 2 min - httpOnly: true - }) + paramsToCookies(req, res, authMethod = 'local') { + const TWO_MINUTES = 120000 // 2 minutes in milliseconds + const callback = req.query.redirect_uri || req.query.callback - // persist state if passed in + // Additional handling for non-API based authMethod + if (!this.isAuthMethodAPIBased(authMethod)) { + // Store 'auth_state' if present in the request if (req.query.state) { - res.cookie('auth_state', req.query.state, { - maxAge: 120000, // 2 min - httpOnly: true - }) + res.cookie('auth_state', req.query.state, { maxAge: TWO_MINUTES, httpOnly: true }) } - const callback = req.query.redirect_uri || req.query.callback - - // check if we are missing a callback parameter - we need one if isRest=false + // Validate and store the callback URL if (!callback) { - res.status(400).send({ - message: 'No callback parameter' - }) - return + return res.status(400).send({ message: 'No callback parameter' }) } - // store the callback url to the auth_cb cookie - res.cookie('auth_cb', callback, { - maxAge: 120000, // 2 min - httpOnly: true - }) + res.cookie('auth_cb', callback, { maxAge: TWO_MINUTES, httpOnly: true }) } + + // Store the authentication method for long + res.cookie('auth_method', authMethod, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true }) } /** @@ -239,7 +242,7 @@ class Auth { // get userLogin json (information about the user, server and the session) const data_json = await this.getUserLoginResponsePayload(req.user) - if (req.cookies.is_rest === 'true') { + if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data res.json(data_json) } else { @@ -269,109 +272,105 @@ class Auth { // openid strategy login route (this redirects to the configured openid login provider) router.get('/auth/openid', (req, res, next) => { + // Get the OIDC client from the strategy + // We need to call the client manually, because the strategy does not support forwarding the code challenge + // for API or mobile clients + const oidcStrategy = passport._strategy('openid-client') + const client = oidcStrategy._client + const sessionKey = oidcStrategy._key + try { - // helper function from openid-client - function pick(object, ...paths) { - const obj = {} - for (const path of paths) { - if (object[path] !== undefined) { - obj[path] = object[path] - } - } - return obj + const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http' + const hostUrl = new URL(`${protocol}://${req.get('host')}`) + const isMobileFlow = req.query.response_type === 'code' || req.query.redirect_uri || req.query.code_challenge + + // Only allow code flow (for mobile clients) + if (req.query.response_type && req.query.response_type !== 'code') { + Logger.debug(`[Auth] OIDC Invalid response_type=${req.query.response_type}`) + return res.status(400).send('Invalid response_type, only code supported') } - // Get the OIDC client from the strategy - // We need to call the client manually, because the strategy does not support forwarding the code challenge - // for API or mobile clients - const oidcStrategy = passport._strategy('openid-client') - const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' + // Generate a state on web flow or if no state supplied + const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state - let mobile_redirect_uri = null - - // The client wishes a different redirect_uri - // We will allow if it is in the whitelist, by saving it into this.openIdAuthSession and setting the redirect uri to /auth/openid/mobile-redirect - // where we will handle the redirect to it - if (req.query.redirect_uri) { - // Check if the redirect_uri is in the whitelist - if (Database.serverSettings.authOpenIDMobileRedirectURIs.includes(req.query.redirect_uri) || - (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')) { - oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/mobile-redirect`).toString() - mobile_redirect_uri = req.query.redirect_uri - } else { - Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri} - not in whitelist`) + // Redirect URL for the SSO provider + let redirectUri + if (isMobileFlow) { + // Mobile required redirect uri + // If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect + // where we will handle the redirect to it + if (!req.query.redirect_uri || !isValidRedirectUri(req.query.redirect_uri)) { + Logger.debug(`[Auth] Invalid redirect_uri=${req.query.redirect_uri}`) return res.status(400).send('Invalid redirect_uri') } + // We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API + // for the request to mobile-redirect and as such the session is not shared + this.openIdAuthSession.set(state, { mobile_redirect_uri: req.query.redirect_uri }) + + redirectUri = new URL('/auth/openid/mobile-redirect', hostUrl).toString() } else { - oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString() - } + redirectUri = new URL('/auth/openid/callback', hostUrl).toString() - Logger.debug(`[Auth] Oidc redirect_uri=${oidcStrategy._params.redirect_uri}`) - const client = oidcStrategy._client - const sessionKey = oidcStrategy._key - - let code_challenge - let code_challenge_method - - // If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app) - // The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow - // and as such will not send a code challenge, we will generate then one - if (req.query.code_challenge) { - code_challenge = req.query.code_challenge - code_challenge_method = req.query.code_challenge_method || 'S256' - - if (!['S256', 'plain'].includes(code_challenge_method)) { - return res.status(400).send('Invalid code_challenge_method') + if (req.query.state) { + Logger.debug(`[Auth] Invalid state - not allowed on web openid flow`) + return res.status(400).send('Invalid state, not allowed on web flow') } - } else { - // If no code_challenge is provided, assume a web application flow and generate one - const code_verifier = OpenIDClient.generators.codeVerifier() - code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) - code_challenge_method = 'S256' - - // Store the code_verifier in the session for later use in the token exchange - req.session[sessionKey] = { ...req.session[sessionKey], code_verifier } } + oidcStrategy._params.redirect_uri = redirectUri + Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`) - const params = { - state: OpenIDClient.generators.random(), - // Other params by the passport strategy - ...oidcStrategy._params - } - - if (!params.nonce && params.response_type.includes('id_token')) { - params.nonce = OpenIDClient.generators.random() - } + let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow) req.session[sessionKey] = { ...req.session[sessionKey], - ...pick(params, 'nonce', 'state', 'max_age', 'response_type'), + state: state, + max_age: oidcStrategy._params.max_age, + response_type: 'code', + code_verifier: code_verifier, // not null if web flow mobile: req.query.redirect_uri, // Used in the abs callback later, set mobile if redirect_uri is filled out sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback } - // We cannot save redirect_uri in the session, because it the mobile client uses browser instead of the API - // for the request to mobile-redirect and as such the session is not shared - this.openIdAuthSession.set(params.state, { mobile_redirect_uri: mobile_redirect_uri }) - - // Now get the URL to direct to const authorizationUrl = client.authorizationUrl({ - ...params, - scope: 'openid profile email', + ...oidcStrategy._params, + state: state, response_type: 'code', code_challenge, code_challenge_method }) - // params (isRest, callback) to a cookie that will be send to the client - this.paramsToCookies(req, res) + this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid') - // Redirect the user agent (browser) to the authorization URL res.redirect(authorizationUrl) } catch (error) { Logger.error(`[Auth] Error in /auth/openid route: ${error}`) res.status(500).send('Internal Server Error') } + + function generatePkce(req, isMobileFlow) { + if (isMobileFlow) { + if (!req.query.code_challenge) { + throw new Error('code_challenge required for mobile flow (PKCE)') + } + if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { + throw new Error('Only S256 code_challenge_method method supported') + } + return { + code_challenge: req.query.code_challenge, + code_challenge_method: req.query.code_challenge_method || 'S256' + } + } else { + const code_verifier = OpenIDClient.generators.codeVerifier() + const code_challenge = OpenIDClient.generators.codeChallenge(code_verifier) + return { code_challenge, code_challenge_method: 'S256', code_verifier } + } + } + + function isValidRedirectUri(uri) { + // Check if the redirect_uri is in the whitelist + return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || + (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*') + } }) // This will be the oauth2 callback route for mobile clients @@ -453,6 +452,12 @@ class Auth { if (loginError) { return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`) } + + // The id_token does not provide access to the user, but is used to identify the user to the SSO provider + // instead it containts a JWT with userinfo like user email, username, etc. + // the client will get to know it anyway in the logout url according to the oauth2 spec + // so it is safe to send it to the client, but we use strict settings + res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' }) next() }) } @@ -521,7 +526,46 @@ class Auth { if (err) { res.sendStatus(500) } else { - res.sendStatus(200) + const authMethod = req.cookies.auth_method + + res.clearCookie('auth_method') + + if (authMethod === 'openid' || authMethod === 'openid-mobile') { + // If we are using openid, we need to redirect to the logout endpoint + // node-openid-client does not support doing it over passport + const oidcStrategy = passport._strategy('openid-client') + const client = oidcStrategy._client + + let postLogoutRedirectUri = null + + if (authMethod === 'openid') { + const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' + const host = req.get('host') + // TODO: ABS does currently not support subfolders for installation + // If we want to support it we need to include a config for the serverurl + postLogoutRedirectUri = `${protocol}://${host}/login` + } + // else for openid-mobile we keep postLogoutRedirectUri on null + // nice would be to redirect to the app here, but for example Authentik does not implement + // the post_logout_redirect_uri parameter at all and for other providers + // we would also need again to implement (and even before get to know somehow for 3rd party apps) + // the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect). + // Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like + // &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution + // (The URL needs to be whitelisted in the config of the SSO/ID provider) + + const logoutUrl = client.endSessionUrl({ + id_token_hint: req.cookies.openid_id_token, + post_logout_redirect_uri: postLogoutRedirectUri + }) + + res.clearCookie('openid_id_token') + + // Tell the user agent (browser) to redirect to the authentification provider's logout URL + res.send({ redirect_url: logoutUrl }) + } else { + res.sendStatus(200) + } } }) }) @@ -612,7 +656,7 @@ class Auth { * Checks if a username and password tuple is valid and the user active. * @param {string} username * @param {string} password - * @param {function} done + * @param {Promise} done */ async localAuthCheckUserPw(username, password, done) { // Load the user given it's username @@ -654,7 +698,7 @@ class Auth { /** * Hashes a password with bcrypt. * @param {string} password - * @returns {string} hash + * @returns {Promise} hash */ hashPass(password) { return new Promise((resolve) => { @@ -688,8 +732,8 @@ class Auth { /** * * @param {string} password - * @param {*} user - * @returns {boolean} + * @param {import('./models/User')} user + * @returns {Promise} */ comparePassword(password, user) { if (user.type === 'root' && !password && !user.pash) return true diff --git a/server/Server.js b/server/Server.js index 6feabee8..01b1af12 100644 --- a/server/Server.js +++ b/server/Server.js @@ -5,7 +5,6 @@ const http = require('http') const util = require('util') const fs = require('./libs/fsExtra') const fileUpload = require('./libs/expressFileupload') -const rateLimit = require('./libs/expressRateLimit') const cookieParser = require("cookie-parser") const { version } = require('../package.json') @@ -287,8 +286,6 @@ class Server { ] dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html')))) - // router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this)) - // router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) router.post('/init', (req, res) => { if (Database.hasRootUser) { Logger.error(`[Server] attempt to init server when server already has a root user`) @@ -401,30 +398,6 @@ class Server { } } - // First time login rate limit is hit - loginLimitReached(req, res, options) { - Logger.error(`[Server] Login rate limit (${options.max}) was hit for ip ${req.ip}`) - options.message = 'Too many attempts. Login temporarily locked.' - } - - getLoginRateLimiter() { - return rateLimit({ - windowMs: Database.serverSettings.rateLimitLoginWindow, // 5 minutes - max: Database.serverSettings.rateLimitLoginRequests, - skipSuccessfulRequests: true, - onLimitReached: this.loginLimitReached - }) - } - - logout(req, res) { - if (req.body.socketId) { - Logger.info(`[Server] User ${req.user ? req.user.username : 'Unknown'} is logging out with socket ${req.body.socketId}`) - SocketAuthority.logout(req.body.socketId) - } - - res.sendStatus(200) - } - /** * Gracefully stop server * Stops watcher and socket server diff --git a/server/SocketAuthority.js b/server/SocketAuthority.js index b626c0e4..930037a8 100644 --- a/server/SocketAuthority.js +++ b/server/SocketAuthority.js @@ -219,25 +219,6 @@ class SocketAuthority { client.socket.emit('init', initialPayload) } - logout(socketId) { - // Strip user and client from client and client socket - if (socketId && this.clients[socketId]) { - const client = this.clients[socketId] - const clientSocket = client.socket - Logger.debug(`[SocketAuthority] Found user client ${clientSocket.id}, Has user: ${!!client.user}, Socket has client: ${!!clientSocket.sheepClient}`) - - if (client.user) { - Logger.debug('[SocketAuthority] User Offline ' + client.user.username) - this.adminEmitter('user_offline', client.user.toJSONForPublic()) - } - - delete this.clients[socketId].user - if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient - } else if (socketId) { - Logger.warn(`[SocketAuthority] No client for socket ${socketId}`) - } - } - cancelScan(id) { Logger.debug('[SocketAuthority] Cancel scan', id) this.Server.cancelLibraryScan(id) diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index 2badbb6e..4c4a06ff 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -633,7 +633,7 @@ class MiscController { } else if (key === 'authOpenIDMobileRedirectURIs') { function isValidRedirectURI(uri) { if (typeof uri !== 'string') return false - const pattern = new RegExp('^\\w+://[\\w.-]+$', 'i') + const pattern = new RegExp('^\\w+://[\\w\\.-]+(/[\\w\\./-]*)*$', 'i') return pattern.test(uri) } diff --git a/server/libs/expressRateLimit/LICENSE b/server/libs/expressRateLimit/LICENSE deleted file mode 100644 index f4bb9cc3..00000000 --- a/server/libs/expressRateLimit/LICENSE +++ /dev/null @@ -1,20 +0,0 @@ -# MIT License - -Copyright 2021 Nathan Friedly - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/server/libs/expressRateLimit/index.js b/server/libs/expressRateLimit/index.js deleted file mode 100644 index 6df27ff5..00000000 --- a/server/libs/expressRateLimit/index.js +++ /dev/null @@ -1,196 +0,0 @@ -"use strict"; - -// -// modified for use in audiobookshelf -// Source: https://github.com/nfriedly/express-rate-limit -// - -const MemoryStore = require("./memory-store"); - -function RateLimit(options) { - options = Object.assign( - { - windowMs: 60 * 1000, // milliseconds - how long to keep records of requests in memory - max: 5, // max number of recent connections during `window` milliseconds before sending a 429 response - message: "Too many requests, please try again later.", - statusCode: 429, // 429 status = Too Many Requests (RFC 6585) - headers: true, //Send custom rate limit header with limit and remaining - draft_polli_ratelimit_headers: false, //Support for the new RateLimit standardization headers - // ability to manually decide if request was successful. Used when `skipSuccessfulRequests` and/or `skipFailedRequests` are set to `true` - requestWasSuccessful: function (req, res) { - return res.statusCode < 400; - }, - skipFailedRequests: false, // Do not count failed requests - skipSuccessfulRequests: false, // Do not count successful requests - // allows to create custom keys (by default user IP is used) - keyGenerator: function (req /*, res*/) { - if (!req.ip) { - console.error( - "express-rate-limit: req.ip is undefined - you can avoid this by providing a custom keyGenerator function, but it may be indicative of a larger issue." - ); - } - return req.ip; - }, - skip: function (/*req, res*/) { - return false; - }, - handler: function (req, res /*, next, optionsUsed*/) { - res.status(options.statusCode).send(options.message); - }, - onLimitReached: function (/*req, res, optionsUsed*/) { }, - requestPropertyName: "rateLimit", // Parameter name appended to req object - }, - options - ); - - // store to use for persisting rate limit data - options.store = options.store || new MemoryStore(options.windowMs); - - // ensure that the store has the incr method - if ( - typeof options.store.incr !== "function" || - typeof options.store.resetKey !== "function" || - (options.skipFailedRequests && - typeof options.store.decrement !== "function") - ) { - throw new Error("The store is not valid."); - } - - ["global", "delayMs", "delayAfter"].forEach((key) => { - // note: this doesn't trigger if delayMs or delayAfter are set to 0, because that essentially disables them - if (options[key]) { - throw new Error( - `The ${key} option was removed from express-rate-limit v3.` - ); - } - }); - - function rateLimit(req, res, next) { - Promise.resolve(options.skip(req, res)) - .then((skip) => { - if (skip) { - return next(); - } - - const key = options.keyGenerator(req, res); - - options.store.incr(key, function (err, current, resetTime) { - if (err) { - return next(err); - } - - const maxResult = - typeof options.max === "function" - ? options.max(req, res) - : options.max; - - Promise.resolve(maxResult) - .then((max) => { - req[options.requestPropertyName] = { - limit: max, - current: current, - remaining: Math.max(max - current, 0), - resetTime: resetTime, - }; - - if (options.headers && !res.headersSent) { - res.setHeader("X-RateLimit-Limit", max); - res.setHeader( - "X-RateLimit-Remaining", - req[options.requestPropertyName].remaining - ); - if (resetTime instanceof Date) { - // if we have a resetTime, also provide the current date to help avoid issues with incorrect clocks - res.setHeader("Date", new Date().toUTCString()); - res.setHeader( - "X-RateLimit-Reset", - Math.ceil(resetTime.getTime() / 1000) - ); - } - } - if (options.draft_polli_ratelimit_headers && !res.headersSent) { - res.setHeader("RateLimit-Limit", max); - res.setHeader( - "RateLimit-Remaining", - req[options.requestPropertyName].remaining - ); - if (resetTime) { - const deltaSeconds = Math.ceil( - (resetTime.getTime() - Date.now()) / 1000 - ); - res.setHeader("RateLimit-Reset", Math.max(0, deltaSeconds)); - } - } - - if ( - options.skipFailedRequests || - options.skipSuccessfulRequests - ) { - let decremented = false; - const decrementKey = () => { - if (!decremented) { - options.store.decrement(key); - decremented = true; - } - }; - - if (options.skipFailedRequests) { - res.on("finish", function () { - if (!options.requestWasSuccessful(req, res)) { - decrementKey(); - } - }); - - res.on("close", () => { - if (!res.finished) { - decrementKey(); - } - }); - - res.on("error", () => decrementKey()); - } - - if (options.skipSuccessfulRequests) { - res.on("finish", function () { - if (options.requestWasSuccessful(req, res)) { - options.store.decrement(key); - } - }); - } - } - - if (max && current === max + 1) { - options.onLimitReached(req, res, options); - } - - if (max && current > max) { - if (options.headers && !res.headersSent) { - res.setHeader( - "Retry-After", - Math.ceil(options.windowMs / 1000) - ); - } - return options.handler(req, res, next, options); - } - - next(); - - return null; - }) - .catch(next); - }); - - return null; - }) - .catch(next); - } - - rateLimit.resetKey = options.store.resetKey.bind(options.store); - - // Backward compatibility function - rateLimit.resetIp = rateLimit.resetKey; - - return rateLimit; -} - -module.exports = RateLimit; diff --git a/server/libs/expressRateLimit/memory-store.js b/server/libs/expressRateLimit/memory-store.js deleted file mode 100644 index 60938dbc..00000000 --- a/server/libs/expressRateLimit/memory-store.js +++ /dev/null @@ -1,47 +0,0 @@ -"use strict"; - -function calculateNextResetTime(windowMs) { - const d = new Date(); - d.setMilliseconds(d.getMilliseconds() + windowMs); - return d; -} - -function MemoryStore(windowMs) { - let hits = {}; - let resetTime = calculateNextResetTime(windowMs); - - this.incr = function (key, cb) { - if (hits[key]) { - hits[key]++; - } else { - hits[key] = 1; - } - - cb(null, hits[key], resetTime); - }; - - this.decrement = function (key) { - if (hits[key]) { - hits[key]--; - } - }; - - // export an API to allow hits all IPs to be reset - this.resetAll = function () { - hits = {}; - resetTime = calculateNextResetTime(windowMs); - }; - - // export an API to allow hits from one IP to be reset - this.resetKey = function (key) { - delete hits[key]; - }; - - // simply reset ALL hits every windowMs - const interval = setInterval(this.resetAll, windowMs); - if (interval.unref) { - interval.unref(); - } -} - -module.exports = MemoryStore;