Merge branch 'advplyr:master' into audible-confidence-score

This commit is contained in:
mikiher 2025-07-13 10:13:00 +03:00 committed by GitHub
commit e9a705587a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 3288 additions and 931 deletions

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,16 @@ class Database {
return this.models.user
}
/** @type {typeof import('./models/Session')} */
get sessionModel() {
return this.models.session
}
/** @type {typeof import('./models/ApiKey')} */
get apiKeyModel() {
return this.models.apiKey
}
/** @type {typeof import('./models/Library')} */
get libraryModel() {
return this.models.library
@ -311,6 +321,8 @@ class Database {
buildModels(force = false) {
require('./models/User').init(this.sequelize)
require('./models/Session').init(this.sequelize)
require('./models/ApiKey').init(this.sequelize)
require('./models/Library').init(this.sequelize)
require('./models/LibraryFolder').init(this.sequelize)
require('./models/Book').init(this.sequelize)
@ -656,6 +668,9 @@ class Database {
* Series should have atleast one Book
* Book and Podcast must have an associated LibraryItem (and vice versa)
* Remove playback sessions that are 3 seconds or less
* Remove duplicate mediaProgresses
* Remove expired auth sessions
* Deactivate expired api keys
*/
async cleanDatabase() {
// Remove invalid Podcast records
@ -785,6 +800,40 @@ WHERE EXISTS (
where: { id: duplicateMediaProgress.id }
})
}
// Remove expired Session records
await this.cleanupExpiredSessions()
// Deactivate expired api keys
await this.deactivateExpiredApiKeys()
}
/**
* Deactivate expired api keys
*/
async deactivateExpiredApiKeys() {
try {
const affectedCount = await this.apiKeyModel.deactivateExpiredApiKeys()
if (affectedCount > 0) {
Logger.info(`[Database] Deactivated ${affectedCount} expired api keys`)
}
} catch (error) {
Logger.error(`[Database] Error deactivating expired api keys: ${error.message}`)
}
}
/**
* Clean up expired sessions from the database
*/
async cleanupExpiredSessions() {
try {
const deletedCount = await this.sessionModel.cleanupExpiredSessions()
if (deletedCount > 0) {
Logger.info(`[Database] Cleaned up ${deletedCount} expired sessions`)
}
} catch (error) {
Logger.error(`[Database] Error cleaning up expired sessions: ${error.message}`)
}
}
async createTextSearchQuery(query) {

View file

@ -156,14 +156,11 @@ class Server {
}
await Database.init(false)
// Create or set JWT secret in token manager
await this.auth.tokenManager.initTokenSecret()
await Logger.logManager.init()
// Create token secret if does not exist (Added v2.1.0)
if (!Database.serverSettings.tokenSecret) {
await this.auth.initTokenSecret()
}
await this.cleanUserData() // Remove invalid user item progress
await CacheManager.ensureCachePaths()
@ -264,7 +261,7 @@ class Server {
// enable express-session
app.use(
expressSession({
secret: global.ServerSettings.tokenSecret,
secret: this.auth.tokenManager.TokenSecret,
resave: false,
saveUninitialized: false,
cookie: {
@ -309,7 +306,9 @@ class Server {
})
)
router.use(express.urlencoded({ extended: true, limit: '5mb' }))
router.use(express.json({ limit: '10mb' }))
// Skip JSON parsing for internal-api routes
router.use(/^(?!\/internal-api).*/, express.json({ limit: '10mb' }))
router.use('/api', this.auth.ifAuthNeeded(this.authMiddleware.bind(this)), this.apiRouter.router)
router.use('/hls', this.hlsRouter.router)
@ -404,6 +403,7 @@ class Server {
const handle = nextApp.getRequestHandler()
await nextApp.prepare()
router.get('*', (req, res) => handle(req, res))
router.post('/internal-api/*', (req, res) => handle(req, res))
}
const unixSocketPrefix = 'unix/'
@ -428,7 +428,7 @@ class Server {
Logger.info(`[Server] Initializing new server`)
const newRoot = req.body.newRoot
const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.hashPass(newRoot.password) : ''
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
await Database.createRootUser(rootUsername, rootPash, this.auth)

View file

@ -1,7 +1,7 @@
const SocketIO = require('socket.io')
const Logger = require('./Logger')
const Database = require('./Database')
const Auth = require('./Auth')
const TokenManager = require('./auth/TokenManager')
/**
* @typedef SocketClient
@ -231,18 +231,22 @@ class SocketAuthority {
* When setting up a socket connection the user needs to be associated with a socket id
* for this the client will send a 'auth' event that includes the users API token
*
* Sends event 'init' to the socket. For admins this contains an array of users online.
* For failed authentication it sends event 'auth_failed' with a message
*
* @param {SocketIO.Socket} socket
* @param {string} token JWT
*/
async authenticateSocket(socket, token) {
// we don't use passport to authenticate the jwt we get over the socket connection.
// it's easier to directly verify/decode it.
const token_data = Auth.validateAccessToken(token)
// TODO: Support API keys for web socket connections
const token_data = TokenManager.validateAccessToken(token)
if (!token_data?.userId) {
// Token invalid
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
return socket.emit('auth_failed', { message: 'Invalid token' })
}
// get the user via the id from the decoded jwt.
@ -250,7 +254,11 @@ class SocketAuthority {
if (!user) {
// user not found
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
return socket.emit('auth_failed', { message: 'Invalid token' })
}
if (!user.isActive) {
Logger.error('Cannot validate socket - user is not active')
return socket.emit('auth_failed', { message: 'Invalid user' })
}
const client = this.clients[socket.id]
@ -260,13 +268,18 @@ class SocketAuthority {
}
if (client.user !== undefined) {
Logger.debug(`[SocketAuthority] Authenticating socket client already has user`, client.user.username)
if (client.user.id === user.id) {
// Allow re-authentication of a socket to the same user
Logger.info(`[SocketAuthority] Authenticating socket already associated to user "${client.user.username}"`)
} else {
// Allow re-authentication of a socket to a different user but shouldn't happen
Logger.warn(`[SocketAuthority] Authenticating socket to user "${user.username}", but is already associated with a different user "${client.user.username}"`)
}
} else {
Logger.debug(`[SocketAuthority] Authenticating socket to user "${user.username}"`)
}
client.user = user
Logger.debug(`[SocketAuthority] User Online ${client.user.username}`)
this.adminEmitter('user_online', client.user.toJSONForPublic(this.Server.playbackSessionManager.sessions))
// Update user lastSeen without firing sequelize bulk update hooks

View file

@ -0,0 +1,186 @@
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<string>} 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<boolean>}
*/
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

View file

@ -0,0 +1,488 @@
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<OpenIdIssuerConfig|{status: number, error: string}>}
*/
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

406
server/auth/TokenManager.js Normal file
View file

@ -0,0 +1,406 @@
const { Op } = require('sequelize')
const Database = require('../Database')
const Logger = require('../Logger')
const requestIp = require('../libs/requestIp')
const jwt = require('../libs/jsonwebtoken')
class TokenManager {
/** @type {string} JWT secret key */
static TokenSecret = null
constructor() {
/** @type {number} Refresh token expiry in seconds */
this.RefreshTokenExpiry = parseInt(process.env.REFRESH_TOKEN_EXPIRY) || 7 * 24 * 60 * 60 // 7 days
/** @type {number} Access token expiry in seconds */
this.AccessTokenExpiry = parseInt(process.env.ACCESS_TOKEN_EXPIRY) || 12 * 60 * 60 // 12 hours
if (parseInt(process.env.REFRESH_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Refresh token expiry set from ENV variable to ${this.RefreshTokenExpiry} seconds`)
}
if (parseInt(process.env.ACCESS_TOKEN_EXPIRY) > 0) {
Logger.info(`[TokenManager] Access token expiry set from ENV variable to ${this.AccessTokenExpiry} seconds`)
}
}
get TokenSecret() {
return TokenManager.TokenSecret
}
/**
* Token secret is used to sign and verify JWTs
* Set by ENV variable "JWT_SECRET_KEY" or generated and stored on server settings if not set
*/
async initTokenSecret() {
if (process.env.JWT_SECRET_KEY) {
// Use user supplied token secret
Logger.info('[TokenManager] JWT secret key set from ENV variable')
TokenManager.TokenSecret = process.env.JWT_SECRET_KEY
} else if (!Database.serverSettings.tokenSecret) {
// Generate new token secret and store it on server settings
Logger.info('[TokenManager] JWT secret key not found, generating one')
TokenManager.TokenSecret = require('crypto').randomBytes(256).toString('base64')
Database.serverSettings.tokenSecret = TokenManager.TokenSecret
await Database.updateServerSettings()
} else {
// Use existing token secret from server settings
TokenManager.TokenSecret = Database.serverSettings.tokenSecret
}
}
/**
* Sets the refresh token cookie
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {string} refreshToken
*/
setRefreshTokenCookie(req, res, refreshToken) {
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: req.secure || req.get('x-forwarded-proto') === 'https',
sameSite: 'lax',
maxAge: this.RefreshTokenExpiry * 1000,
path: '/'
})
}
/**
* Function to validate a jwt token for a given user
* Used to authenticate socket connections
* TODO: Support API keys for web socket connections
*
* @param {string} token
* @returns {Object} tokens data
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, TokenManager.TokenSecret)
} catch (err) {
return null
}
}
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateAccessToken(user) {
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
}
/**
* Generate access token for a given user
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateTempAccessToken(user) {
const payload = {
userId: user.id,
username: user.username,
type: 'access'
}
const options = {
expiresIn: this.AccessTokenExpiry
}
try {
return jwt.sign(payload, TokenManager.TokenSecret, options)
} catch (error) {
Logger.error(`[TokenManager] Error generating access token for user ${user.id}: ${error}`)
return null
}
}
/**
* Generate refresh token for a given user
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateRefreshToken(user) {
const payload = {
userId: user.id,
username: user.username,
type: 'refresh'
}
const options = {
expiresIn: this.RefreshTokenExpiry
}
try {
return jwt.sign(payload, TokenManager.TokenSecret, options)
} catch (error) {
Logger.error(`[TokenManager] Error generating refresh token for user ${user.id}: ${error}`)
return null
}
}
/**
* Create tokens and session for a given user
*
* @param {{ id:string, username:string }} user
* @param {import('express').Request} req
* @returns {Promise<{ accessToken:string, refreshToken:string, session:import('../models/Session') }>}
*/
async createTokensAndSession(user, req) {
const ipAddress = requestIp.getClientIp(req)
const userAgent = req.headers['user-agent']
const accessToken = this.generateTempAccessToken(user)
const refreshToken = this.generateRefreshToken(user)
// Calculate expiration time for the refresh token
const expiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
const session = await Database.sessionModel.createSession(user.id, ipAddress, userAgent, refreshToken, expiresAt)
return {
accessToken,
refreshToken,
session
}
}
/**
* Rotate tokens for a given session
*
* @param {import('../models/Session')} session
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<{ accessToken:string, refreshToken:string }>}
*/
async rotateTokensForSession(session, user, req, res) {
// Generate new tokens
const newAccessToken = this.generateTempAccessToken(user)
const newRefreshToken = this.generateRefreshToken(user)
// Calculate new expiration time
const newExpiresAt = new Date(Date.now() + this.RefreshTokenExpiry * 1000)
// Update the session with the new refresh token and expiration
session.refreshToken = newRefreshToken
session.expiresAt = newExpiresAt
await session.save()
// Set new refresh token cookie
this.setRefreshTokenCookie(req, res, newRefreshToken)
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken
}
}
/**
* Check if the jwt is valid
*
* @param {Object} jwt_payload
* @param {Function} done - passportjs callback
*/
async jwtAuthCheck(jwt_payload, done) {
if (jwt_payload.type === 'api') {
// Api key based authentication
const apiKey = await Database.apiKeyModel.getById(jwt_payload.keyId)
if (!apiKey?.isActive) {
done(null, null)
return
}
// Check if the api key is expired and deactivate it
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
done(null, null)
apiKey.isActive = false
await apiKey.save()
Logger.info(`[TokenManager] API key ${apiKey.id} is expired - deactivated`)
return
}
const user = await Database.userModel.getUserById(apiKey.userId)
done(null, user)
} else {
// JWT based authentication
// Check if the jwt is expired
if (jwt_payload.exp && jwt_payload.exp < Date.now() / 1000) {
done(null, null)
return
}
// load user by id from the jwt token
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
if (!user?.isActive) {
// deny login
done(null, null)
return
}
// TODO: Temporary flag to report old tokens to users
// May be a better place for this but here means we dont have to decode the token again
if (!jwt_payload.exp && !user.isOldToken) {
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
user.isOldToken = true
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
delete user.isOldToken
}
// approve login
done(null, user)
}
}
/**
* Handle refresh token
*
* @param {string} refreshToken
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<{ accessToken?:string, refreshToken?:string, user?:import('../models/User'), error?:string }>}
*/
async handleRefreshToken(refreshToken, req, res) {
try {
// Verify the refresh token
const decoded = jwt.verify(refreshToken, TokenManager.TokenSecret)
if (decoded.type !== 'refresh') {
Logger.error(`[TokenManager] Failed to refresh token. Invalid token type: ${decoded.type}`)
return {
error: 'Invalid token type'
}
}
const session = await Database.sessionModel.findOne({
where: { refreshToken: refreshToken }
})
if (!session) {
Logger.error(`[TokenManager] Failed to refresh token. Session not found for refresh token: ${refreshToken}`)
return {
error: 'Invalid refresh token'
}
}
// Check if session is expired in database
if (session.expiresAt < new Date()) {
Logger.info(`[TokenManager] Session expired in database, cleaning up`)
await session.destroy()
return {
error: 'Refresh token expired'
}
}
const user = await Database.userModel.getUserById(decoded.userId)
if (!user?.isActive) {
Logger.error(`[TokenManager] Failed to refresh token. User not found or inactive for user id: ${decoded.userId}`)
return {
error: 'User not found or inactive'
}
}
const newTokens = await this.rotateTokensForSession(session, user, req, res)
return {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
user
}
} catch (error) {
if (error.name === 'TokenExpiredError') {
Logger.info(`[TokenManager] Refresh token expired, cleaning up session`)
// Clean up the expired session from database
try {
await Database.sessionModel.destroy({
where: { refreshToken: refreshToken }
})
Logger.info(`[TokenManager] Expired session cleaned up`)
} catch (cleanupError) {
Logger.error(`[TokenManager] Error cleaning up expired session: ${cleanupError.message}`)
}
return {
error: 'Refresh token expired'
}
} else if (error.name === 'JsonWebTokenError') {
Logger.error(`[TokenManager] Invalid refresh token format: ${error.message}`)
return {
error: 'Invalid refresh token'
}
} else {
Logger.error(`[TokenManager] Refresh token error: ${error.message}`)
return {
error: 'Invalid refresh token'
}
}
}
}
/**
* Invalidate all JWT sessions for a given user
* If user is current user and refresh token is valid, rotate tokens for the current session
*
* @param {import('../models/User')} user
* @param {import('express').Request} req
* @param {import('express').Response} res
* @returns {Promise<string>} accessToken only if user is current user and refresh token is valid
*/
async invalidateJwtSessionsForUser(user, req, res) {
const currentRefreshToken = req.cookies.refresh_token
if (req.user.id === user.id && currentRefreshToken) {
// Current user is the same as the user to invalidate sessions for
// So rotate token for current session
const currentSession = await Database.sessionModel.findOne({ where: { refreshToken: currentRefreshToken } })
if (currentSession) {
const newTokens = await this.rotateTokensForSession(currentSession, user, req, res)
// Invalidate all sessions for the user except the current one
await Database.sessionModel.destroy({
where: {
id: {
[Op.ne]: currentSession.id
},
userId: user.id
}
})
return newTokens.accessToken
} else {
Logger.error(`[TokenManager] No session found to rotate tokens for refresh token ${currentRefreshToken}`)
}
}
// Current user is not the same as the user to invalidate sessions for (or no refresh token)
// So invalidate all sessions for the user
await Database.sessionModel.destroy({ where: { userId: user.id } })
return null
}
/**
* Invalidate a refresh token - used for logout
*
* @param {string} refreshToken
* @returns {Promise<boolean>}
*/
async invalidateRefreshToken(refreshToken) {
if (!refreshToken) {
Logger.error(`[TokenManager] No refresh token provided to invalidate`)
return false
}
try {
const numDeleted = await Database.sessionModel.destroy({ where: { refreshToken: refreshToken } })
Logger.info(`[TokenManager] Refresh token ${refreshToken} invalidated, ${numDeleted} sessions deleted`)
return true
} catch (error) {
Logger.error(`[TokenManager] Error invalidating refresh token: ${error.message}`)
return false
}
}
}
module.exports = TokenManager

View file

@ -0,0 +1,207 @@
const { Request, Response, NextFunction } = require('express')
const uuidv4 = require('uuid').v4
const Logger = require('../Logger')
const Database = require('../Database')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*/
class ApiKeyController {
constructor() {}
/**
* GET: /api/api-keys
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getAll(req, res) {
const apiKeys = await Database.apiKeyModel.findAll({
include: [
{
model: Database.userModel,
attributes: ['id', 'username', 'type']
},
{
model: Database.userModel,
as: 'createdByUser',
attributes: ['id', 'username', 'type']
}
]
})
return res.json({
apiKeys: apiKeys.map((a) => a.toJSON())
})
}
/**
* POST: /api/api-keys
*
* @this {import('../routers/ApiRouter')}
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async create(req, res) {
if (!req.body.name || typeof req.body.name !== 'string') {
Logger.warn(`[ApiKeyController] create: Invalid name: ${req.body.name}`)
return res.sendStatus(400)
}
if (req.body.expiresIn && (typeof req.body.expiresIn !== 'number' || req.body.expiresIn <= 0)) {
Logger.warn(`[ApiKeyController] create: Invalid expiresIn: ${req.body.expiresIn}`)
return res.sendStatus(400)
}
if (!req.body.userId || typeof req.body.userId !== 'string') {
Logger.warn(`[ApiKeyController] create: Invalid userId: ${req.body.userId}`)
return res.sendStatus(400)
}
const user = await Database.userModel.getUserById(req.body.userId)
if (!user) {
Logger.warn(`[ApiKeyController] create: User not found: ${req.body.userId}`)
return res.sendStatus(400)
}
if (user.type === 'root' && !req.user.isRoot) {
Logger.warn(`[ApiKeyController] create: Root user API key cannot be created by non-root user`)
return res.sendStatus(403)
}
const keyId = uuidv4() // Generate key id ahead of time to use in JWT
const apiKey = await Database.apiKeyModel.generateApiKey(this.auth.tokenManager.TokenSecret, keyId, req.body.name, req.body.expiresIn)
if (!apiKey) {
Logger.error(`[ApiKeyController] create: Error generating API key`)
return res.sendStatus(500)
}
// Calculate expiration time for the api key
const expiresAt = req.body.expiresIn ? new Date(Date.now() + req.body.expiresIn * 1000) : null
const apiKeyInstance = await Database.apiKeyModel.create({
id: keyId,
name: req.body.name,
expiresAt,
userId: req.body.userId,
isActive: !!req.body.isActive,
createdByUserId: req.user.id
})
apiKeyInstance.dataValues.user = await apiKeyInstance.getUser({
attributes: ['id', 'username', 'type']
})
Logger.info(`[ApiKeyController] Created API key "${apiKeyInstance.name}"`)
return res.json({
apiKey: {
apiKey, // Actual key only shown to user on creation
...apiKeyInstance.toJSON()
}
})
}
/**
* PATCH: /api/api-keys/:id
* Only isActive and userId can be updated because name and expiresIn are in the JWT
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async update(req, res) {
const apiKey = await Database.apiKeyModel.findByPk(req.params.id, {
include: {
model: Database.userModel
}
})
if (!apiKey) {
return res.sendStatus(404)
}
// Only root user can update root user API keys
if (apiKey.user.type === 'root' && !req.user.isRoot) {
Logger.warn(`[ApiKeyController] update: Root user API key cannot be updated by non-root user`)
return res.sendStatus(403)
}
let hasUpdates = false
if (req.body.userId !== undefined) {
if (typeof req.body.userId !== 'string') {
Logger.warn(`[ApiKeyController] update: Invalid userId: ${req.body.userId}`)
return res.sendStatus(400)
}
const user = await Database.userModel.getUserById(req.body.userId)
if (!user) {
Logger.warn(`[ApiKeyController] update: User not found: ${req.body.userId}`)
return res.sendStatus(400)
}
if (user.type === 'root' && !req.user.isRoot) {
Logger.warn(`[ApiKeyController] update: Root user API key cannot be created by non-root user`)
return res.sendStatus(403)
}
if (apiKey.userId !== req.body.userId) {
apiKey.userId = req.body.userId
hasUpdates = true
}
}
if (req.body.isActive !== undefined) {
if (typeof req.body.isActive !== 'boolean') {
return res.sendStatus(400)
}
if (apiKey.isActive !== req.body.isActive) {
apiKey.isActive = req.body.isActive
hasUpdates = true
}
}
if (hasUpdates) {
await apiKey.save()
apiKey.dataValues.user = await apiKey.getUser({
attributes: ['id', 'username', 'type']
})
Logger.info(`[ApiKeyController] Updated API key "${apiKey.name}"`)
} else {
Logger.info(`[ApiKeyController] No updates needed to API key "${apiKey.name}"`)
}
return res.json({
apiKey: apiKey.toJSON()
})
}
/**
* DELETE: /api/api-keys/:id
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async delete(req, res) {
const apiKey = await Database.apiKeyModel.findByPk(req.params.id)
if (!apiKey) {
return res.sendStatus(404)
}
await apiKey.destroy()
Logger.info(`[ApiKeyController] Deleted API key "${apiKey.name}"`)
return res.sendStatus(200)
}
/**
*
* @param {RequestWithUser} req
* @param {Response} res
* @param {NextFunction} next
*/
middleware(req, res, next) {
if (!req.user.isAdminOrUp) {
Logger.error(`[ApiKeyController] Non-admin user "${req.user.username}" attempting to access api keys`)
return res.sendStatus(403)
}
next()
}
}
module.exports = new ApiKeyController()

View file

@ -273,12 +273,24 @@ class MeController {
* @param {RequestWithUser} req
* @param {Response} res
*/
updatePassword(req, res) {
async updatePassword(req, res) {
if (req.user.isGuest) {
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
return res.sendStatus(500)
return res.sendStatus(403)
}
this.auth.userChangePassword(req, res)
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)
}
/**

View file

@ -127,8 +127,8 @@ class UserController {
}
const userId = uuidv4()
const pash = await this.auth.hashPass(req.body.password)
const token = await this.auth.generateAccessToken({ id: userId, username: req.body.username })
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
const userType = req.body.type || 'user'
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
@ -237,6 +237,7 @@ class UserController {
let hasUpdates = false
let shouldUpdateToken = false
let shouldInvalidateJwtSessions = false
// When changing username create a new API token
if (updatePayload.username && updatePayload.username !== user.username) {
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
@ -245,12 +246,13 @@ class UserController {
}
user.username = updatePayload.username
shouldUpdateToken = true
shouldInvalidateJwtSessions = true
hasUpdates = true
}
// Updating password
if (updatePayload.password) {
user.pash = await this.auth.hashPass(updatePayload.password)
user.pash = await this.auth.localAuthStrategy.hashPassword(updatePayload.password)
hasUpdates = true
}
@ -325,9 +327,24 @@ class UserController {
if (hasUpdates) {
if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken(user)
user.token = this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
}
// Handle JWT session invalidation for username changes
if (shouldInvalidateJwtSessions) {
const newAccessToken = await this.auth.invalidateJwtSessionsForUser(user, req, res)
if (newAccessToken) {
user.accessToken = newAccessToken
// Refresh tokens are only returned for mobile clients
// Mobile apps currently do not use this API endpoint so always set to null
user.refreshToken = null
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username} and rotated tokens for current session`)
} else {
Logger.info(`[UserController] Invalidated JWT sessions for user ${user.username}`)
}
}
await user.save()
SocketAuthority.clientEmitter(req.user.id, 'user_updated', user.toOldJSONForBrowser())
}

View file

@ -31,10 +31,12 @@ class CronManager {
}
/**
* Initialize open session cleanup cron
* Initialize open session & auth session cleanup cron
* Runs every day at 00:30
* Closes open share sessions that have not been updated in 24 hours
* Closes open playback sessions that have not been updated in 36 hours
* Cleans up expired auth sessions
* Deactivates expired api keys
* TODO: Clients should re-open the session if it is closed so that stale sessions can be closed sooner
*/
initOpenSessionCleanupCron() {
@ -42,6 +44,8 @@ class CronManager {
Logger.debug('[CronManager] Open session cleanup cron executing')
ShareManager.closeStaleOpenShareSessions()
await this.playbackSessionManager.closeStaleOpenSessions()
await Database.cleanupExpiredSessions()
await Database.deactivateExpiredApiKeys()
})
}

View file

@ -0,0 +1,163 @@
/**
* @typedef MigrationContext
* @property {import('sequelize').QueryInterface} queryInterface - a suquelize QueryInterface object.
* @property {import('../Logger')} logger - a Logger object.
*
* @typedef MigrationOptions
* @property {MigrationContext} context - an object containing the migration context.
*/
const migrationVersion = '2.26.0'
const migrationName = `${migrationVersion}-create-auth-tables`
const loggerPrefix = `[${migrationVersion} migration]`
/**
* This upward migration creates a sessions table and apiKeys table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function up({ context: { queryInterface, logger } }) {
// Upwards migration script
logger.info(`${loggerPrefix} UPGRADE BEGIN: ${migrationName}`)
// Check if table exists
if (await queryInterface.tableExists('sessions')) {
logger.info(`${loggerPrefix} table "sessions" already exists`)
} else {
// Create table
logger.info(`${loggerPrefix} creating table "sessions"`)
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('sessions', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
ipAddress: DataTypes.STRING,
userAgent: DataTypes.STRING,
refreshToken: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
},
createdAt: {
type: DataTypes.DATE,
allowNull: false
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
},
userId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users'
},
key: 'id'
},
allowNull: false,
onDelete: 'CASCADE'
}
})
logger.info(`${loggerPrefix} created table "sessions"`)
}
// Check if table exists
if (await queryInterface.tableExists('apiKeys')) {
logger.info(`${loggerPrefix} table "apiKeys" already exists`)
} else {
// Create table
logger.info(`${loggerPrefix} creating table "apiKeys"`)
const DataTypes = queryInterface.sequelize.Sequelize.DataTypes
await queryInterface.createTable('apiKeys', {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
permissions: DataTypes.JSON,
createdAt: {
type: DataTypes.DATE,
allowNull: false
},
updatedAt: {
type: DataTypes.DATE,
allowNull: false
},
userId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users'
},
key: 'id'
},
onDelete: 'CASCADE'
},
createdByUserId: {
type: DataTypes.UUID,
references: {
model: {
tableName: 'users',
as: 'createdByUser'
},
key: 'id'
},
onDelete: 'SET NULL'
}
})
logger.info(`${loggerPrefix} created table "apiKeys"`)
}
logger.info(`${loggerPrefix} UPGRADE END: ${migrationName}`)
}
/**
* This downward migration script removes the sessions table and apiKeys table.
*
* @param {MigrationOptions} options - an object containing the migration context.
* @returns {Promise<void>} - A promise that resolves when the migration is complete.
*/
async function down({ context: { queryInterface, logger } }) {
// Downward migration script
logger.info(`${loggerPrefix} DOWNGRADE BEGIN: ${migrationName}`)
// Check if table exists
if (await queryInterface.tableExists('sessions')) {
logger.info(`${loggerPrefix} dropping table "sessions"`)
// Drop table
await queryInterface.dropTable('sessions')
logger.info(`${loggerPrefix} dropped table "sessions"`)
} else {
logger.info(`${loggerPrefix} table "sessions" does not exist`)
}
if (await queryInterface.tableExists('apiKeys')) {
logger.info(`${loggerPrefix} dropping table "apiKeys"`)
await queryInterface.dropTable('apiKeys')
logger.info(`${loggerPrefix} dropped table "apiKeys"`)
} else {
logger.info(`${loggerPrefix} table "apiKeys" does not exist`)
}
logger.info(`${loggerPrefix} DOWNGRADE END: ${migrationName}`)
}
module.exports = { up, down }

272
server/models/ApiKey.js Normal file
View file

@ -0,0 +1,272 @@
const { DataTypes, Model, Op } = require('sequelize')
const jwt = require('jsonwebtoken')
const { LRUCache } = require('lru-cache')
const Logger = require('../Logger')
/**
* @typedef {Object} ApiKeyPermissions
* @property {boolean} download
* @property {boolean} update
* @property {boolean} delete
* @property {boolean} upload
* @property {boolean} createEreader
* @property {boolean} accessAllLibraries
* @property {boolean} accessAllTags
* @property {boolean} accessExplicitContent
* @property {boolean} selectedTagsNotAccessible
* @property {string[]} librariesAccessible
* @property {string[]} itemTagsSelected
*/
class ApiKeyCache {
constructor() {
this.cache = new LRUCache({ max: 100 })
}
getById(id) {
const apiKey = this.cache.get(id)
return apiKey
}
set(apiKey) {
apiKey.fromCache = true
this.cache.set(apiKey.id, apiKey)
}
delete(apiKeyId) {
this.cache.delete(apiKeyId)
}
maybeInvalidate(apiKey) {
if (!apiKey.fromCache) this.delete(apiKey.id)
}
}
const apiKeyCache = new ApiKeyCache()
class ApiKey extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.name
/** @type {string} */
this.description
/** @type {Date} */
this.expiresAt
/** @type {Date} */
this.lastUsedAt
/** @type {boolean} */
this.isActive
/** @type {ApiKeyPermissions} */
this.permissions
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {UUIDV4} */
this.createdByUserId
// Expanded properties
/** @type {import('./User').User} */
this.user
}
/**
* Same properties as User.getDefaultPermissions
* @returns {ApiKeyPermissions}
*/
static getDefaultPermissions() {
return {
download: true,
update: true,
delete: true,
upload: true,
createEreader: true,
accessAllLibraries: true,
accessAllTags: true,
accessExplicitContent: true,
selectedTagsNotAccessible: false, // Inverts itemTagsSelected
librariesAccessible: [],
itemTagsSelected: []
}
}
/**
* Merge permissions from request with default permissions
* @param {ApiKeyPermissions} reqPermissions
* @returns {ApiKeyPermissions}
*/
static mergePermissionsWithDefault(reqPermissions) {
const permissions = this.getDefaultPermissions()
if (!reqPermissions || typeof reqPermissions !== 'object') {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permissions: ${reqPermissions}`)
return permissions
}
for (const key in reqPermissions) {
if (reqPermissions[key] === undefined) {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission key: ${key}`)
continue
}
if (key === 'librariesAccessible' || key === 'itemTagsSelected') {
if (!Array.isArray(reqPermissions[key]) || reqPermissions[key].some((value) => typeof value !== 'string')) {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid ${key} value: ${reqPermissions[key]}`)
continue
}
permissions[key] = reqPermissions[key]
} else if (typeof reqPermissions[key] !== 'boolean') {
Logger.warn(`[ApiKey] mergePermissionsWithDefault: Invalid permission value for key ${key}. Should be boolean`)
continue
}
permissions[key] = reqPermissions[key]
}
return permissions
}
/**
* Deactivate expired api keys
* @returns {Promise<number>} Number of api keys affected
*/
static async deactivateExpiredApiKeys() {
const [affectedCount] = await ApiKey.update(
{
isActive: false
},
{
where: {
isActive: true,
expiresAt: {
[Op.lt]: new Date()
}
}
}
)
return affectedCount
}
/**
* Generate a new api key
* @param {string} tokenSecret
* @param {string} keyId
* @param {string} name
* @param {number} [expiresIn] - Seconds until the api key expires or undefined for no expiration
* @returns {Promise<string>}
*/
static async generateApiKey(tokenSecret, keyId, name, expiresIn) {
const options = {}
if (expiresIn && !isNaN(expiresIn) && expiresIn > 0) {
options.expiresIn = expiresIn
}
return new Promise((resolve) => {
jwt.sign(
{
keyId,
name,
type: 'api'
},
tokenSecret,
options,
(err, token) => {
if (err) {
Logger.error(`[ApiKey] Error generating API key: ${err}`)
resolve(null)
} else {
resolve(token)
}
}
)
})
}
/**
* Get an api key by id, from cache or database
* @param {string} apiKeyId
* @returns {Promise<ApiKey | null>}
*/
static async getById(apiKeyId) {
if (!apiKeyId) return null
const cachedApiKey = apiKeyCache.getById(apiKeyId)
if (cachedApiKey) return cachedApiKey
const apiKey = await ApiKey.findByPk(apiKeyId)
if (!apiKey) return null
apiKeyCache.set(apiKey)
return apiKey
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
name: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
expiresAt: DataTypes.DATE,
lastUsedAt: DataTypes.DATE,
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
},
permissions: DataTypes.JSON
},
{
sequelize,
modelName: 'apiKey'
}
)
const { user } = sequelize.models
user.hasMany(ApiKey, {
onDelete: 'CASCADE'
})
ApiKey.belongsTo(user)
user.hasMany(ApiKey, {
foreignKey: 'createdByUserId',
onDelete: 'SET NULL'
})
ApiKey.belongsTo(user, { as: 'createdByUser', foreignKey: 'createdByUserId' })
}
async update(values, options) {
apiKeyCache.maybeInvalidate(this)
return await super.update(values, options)
}
async save(options) {
apiKeyCache.maybeInvalidate(this)
return await super.save(options)
}
async destroy(options) {
apiKeyCache.delete(this.id)
await super.destroy(options)
}
}
module.exports = ApiKey

88
server/models/Session.js Normal file
View file

@ -0,0 +1,88 @@
const { DataTypes, Model, Op } = require('sequelize')
class Session extends Model {
constructor(values, options) {
super(values, options)
/** @type {UUIDV4} */
this.id
/** @type {string} */
this.ipAddress
/** @type {string} */
this.userAgent
/** @type {Date} */
this.createdAt
/** @type {Date} */
this.updatedAt
/** @type {UUIDV4} */
this.userId
/** @type {Date} */
this.expiresAt
// Expanded properties
/** @type {import('./User').User} */
this.user
}
static async createSession(userId, ipAddress, userAgent, refreshToken, expiresAt) {
const session = await Session.create({ userId, ipAddress, userAgent, refreshToken, expiresAt })
return session
}
/**
* Clean up expired sessions from the database
* @returns {Promise<number>} Number of sessions deleted
*/
static async cleanupExpiredSessions() {
const deletedCount = await Session.destroy({
where: {
expiresAt: {
[Op.lt]: new Date()
}
}
})
return deletedCount
}
/**
* Initialize model
* @param {import('../Database').sequelize} sequelize
*/
static init(sequelize) {
super.init(
{
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
ipAddress: DataTypes.STRING,
userAgent: DataTypes.STRING,
refreshToken: {
type: DataTypes.STRING,
allowNull: false
},
expiresAt: {
type: DataTypes.DATE,
allowNull: false
}
},
{
sequelize,
modelName: 'session'
}
)
const { user } = sequelize.models
user.hasMany(Session, {
onDelete: 'CASCADE',
foreignKey: {
allowNull: false
}
})
Session.belongsTo(user)
}
}
module.exports = Session

View file

@ -190,7 +190,7 @@ class User extends Model {
static async createRootUser(username, pash, auth) {
const userId = uuidv4()
const token = await auth.generateAccessToken({ id: userId, username })
const token = auth.generateAccessToken({ id: userId, username })
const newUser = {
id: userId,
@ -208,6 +208,96 @@ class User extends Model {
return this.create(newUser)
}
/**
* Finds an existing user by OpenID subject identifier, or by email/username based on server settings,
* or creates a new user if configured to do so.
*
* @param {Object} userinfo
* @param {import('../Auth')} auth
* @returns {Promise<User>}
*/
static async findOrCreateUserFromOpenIdUserInfo(userinfo, auth) {
let user = await this.getUserByOpenIDSub(userinfo.sub)
// Matched by sub
if (user) {
Logger.debug(`[User] openid: User found by sub`)
return user
}
// Match existing user by email
if (global.ServerSettings.authOpenIDMatchExistingBy === 'email') {
if (userinfo.email) {
// Only disallow when email_verified explicitly set to false (allow both if not set or true)
if (userinfo.email_verified === false) {
Logger.warn(`[User] openid: User not found and email "${userinfo.email}" is not verified`)
return null
} else {
Logger.info(`[User] openid: User not found, checking existing with email "${userinfo.email}"`)
user = await this.getUserByEmail(userinfo.email)
if (user?.authOpenIDSub) {
Logger.warn(`[User] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`)
return null // User is linked to a different OpenID subject; do not proceed.
}
}
} else {
Logger.warn(`[User] openid: User not found and no email in userinfo`)
// We deny login, because if the admin whishes to match email, it makes sense to require it
return null
}
}
// Match existing user by username
else if (global.ServerSettings.authOpenIDMatchExistingBy === 'username') {
let username
if (userinfo.preferred_username) {
Logger.info(`[User] openid: User not found, checking existing with userinfo.preferred_username "${userinfo.preferred_username}"`)
username = userinfo.preferred_username
} else if (userinfo.username) {
Logger.info(`[User] openid: User not found, checking existing with userinfo.username "${userinfo.username}"`)
username = userinfo.username
} else {
Logger.warn(`[User] openid: User not found and neither preferred_username nor username in userinfo`)
return null
}
user = await this.getUserByUsername(username)
if (user?.authOpenIDSub) {
Logger.warn(`[User] openid: User found with username "${username}" but is already matched with sub "${user.authOpenIDSub}"`)
return null // User is linked to a different OpenID subject; do not proceed.
}
}
// Found existing user via email or username
if (user) {
if (!user.isActive) {
Logger.warn(`[User] openid: User found but is not active`)
return null
}
// Update user with OpenID sub
if (!user.extraData) user.extraData = {}
user.extraData.authOpenIDSub = userinfo.sub
user.changed('extraData', true)
await user.save()
Logger.debug(`[User] openid: User found by email/username`)
return user
}
// If no existing user was matched, auto-register if configured
if (global.ServerSettings.authOpenIDAutoRegister) {
Logger.info(`[User] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo)
user = await this.createUserFromOpenIdUserInfo(userinfo, auth)
return user
}
Logger.warn(`[User] openid: User not found and auto-register is disabled`)
return null
}
/**
* Create user from openid userinfo
* @param {Object} userinfo
@ -220,7 +310,7 @@ class User extends Model {
const username = userinfo.preferred_username || userinfo.name || userinfo.sub
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
const token = await auth.generateAccessToken({ id: userId, username })
const token = auth.generateAccessToken({ id: userId, username })
const newUser = {
id: userId,
@ -520,7 +610,11 @@ class User extends Model {
username: this.username,
email: this.email,
type: this.type,
// TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token,
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],

View file

@ -7,6 +7,7 @@ const User = require('../../models/User')
class ServerSettings {
constructor(settings) {
this.id = 'server-settings'
/** @type {string} JWT secret key ONLY used when JWT_SECRET_KEY is not set in ENV */
this.tokenSecret = null
// Scanner

View file

@ -34,6 +34,7 @@ const CustomMetadataProviderController = require('../controllers/CustomMetadataP
const MiscController = require('../controllers/MiscController')
const ShareController = require('../controllers/ShareController')
const StatsController = require('../controllers/StatsController')
const ApiKeyController = require('../controllers/ApiKeyController')
class ApiRouter {
constructor(Server) {
@ -181,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))
@ -325,6 +326,14 @@ class ApiRouter {
this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this))
this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this))
//
// API Key Routes
//
this.router.get('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.getAll.bind(this))
this.router.post('/api-keys', ApiKeyController.middleware.bind(this), ApiKeyController.create.bind(this))
this.router.patch('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.update.bind(this))
this.router.delete('/api-keys/:id', ApiKeyController.middleware.bind(this), ApiKeyController.delete.bind(this))
//
// Misc Routes
//

View file

@ -206,6 +206,11 @@ class LibraryItemScanner {
async scanPotentialNewLibraryItem(libraryItemPath, library, folder, isSingleMediaItem) {
const libraryItemScanData = await this.getLibraryItemScanData(libraryItemPath, library, folder, isSingleMediaItem)
if (!libraryItemScanData.libraryFiles.length) {
Logger.info(`[LibraryItemScanner] Library item at path "${libraryItemPath}" has no files - ignoring`)
return null
}
const scanLogger = new ScanLogger()
scanLogger.verbose = true
scanLogger.setData('libraryItem', libraryItemScanData.relPath)

View file

@ -606,6 +606,11 @@ class LibraryScanner {
} else if (library.settings.audiobooksOnly && !hasAudioFiles(fileUpdateGroup, itemDir)) {
Logger.debug(`[LibraryScanner] Folder update for relative path "${itemDir}" has no audio files`)
continue
} else if (!(await fs.pathExists(fullPath))) {
Logger.info(`[LibraryScanner] File update group "${itemDir}" does not exist - ignoring`)
itemGroupingResults[itemDir] = ScanResult.NOTHING
continue
}
// Check if a library item is a subdirectory of this dir

View file

@ -109,7 +109,7 @@ function getIno(path) {
.stat(path, { bigint: true })
.then((data) => String(data.ino))
.catch((err) => {
Logger.error('[Utils] Failed to get ino for path', path, err)
Logger.warn(`[Utils] Failed to get ino for path "${path}"`, err)
return null
})
}

View file

@ -0,0 +1,82 @@
const { rateLimit, RateLimitRequestHandler } = require('express-rate-limit')
const Logger = require('../Logger')
const requestIp = require('../libs/requestIp')
/**
* Factory for creating authentication rate limiters
*/
class RateLimiterFactory {
static DEFAULT_WINDOW_MS = 10 * 60 * 1000 // 10 minutes
static DEFAULT_MAX = 40 // 40 attempts
constructor() {
this.authRateLimiter = null
}
/**
* Get the authentication rate limiter
* @returns {RateLimitRequestHandler}
*/
getAuthRateLimiter() {
if (this.authRateLimiter) {
return this.authRateLimiter
}
// Disable by setting max to 0
if (process.env.RATE_LIMIT_AUTH_MAX === '0') {
this.authRateLimiter = (req, res, next) => next()
Logger.info(`[RateLimiterFactory] Authentication rate limiting disabled by ENV variable`)
return this.authRateLimiter
}
let windowMs = RateLimiterFactory.DEFAULT_WINDOW_MS
if (parseInt(process.env.RATE_LIMIT_AUTH_WINDOW) > 0) {
windowMs = parseInt(process.env.RATE_LIMIT_AUTH_WINDOW)
if (windowMs !== RateLimiterFactory.DEFAULT_WINDOW_MS) {
Logger.info(`[RateLimiterFactory] Authentication rate limiting window set to ${windowMs}ms by ENV variable`)
}
}
let max = RateLimiterFactory.DEFAULT_MAX
if (parseInt(process.env.RATE_LIMIT_AUTH_MAX) > 0) {
max = parseInt(process.env.RATE_LIMIT_AUTH_MAX)
if (max !== RateLimiterFactory.DEFAULT_MAX) {
Logger.info(`[RateLimiterFactory] Authentication rate limiting max set to ${max} by ENV variable`)
}
}
let message = 'Too many authentication requests'
if (process.env.RATE_LIMIT_AUTH_MESSAGE) {
message = process.env.RATE_LIMIT_AUTH_MESSAGE
}
this.authRateLimiter = rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => {
// Override keyGenerator to handle proxy IPs
return requestIp.getClientIp(req) || req.ip
},
handler: (req, res) => {
const userAgent = req.get('User-Agent') || 'Unknown'
const endpoint = req.path
const method = req.method
const ip = requestIp.getClientIp(req) || req.ip
Logger.warn(`[RateLimiter] Rate limit exceeded - IP: ${ip}, Endpoint: ${method} ${endpoint}, User-Agent: ${userAgent}`)
res.status(429).json({
error: message
})
}
})
Logger.debug(`[RateLimiterFactory] Created auth rate limiter: ${max} attempts per ${windowMs / 1000 / 60} minutes`)
return this.authRateLimiter
}
}
module.exports = new RateLimiterFactory()