Add rate limiter for auth endpoints

This commit is contained in:
advplyr 2025-07-07 16:23:15 -05:00
parent 9c8900560c
commit ac381854e5
5 changed files with 90 additions and 6 deletions

16
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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)

View file

@ -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))

View file

@ -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()