mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Merge pull request #2553 from Sapd/sso
OpenID: Implement Logout + Fix state + Fix URL Regex
This commit is contained in:
commit
7bf7b6bcf9
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
268
server/Auth.js
268
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<function>} 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<string>} 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<boolean>}
|
||||
*/
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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.
|
@ -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;
|
@ -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;
|
Loading…
Reference in New Issue
Block a user