diff --git a/package-lock.json b/package-lock.json index d44ea79b..1be14fa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", @@ -1893,6 +1894,21 @@ "node": ">= 0.10.0" } }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/express-session": { "version": "1.17.3", "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.17.3.tgz", diff --git a/package.json b/package.json index 2fd1a87e..3fdbf768 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "axios": "^0.27.2", "cookie-parser": "^1.4.6", "express": "^4.17.1", + "express-rate-limit": "^7.5.1", "express-session": "^1.17.3", "graceful-fs": "^4.2.10", "htmlparser2": "^8.0.1", diff --git a/server/Auth.js b/server/Auth.js index e62df0b8..601fe8f2 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,4 +1,5 @@ const { Request, Response, NextFunction } = require('express') +const { rateLimit } = require('express-rate-limit') const passport = require('passport') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt @@ -9,6 +10,7 @@ const TokenManager = require('./auth/TokenManager') const LocalAuthStrategy = require('./auth/LocalAuthStrategy') const OidcAuthStrategy = require('./auth/OidcAuthStrategy') +const RateLimiterFactory = require('./utils/rateLimiterFactory') const { escapeRegExp } = require('./utils') /** @@ -19,6 +21,9 @@ class Auth { const escapedRouterBasePath = escapeRegExp(global.RouterBasePath) this.ignorePatterns = [new RegExp(`^(${escapedRouterBasePath}/api)?/items/[^/]+/cover$`), new RegExp(`^(${escapedRouterBasePath}/api)?/authors/[^/]+/image$`)] + /** @type {import('express-rate-limit').RateLimitRequestHandler} */ + this.authRateLimiter = RateLimiterFactory.getAuthRateLimiter() + this.tokenManager = new TokenManager() this.localAuthStrategy = new LocalAuthStrategy() this.oidcAuthStrategy = new OidcAuthStrategy() @@ -305,7 +310,7 @@ class Auth { */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) - router.post('/login', passport.authenticate('local'), async (req, res) => { + router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { // Check if mobile app wants refresh token in response const returnTokens = req.query.return_tokens === 'true' || req.headers['x-return-tokens'] === 'true' @@ -314,7 +319,7 @@ class Auth { }) // Refresh token route - router.post('/auth/refresh', async (req, res) => { + router.post('/auth/refresh', this.authRateLimiter, async (req, res) => { let refreshToken = req.cookies.refresh_token // If x-refresh-token header is present, use it instead of the cookie @@ -345,7 +350,7 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', (req, res) => { + router.get('/auth/openid', this.authRateLimiter, (req, res) => { const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) if (authorizationUrlResponse.error) { @@ -359,11 +364,12 @@ class Auth { // This will be the oauth2 callback route for mobile clients // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) + router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) // openid strategy callback route (this receives the token from the configured openid login provider) router.get( '/auth/openid/callback', + this.authRateLimiter, (req, res, next) => { const sessionKey = this.oidcAuthStrategy.getStrategy()._key @@ -436,7 +442,7 @@ class Auth { * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => { + router.get('/auth/openid/config', this.authRateLimiter, this.isAuthenticated, async (req, res) => { if (!req.user.isAdminOrUp) { Logger.error(`[Auth] Non-admin user "${req.user.username}" attempted to get issuer config`) return res.sendStatus(403) diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 8966ff66..6446ecc8 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,7 +182,7 @@ class ApiRouter { this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this)) this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) - this.router.patch('/me/password', MeController.updatePassword.bind(this)) + this.router.patch('/me/password', this.auth.authRateLimiter, MeController.updatePassword.bind(this)) this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) diff --git a/server/utils/rateLimiterFactory.js b/server/utils/rateLimiterFactory.js new file mode 100644 index 00000000..6f04d5ac --- /dev/null +++ b/server/utils/rateLimiterFactory.js @@ -0,0 +1,61 @@ +const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit') +const Logger = require('../Logger') + +/** + * Factory for creating authentication rate limiters + */ +class RateLimiterFactory { + constructor() { + this.authRateLimiter = null + } + + /** + * Get the authentication rate limiter + * @returns {RateLimitRequestHandler} + */ + getAuthRateLimiter() { + if (this.authRateLimiter) { + return this.authRateLimiter + } + + let windowMs = 10 * 60 * 1000 // 10 minutes default + if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) { + windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) + } + + let max = 20 // 20 attempts default + if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) { + max = parseInt(process.env.RATE_LIMIT_AUTH_MAX) + } + + let message = 'Too many requests, please try again later.' + if (process.env.RATE_LIMIT_AUTH_MESSAGE) { + message = process.env.RATE_LIMIT_AUTH_MESSAGE + } + + this.authRateLimiter = rateLimit({ + windowMs, + max, + message, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + const userAgent = req.get('User-Agent') || 'Unknown' + const endpoint = req.path + const method = req.method + + Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${req.ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`) + + res.status(429).json({ + error: 'Too many authentication attempts, please try again later.' + }) + } + }) + + Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`) + + return this.authRateLimiter + } +} + +module.exports = new RateLimiterFactory()