diff --git a/client/pages/account.vue b/client/pages/account.vue index e9b5da3cb..b157f5704 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -182,19 +182,18 @@ export default { password: this.password, newPassword: this.newPassword }) - .then(() => { - this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) - this.resetForm() + .then((res) => { + if (res.success) { + this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess) + this.resetForm() + } else { + this.$toast.error(res.error || this.$strings.ToastUnknownError) + } + this.changingPassword = false }) .catch((error) => { - console.error('Failed to change password', error) - let errorMessage = this.$strings.ToastUnknownError - if (error.response?.data && typeof error.response.data === 'string') { - errorMessage = error.response.data - } - this.$toast.error(errorMessage) - }) - .finally(() => { + console.error(error) + this.$toast.error(this.$strings.ToastUnknownError) this.changingPassword = false }) }, diff --git a/package-lock.json b/package-lock.json index 1be14fa82..d44ea79bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "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", @@ -1894,21 +1893,6 @@ "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 3fdbf768b..2fd1a87e0 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,6 @@ "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 601fe8f24..a4c52781d 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,16 +1,17 @@ const { Request, Response, NextFunction } = require('express') -const { rateLimit } = require('express-rate-limit') +const axios = require('axios') const passport = require('passport') +const OpenIDClient = require('openid-client') const JwtStrategy = require('passport-jwt').Strategy const ExtractJwt = require('passport-jwt').ExtractJwt const Database = require('./Database') const Logger = require('./Logger') const TokenManager = require('./auth/TokenManager') -const LocalAuthStrategy = require('./auth/LocalAuthStrategy') -const OidcAuthStrategy = require('./auth/OidcAuthStrategy') -const RateLimiterFactory = require('./utils/rateLimiterFactory') +const bcrypt = require('./libs/bcryptjs') +const requestIp = require('./libs/requestIp') +const LocalStrategy = require('./libs/passportLocal') const { escapeRegExp } = require('./utils') /** @@ -18,15 +19,12 @@ const { escapeRegExp } = require('./utils') */ class Auth { constructor() { + // Map of openId sessions indexed by oauth2 state-variable + this.openIdAuthSession = new Map() 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() } /** @@ -119,12 +117,12 @@ class Auth { async initPassportJs() { // Check if we should load the local strategy (username + password login) if (global.ServerSettings.authActiveAuthMethods.includes('local')) { - this.localAuthStrategy.init() + this.initAuthStrategyPassword() } // Check if we should load the openid strategy if (global.ServerSettings.authActiveAuthMethods.includes('openid')) { - this.oidcAuthStrategy.init() + this.initAuthStrategyOpenID() } // Load the JwtStrategy (always) -> for bearer token auth @@ -167,21 +165,169 @@ class Auth { }.bind(this) ) } + + /** + * Passport use LocalStrategy + */ + initAuthStrategyPassword() { + passport.use(new LocalStrategy({ passReqToCallback: true }, this.localAuthCheckUserPw.bind(this))) + } + + /** + * Passport use OpenIDClient.Strategy + */ + initAuthStrategyOpenID() { + if (!Database.serverSettings.isOpenIDAuthSettingsValid) { + Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`) + return + } + + // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing + OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) + + const openIdIssuerClient = new OpenIDClient.Issuer({ + issuer: global.ServerSettings.authOpenIDIssuerURL, + authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, + token_endpoint: global.ServerSettings.authOpenIDTokenURL, + userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, + jwks_uri: global.ServerSettings.authOpenIDJwksURL, + end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL + }).Client + const openIdClient = new openIdIssuerClient({ + client_id: global.ServerSettings.authOpenIDClientID, + client_secret: global.ServerSettings.authOpenIDClientSecret, + id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm + }) + passport.use( + 'openid-client', + new OpenIDClient.Strategy( + { + client: openIdClient, + params: { + redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, + scope: 'openid profile email' + } + }, + async (tokenset, userinfo, done) => { + try { + Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + + if (!userinfo.sub) { + throw new Error('Invalid userinfo, no sub') + } + + if (!this.validateGroupClaim(userinfo)) { + throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + } + + let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) + + if (!user?.isActive) { + throw new Error('User not active or not found') + } + + await this.setUserGroup(user, userinfo) + await this.updateUserPermissions(user, userinfo) + + // 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 + + return done(null, user) + } catch (error) { + Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`) + + return done(null, null, 'Unauthorized') + } + } + ) + ) + } // #endregion + /** + * Validates the presence and content of the group claim in userinfo. + */ + validateGroupClaim(userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // Allow no group claim when configured like this + return true + + // If configured it must exist in userinfo + if (!userinfo[groupClaimName]) { + return false + } + return true + } + + /** + * Sets the user group based on group claim in userinfo. + * + * @param {import('./models/User')} user + * @param {Object} userinfo + */ + async setUserGroup(user, userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim + if (!groupClaimName) + // No group claim configured, don't set anything + return + + if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) + + const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) + if (userType) { + if (user.type === 'root') { + // Check OpenID Group + if (userType !== 'admin') { + throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) + } else { + // If root user is logging in via OpenID, we will not change the type + return + } + } + + if (user.type !== userType) { + Logger.info(`[Auth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) + user.type = userType + await user.save() + } + } else { + throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) + } + } + + /** + * Updates user permissions based on the advanced permissions claim. + * + * @param {import('./models/User')} user + * @param {Object} userinfo + */ + async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) + // No advanced permissions claim configured, don't set anything + return + + if (user.type === 'admin' || user.type === 'root') return + + const absPermissions = userinfo[absPermissionsClaim] + if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + + if (await user.updatePermissionsFromExternalJSON(absPermissions)) { + Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) + } + } + /** * Unuse strategy * * @param {string} name */ unuseAuthStrategy(name) { - if (name === 'openid') { - this.oidcAuthStrategy.unuse() - } else if (name === 'local') { - this.localAuthStrategy.unuse() - } else { - Logger.error('[Auth] Invalid auth strategy ' + name) - } + passport.unuse(name) } /** @@ -191,9 +337,9 @@ class Auth { */ useAuthStrategy(name) { if (name === 'openid') { - this.oidcAuthStrategy.init() + this.initAuthStrategyOpenID() } else if (name === 'local') { - this.localAuthStrategy.init() + this.initAuthStrategyPassword() } else { Logger.error('[Auth] Invalid auth strategy ' + name) } @@ -253,11 +399,10 @@ class Auth { */ async handleLoginSuccessBasedOnCookie(req, res) { // Handle token generation and get userResponse object - // For API based auth (e.g. mobile), we will return the refresh token in the response - const isApiBased = this.isAuthMethodAPIBased(req.cookies.auth_method) - const userResponse = await this.handleLoginSuccess(req, res, isApiBased) + // TODO: where to check if refresh tokens should be returned? + const userResponse = await this.handleLoginSuccess(req, res, false) - if (isApiBased) { + if (this.isAuthMethodAPIBased(req.cookies.auth_method)) { // REST request - send data res.json(userResponse) } else { @@ -310,7 +455,7 @@ class Auth { */ async initAuthRoutes(router) { // Local strategy login route (takes username and password) - router.post('/login', this.authRateLimiter, passport.authenticate('local'), async (req, res) => { + router.post('/login', 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' @@ -319,7 +464,7 @@ class Auth { }) // Refresh token route - router.post('/auth/refresh', this.authRateLimiter, async (req, res) => { + router.post('/auth/refresh', async (req, res) => { let refreshToken = req.cookies.refresh_token // If x-refresh-token header is present, use it instead of the cookie @@ -350,28 +495,153 @@ class Auth { }) // openid strategy login route (this redirects to the configured openid login provider) - router.get('/auth/openid', this.authRateLimiter, (req, res) => { - const authorizationUrlResponse = this.oidcAuthStrategy.getAuthorizationUrl(req) + 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 - if (authorizationUrlResponse.error) { - return res.status(authorizationUrlResponse.status).send(authorizationUrlResponse.error) + try { + 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') + } + + // Generate a state on web flow or if no state supplied + const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state + + // 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(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() + } else { + redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() + + 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') + } + } + oidcStrategy._params.redirect_uri = redirectUri + Logger.debug(`[Auth] OIDC redirect_uri=${redirectUri}`) + + let { code_challenge, code_challenge_method, code_verifier } = generatePkce(req, isMobileFlow) + + req.session[sessionKey] = { + ...req.session[sessionKey], + 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 + } + + var scope = 'openid profile email' + if (global.ServerSettings.authOpenIDGroupClaim) { + scope += ' ' + global.ServerSettings.authOpenIDGroupClaim + } + if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim + } + + const authorizationUrl = client.authorizationUrl({ + ...oidcStrategy._params, + state: state, + response_type: 'code', + scope: scope, + code_challenge, + code_challenge_method + }) + + this.paramsToCookies(req, res, isMobileFlow ? 'openid-mobile' : 'openid') + + res.redirect(authorizationUrl) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`) + res.status(500).send('Internal Server Error') } - this.paramsToCookies(req, res, authorizationUrlResponse.isMobileFlow ? 'openid-mobile' : 'openid') + 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 } + } + } - res.redirect(authorizationUrlResponse.authorizationUrl) + 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 // It will redirect to an app-link like audiobookshelf://oauth - router.get('/auth/openid/mobile-redirect', this.authRateLimiter, (req, res) => this.oidcAuthStrategy.handleMobileRedirect(req, res)) + router.get('/auth/openid/mobile-redirect', (req, res) => { + try { + // Extract the state parameter from the request + const { state, code } = req.query + + // Check if the state provided is in our list + if (!state || !this.openIdAuthSession.has(state)) { + Logger.error('[Auth] /auth/openid/mobile-redirect route: State parameter mismatch') + return res.status(400).send('State parameter mismatch') + } + + let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri + + if (!mobile_redirect_uri) { + Logger.error('[Auth] No redirect URI') + return res.status(400).send('No redirect URI') + } + + this.openIdAuthSession.delete(state) + + const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` + // Redirect to the overwrite URI saved in the map + res.redirect(redirectUri) + } catch (error) { + Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) + res.status(500).send('Internal Server Error') + } + }) // 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 + const oidcStrategy = passport._strategy('openid-client') + const sessionKey = oidcStrategy._key if (!req.session[sessionKey]) { return res.status(400).send('No session') @@ -442,22 +712,49 @@ class Auth { * * @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/ */ - router.get('/auth/openid/config', this.authRateLimiter, this.isAuthenticated, async (req, res) => { + router.get('/auth/openid/config', 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) } - if (!req.query.issuer || typeof req.query.issuer !== 'string') { + if (!req.query.issuer) { return res.status(400).send("Invalid request. Query param 'issuer' is required") } - const openIdIssuerConfig = await this.oidcAuthStrategy.getIssuerConfig(req.query.issuer) - if (openIdIssuerConfig.error) { - return res.status(openIdIssuerConfig.status).send(openIdIssuerConfig.error) + // Strip trailing slash + let issuerUrl = req.query.issuer + if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) + + // Append config pathname and validate URL + let configUrl = null + try { + configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) + if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { + throw new Error('Invalid pathname') + } + } catch (error) { + Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) + return res.status(400).send("Invalid request. Query param 'issuer' is invalid") } - res.json(openIdIssuerConfig) + axios + .get(configUrl.toString()) + .then(({ data }) => { + res.json({ + issuer: data.issuer, + authorization_endpoint: data.authorization_endpoint, + token_endpoint: data.token_endpoint, + userinfo_endpoint: data.userinfo_endpoint, + end_session_endpoint: data.end_session_endpoint, + jwks_uri: data.jwks_uri, + id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported + }) + }) + .catch((error) => { + Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) + res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) + }) }) // Logout route @@ -484,6 +781,7 @@ class Auth { Logger.info(`[Auth] logout: No refresh token on request`) } + // TODO: invalidate possible JWTs req.logout((err) => { if (err) { res.sendStatus(500) @@ -495,7 +793,36 @@ class Auth { let logoutUrl = null if (authMethod === 'openid' || authMethod === 'openid-mobile') { - logoutUrl = this.oidcAuthStrategy.getEndSessionUrl(req, req.cookies.openid_id_token, authMethod) + // 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 + + if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { + 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}${global.RouterBasePath}/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) + + logoutUrl = client.endSessionUrl({ + id_token_hint: req.cookies.openid_id_token, + post_logout_redirect_uri: postLogoutRedirectUri + }) + } + res.clearCookie('openid_id_token') } @@ -507,6 +834,153 @@ class Auth { }) } // #endregion + + // #region Local Auth + /** + * Checks if a username and password tuple is valid and the user active. + * @param {Request} req + * @param {string} username + * @param {string} password + * @param {Promise} done + */ + async localAuthCheckUserPw(req, username, password, done) { + // Load the user given it's username + const user = await Database.userModel.getUserByUsername(username.toLowerCase()) + + if (!user?.isActive) { + if (user) { + this.logFailedLocalAuthLoginAttempt(req, user.username, 'User is not active') + } else { + this.logFailedLocalAuthLoginAttempt(req, username, 'User not found') + } + done(null, null) + return + } + + // Check passwordless root user + if (user.type === 'root' && !user.pash) { + if (password) { + // deny login + this.logFailedLocalAuthLoginAttempt(req, user.username, 'Root user has no password set') + done(null, null) + return + } + // approve login + Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } else if (!user.pash) { + this.logFailedLocalAuthLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') + done(null, null) + return + } + + // Check password match + const compare = await bcrypt.compare(password, user.pash) + if (compare) { + // approve login + Logger.info(`[Auth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) + + done(null, user) + return + } + // deny login + this.logFailedLocalAuthLoginAttempt(req, user.username, 'Invalid password') + done(null, null) + return + } + + /** + * + * @param {Request} req + * @param {string} username + * @param {string} message + */ + logFailedLocalAuthLoginAttempt(req, username, message) { + if (!req || !username || !message) return + Logger.error(`[Auth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) + } + + /** + * Hashes a password with bcrypt. + * @param {string} password + * @returns {Promise} hash + */ + hashPass(password) { + return new Promise((resolve) => { + bcrypt.hash(password, 8, (err, hash) => { + if (err) { + resolve(null) + } else { + resolve(hash) + } + }) + }) + } + + /** + * + * @param {string} password + * @param {import('./models/User')} user + * @returns {Promise} + */ + comparePassword(password, user) { + if (user.type === 'root' && !password && !user.pash) return true + if (!password || !user.pash) return false + return bcrypt.compare(password, user.pash) + } + + /** + * User changes their password from request + * TODO: Update responses to use error status codes + * + * @param {import('./controllers/MeController').RequestWithUser} req + * @param {Response} res + */ + async userChangePassword(req, res) { + let { password, newPassword } = req.body + newPassword = newPassword || '' + const matchingUser = req.user + + // Only root can have an empty password + if (matchingUser.type !== 'root' && !newPassword) { + return res.json({ + error: 'Invalid new password - Only root can have an empty password' + }) + } + + // Check password match + const compare = await this.comparePassword(password, matchingUser) + if (!compare) { + return res.json({ + error: 'Invalid password' + }) + } + + let pw = '' + if (newPassword) { + pw = await this.hashPass(newPassword) + if (!pw) { + return res.json({ + error: 'Hash failed' + }) + } + } + try { + await matchingUser.update({ pash: pw }) + Logger.info(`[Auth] User "${matchingUser.username}" changed password`) + res.json({ + success: true + }) + } catch (error) { + Logger.error(`[Auth] User "${matchingUser.username}" failed to change password`, error) + res.json({ + error: 'Unknown error' + }) + } + } + // #endregion } module.exports = Auth diff --git a/server/auth/LocalAuthStrategy.js b/server/auth/LocalAuthStrategy.js deleted file mode 100644 index e9499d13d..000000000 --- a/server/auth/LocalAuthStrategy.js +++ /dev/null @@ -1,186 +0,0 @@ -const passport = require('passport') -const LocalStrategy = require('../libs/passportLocal') -const Database = require('../Database') -const Logger = require('../Logger') - -const bcrypt = require('../libs/bcryptjs') -const requestIp = require('../libs/requestIp') - -/** - * Local authentication strategy using username/password - */ -class LocalAuthStrategy { - constructor() { - this.name = 'local' - this.strategy = null - } - - /** - * Get the passport strategy instance - * @returns {LocalStrategy} - */ - getStrategy() { - if (!this.strategy) { - this.strategy = new LocalStrategy({ passReqToCallback: true }, this.verifyCredentials.bind(this)) - } - return this.strategy - } - - /** - * Initialize the strategy with passport - */ - init() { - passport.use(this.name, this.getStrategy()) - } - - /** - * Remove the strategy from passport - */ - unuse() { - passport.unuse(this.name) - this.strategy = null - } - - /** - * Verify user credentials - * @param {import('express').Request} req - * @param {string} username - * @param {string} password - * @param {Function} done - Passport callback - */ - async verifyCredentials(req, username, password, done) { - // Load the user given it's username - const user = await Database.userModel.getUserByUsername(username.toLowerCase()) - - if (!user?.isActive) { - if (user) { - this.logFailedLoginAttempt(req, user.username, 'User is not active') - } else { - this.logFailedLoginAttempt(req, username, 'User not found') - } - done(null, null) - return - } - - // Check passwordless root user - if (user.type === 'root' && !user.pash) { - if (password) { - // deny login - this.logFailedLoginAttempt(req, user.username, 'Root user has no password set') - done(null, null) - return - } - // approve login - Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } else if (!user.pash) { - this.logFailedLoginAttempt(req, user.username, 'User has no password set. Might have been created with OpenID') - done(null, null) - return - } - - // Check password match - const compare = await bcrypt.compare(password, user.pash) - if (compare) { - // approve login - Logger.info(`[LocalAuth] User "${user.username}" logged in from ip ${requestIp.getClientIp(req)}`) - - done(null, user) - return - } - - // deny login - this.logFailedLoginAttempt(req, user.username, 'Invalid password') - done(null, null) - } - - /** - * Log failed login attempts - * @param {import('express').Request} req - * @param {string} username - * @param {string} message - */ - logFailedLoginAttempt(req, username, message) { - if (!req || !username || !message) return - Logger.error(`[LocalAuth] Failed login attempt for username "${username}" from ip ${requestIp.getClientIp(req)} (${message})`) - } - - /** - * Hash a password with bcrypt - * @param {string} password - * @returns {Promise} hash - */ - hashPassword(password) { - return new Promise((resolve) => { - bcrypt.hash(password, 8, (err, hash) => { - if (err) { - resolve(null) - } else { - resolve(hash) - } - }) - }) - } - - /** - * Compare password with user's hashed password - * @param {string} password - * @param {import('../models/User')} user - * @returns {Promise} - */ - comparePassword(password, user) { - if (user.type === 'root' && !password && !user.pash) return true - if (!password || !user.pash) return false - return bcrypt.compare(password, user.pash) - } - - /** - * Change user password - * @param {import('../models/User')} user - * @param {string} password - * @param {string} newPassword - */ - async changePassword(user, password, newPassword) { - // Only root can have an empty password - if (user.type !== 'root' && !newPassword) { - return { - error: 'Invalid new password - Only root can have an empty password' - } - } - - // Check password match - const compare = await this.comparePassword(password, user) - if (!compare) { - return { - error: 'Invalid password' - } - } - - let pw = '' - if (newPassword) { - pw = await this.hashPassword(newPassword) - if (!pw) { - return { - error: 'Hash failed' - } - } - } - - try { - await user.update({ pash: pw }) - Logger.info(`[LocalAuth] User "${user.username}" changed password`) - return { - success: true - } - } catch (error) { - Logger.error(`[LocalAuth] User "${user.username}" failed to change password`, error) - return { - error: 'Unknown error' - } - } - } -} - -module.exports = LocalAuthStrategy diff --git a/server/auth/OidcAuthStrategy.js b/server/auth/OidcAuthStrategy.js deleted file mode 100644 index c3f6cfb22..000000000 --- a/server/auth/OidcAuthStrategy.js +++ /dev/null @@ -1,488 +0,0 @@ -const { Request, Response } = require('express') -const passport = require('passport') -const OpenIDClient = require('openid-client') -const axios = require('axios') -const Database = require('../Database') -const Logger = require('../Logger') - -/** - * OpenID Connect authentication strategy - */ -class OidcAuthStrategy { - constructor() { - this.name = 'openid-client' - this.strategy = null - this.client = null - // Map of openId sessions indexed by oauth2 state-variable - this.openIdAuthSession = new Map() - } - - /** - * Get the passport strategy instance - * @returns {OpenIDClient.Strategy} - */ - getStrategy() { - if (!this.strategy) { - this.strategy = new OpenIDClient.Strategy( - { - client: this.getClient(), - params: { - redirect_uri: `${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, - scope: this.getScope() - } - }, - this.verifyCallback.bind(this) - ) - } - return this.strategy - } - - /** - * Get the OpenID Connect client - * @returns {OpenIDClient.Client} - */ - getClient() { - if (!this.client) { - if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - throw new Error('OpenID Connect settings are not valid') - } - - // Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing - OpenIDClient.custom.setHttpOptionsDefaults({ timeout: 10000 }) - - const openIdIssuerClient = new OpenIDClient.Issuer({ - issuer: global.ServerSettings.authOpenIDIssuerURL, - authorization_endpoint: global.ServerSettings.authOpenIDAuthorizationURL, - token_endpoint: global.ServerSettings.authOpenIDTokenURL, - userinfo_endpoint: global.ServerSettings.authOpenIDUserInfoURL, - jwks_uri: global.ServerSettings.authOpenIDJwksURL, - end_session_endpoint: global.ServerSettings.authOpenIDLogoutURL - }).Client - - this.client = new openIdIssuerClient({ - client_id: global.ServerSettings.authOpenIDClientID, - client_secret: global.ServerSettings.authOpenIDClientSecret, - id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm - }) - } - return this.client - } - - /** - * Get the scope string for the OpenID Connect request - * @returns {string} - */ - getScope() { - let scope = 'openid profile email' - if (global.ServerSettings.authOpenIDGroupClaim) { - scope += ' ' + global.ServerSettings.authOpenIDGroupClaim - } - if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { - scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim - } - return scope - } - - /** - * Initialize the strategy with passport - */ - init() { - if (!Database.serverSettings.isOpenIDAuthSettingsValid) { - Logger.error(`[OidcAuth] Cannot init openid auth strategy - invalid settings`) - return - } - passport.use(this.name, this.getStrategy()) - } - - /** - * Remove the strategy from passport - */ - unuse() { - passport.unuse(this.name) - this.strategy = null - this.client = null - } - - /** - * Verify callback for OpenID Connect authentication - * @param {Object} tokenset - * @param {Object} userinfo - * @param {Function} done - Passport callback - */ - async verifyCallback(tokenset, userinfo, done) { - try { - Logger.debug(`[OidcAuth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) - - if (!userinfo.sub) { - throw new Error('Invalid userinfo, no sub') - } - - if (!this.validateGroupClaim(userinfo)) { - throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) - } - - let user = await Database.userModel.findOrCreateUserFromOpenIdUserInfo(userinfo, this) - - if (!user?.isActive) { - throw new Error('User not active or not found') - } - - await this.setUserGroup(user, userinfo) - await this.updateUserPermissions(user, userinfo) - - // 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 - - return done(null, user) - } catch (error) { - Logger.error(`[OidcAuth] openid callback error: ${error?.message}\n${error?.stack}`) - return done(null, null, 'Unauthorized') - } - } - - /** - * Validates the presence and content of the group claim in userinfo. - * @param {Object} userinfo - * @returns {boolean} - */ - validateGroupClaim(userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // Allow no group claim when configured like this - return true - - // If configured it must exist in userinfo - if (!userinfo[groupClaimName]) { - return false - } - return true - } - - /** - * Sets the user group based on group claim in userinfo. - * @param {import('../models/User')} user - * @param {Object} userinfo - */ - async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim - if (!groupClaimName) - // No group claim configured, don't set anything - return - - if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`) - - const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase()) - const rolesInOrderOfPriority = ['admin', 'user', 'guest'] - - let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role)) - if (userType) { - if (user.type === 'root') { - // Check OpenID Group - if (userType !== 'admin') { - throw new Error(`Root user "${user.username}" cannot be downgraded to ${userType}. Denying login.`) - } else { - // If root user is logging in via OpenID, we will not change the type - return - } - } - - if (user.type !== userType) { - Logger.info(`[OidcAuth] openid callback: Updating user "${user.username}" type to "${userType}" from "${user.type}"`) - user.type = userType - await user.save() - } - } else { - throw new Error(`No valid group found in userinfo: ${JSON.stringify(userinfo[groupClaimName], null, 2)}`) - } - } - - /** - * Updates user permissions based on the advanced permissions claim. - * @param {import('../models/User')} user - * @param {Object} userinfo - */ - async updateUserPermissions(user, userinfo) { - const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim - if (!absPermissionsClaim) - // No advanced permissions claim configured, don't set anything - return - - if (user.type === 'admin' || user.type === 'root') return - - const absPermissions = userinfo[absPermissionsClaim] - if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) - - if (await user.updatePermissionsFromExternalJSON(absPermissions)) { - Logger.info(`[OidcAuth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) - } - } - - /** - * Generate PKCE parameters for the authorization request - * @param {Request} req - * @param {boolean} isMobileFlow - * @returns {Object|{error: string}} - */ - generatePkce(req, isMobileFlow) { - if (isMobileFlow) { - if (!req.query.code_challenge) { - return { - error: 'code_challenge required for mobile flow (PKCE)' - } - } - if (req.query.code_challenge_method && req.query.code_challenge_method !== 'S256') { - return { - 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 } - } - } - - /** - * Check if a redirect URI is valid - * @param {string} uri - * @returns {boolean} - */ - 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] === '*') - } - - /** - * Get the authorization URL for OpenID Connect - * Calls client manually because the strategy does not support forwarding the code challenge for the mobile flow - * @param {Request} req - * @returns {{ authorizationUrl: string }|{status: number, error: string}} - */ - getAuthorizationUrl(req) { - const client = this.getClient() - const strategy = this.getStrategy() - const sessionKey = strategy._key - - try { - 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(`[OidcAuth] OIDC Invalid response_type=${req.query.response_type}`) - return { - status: 400, - error: 'Invalid response_type, only code supported' - } - } - - // Generate a state on web flow or if no state supplied - const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state - - // 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 || !this.isValidRedirectUri(req.query.redirect_uri)) { - Logger.debug(`[OidcAuth] Invalid redirect_uri=${req.query.redirect_uri}`) - return { - status: 400, - error: '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(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/mobile-redirect`, hostUrl).toString() - } else { - redirectUri = new URL(`${global.ServerSettings.authOpenIDSubfolderForRedirectURLs}/auth/openid/callback`, hostUrl).toString() - - if (req.query.state) { - Logger.debug(`[OidcAuth] Invalid state - not allowed on web openid flow`) - return { - status: 400, - error: 'Invalid state, not allowed on web flow' - } - } - } - - // Update the strategy's redirect_uri for this request - strategy._params.redirect_uri = redirectUri - Logger.debug(`[OidcAuth] OIDC redirect_uri=${redirectUri}`) - - const pkceData = this.generatePkce(req, isMobileFlow) - if (pkceData.error) { - return { - status: 400, - error: pkceData.error - } - } - - req.session[sessionKey] = { - ...req.session[sessionKey], - state: state, - max_age: strategy._params.max_age, - response_type: 'code', - code_verifier: pkceData.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: redirectUri // Save the redirect_uri (for the SSO Provider) for the callback - } - - const authorizationUrl = client.authorizationUrl({ - ...strategy._params, - redirect_uri: redirectUri, - state: state, - response_type: 'code', - scope: this.getScope(), - code_challenge: pkceData.code_challenge, - code_challenge_method: pkceData.code_challenge_method - }) - - return { - authorizationUrl, - isMobileFlow - } - } catch (error) { - Logger.error(`[OidcAuth] Error generating authorization URL: ${error}\n${error?.stack}`) - return { - status: 500, - error: error.message || 'Unknown error' - } - } - } - - /** - * Get the end session URL for logout - * @param {Request} req - * @param {string} idToken - * @param {string} authMethod - * @returns {string|null} - */ - getEndSessionUrl(req, idToken, authMethod) { - const client = this.getClient() - - if (client.issuer.end_session_endpoint && client.issuer.end_session_endpoint.length > 0) { - 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}${global.RouterBasePath}/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) - - return client.endSessionUrl({ - id_token_hint: idToken, - post_logout_redirect_uri: postLogoutRedirectUri - }) - } - - return null - } - - /** - * @typedef {Object} OpenIdIssuerConfig - * @property {string} issuer - * @property {string} authorization_endpoint - * @property {string} token_endpoint - * @property {string} userinfo_endpoint - * @property {string} end_session_endpoint - * @property {string} jwks_uri - * @property {string} id_token_signing_alg_values_supported - * - * Get OpenID Connect configuration from an issuer URL - * @param {string} issuerUrl - * @returns {Promise} - */ - async getIssuerConfig(issuerUrl) { - // Strip trailing slash - if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1) - - // Append config pathname and validate URL - let configUrl = null - try { - configUrl = new URL(`${issuerUrl}/.well-known/openid-configuration`) - if (!configUrl.pathname.endsWith('/.well-known/openid-configuration')) { - throw new Error('Invalid pathname') - } - } catch (error) { - Logger.error(`[OidcAuth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) - return { - status: 400, - error: "Invalid request. Query param 'issuer' is invalid" - } - } - - try { - const { data } = await axios.get(configUrl.toString()) - return { - issuer: data.issuer, - authorization_endpoint: data.authorization_endpoint, - token_endpoint: data.token_endpoint, - userinfo_endpoint: data.userinfo_endpoint, - end_session_endpoint: data.end_session_endpoint, - jwks_uri: data.jwks_uri, - id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported - } - } catch (error) { - Logger.error(`[OidcAuth] Failed to get openid configuration at "${configUrl}"`, error) - return { - status: 400, - error: 'Failed to get openid configuration' - } - } - } - - /** - * Handle mobile redirect for OAuth2 callback - * @param {Request} req - * @param {Response} res - */ - handleMobileRedirect(req, res) { - try { - // Extract the state parameter from the request - const { state, code } = req.query - - // Check if the state provided is in our list - if (!state || !this.openIdAuthSession.has(state)) { - Logger.error('[OidcAuth] /auth/openid/mobile-redirect route: State parameter mismatch') - return res.status(400).send('State parameter mismatch') - } - - let mobile_redirect_uri = this.openIdAuthSession.get(state).mobile_redirect_uri - - if (!mobile_redirect_uri) { - Logger.error('[OidcAuth] No redirect URI') - return res.status(400).send('No redirect URI') - } - - this.openIdAuthSession.delete(state) - - const redirectUri = `${mobile_redirect_uri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}` - // Redirect to the overwrite URI saved in the map - res.redirect(redirectUri) - } catch (error) { - Logger.error(`[OidcAuth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) - res.status(500).send('Internal Server Error') - } - } -} - -module.exports = OidcAuthStrategy diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 9451a7654..87acd2211 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -273,24 +273,12 @@ class MeController { * @param {RequestWithUser} req * @param {Response} res */ - async updatePassword(req, res) { + updatePassword(req, res) { if (req.user.isGuest) { Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`) - return res.sendStatus(403) + return res.sendStatus(500) } - - const { password, newPassword } = req.body - if (!password || !newPassword || typeof password !== 'string' || typeof newPassword !== 'string') { - return res.status(400).send('Missing or invalid password or new password') - } - - const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword) - - if (result.error) { - return res.status(400).send(result.error) - } - - res.sendStatus(200) + this.auth.userChangePassword(req, res) } /** diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 6446ecc80..8966ff66e 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', this.auth.authRateLimiter, MeController.updatePassword.bind(this)) + this.router.patch('/me/password', 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 deleted file mode 100644 index 6f04d5ace..000000000 --- a/server/utils/rateLimiterFactory.js +++ /dev/null @@ -1,61 +0,0 @@ -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() diff --git a/test/server/controllers/LibraryItemController.test.js b/test/server/controllers/LibraryItemController.test.js index 5a0422392..9972bd90f 100644 --- a/test/server/controllers/LibraryItemController.test.js +++ b/test/server/controllers/LibraryItemController.test.js @@ -6,7 +6,6 @@ const Database = require('../../../server/Database') const ApiRouter = require('../../../server/routers/ApiRouter') const LibraryItemController = require('../../../server/controllers/LibraryItemController') const ApiCacheManager = require('../../../server/managers/ApiCacheManager') -const Auth = require('../../../server/Auth') const Logger = require('../../../server/Logger') describe('LibraryItemController', () => { @@ -20,7 +19,6 @@ describe('LibraryItemController', () => { await Database.buildModels() apiRouter = new ApiRouter({ - auth: new Auth(), apiCacheManager: new ApiCacheManager() })