From 679bdf36b117a59b4db0cd8022818acc5d17f5b7 Mon Sep 17 00:00:00 2001 From: Jorge <46056498+jorgectf@users.noreply.github.com> Date: Mon, 3 Jul 2023 09:15:04 +0200 Subject: [PATCH 001/175] Add CodeQL workflow --- .github/workflows/codeql.yml | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..a77ab3e0a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,65 @@ +name: "CodeQL" + +on: + push: + branches: [ 'master' ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ 'master' ] + schedule: + - cron: '16 5 * * 4' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'javascript' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] + # Use only 'java' to analyze code written in Java, Kotlin or both + # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both + # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" From 56f1bfef507228ddeb58795666d6264bfe5cd966 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 17:57:24 +0100 Subject: [PATCH 002/175] Auth/OpenID: Implement Permissions via OpenID * Ability to set group * Ability to set more advanced permissions * Modified TextInputWithLabel to provide an ability to specify a different placeholder then the name --- client/components/ui/TextInputWithLabel.vue | 3 +- client/pages/config/authentication.vue | 45 +++++++++++- server/Auth.js | 81 ++++++++++++++++++++- server/objects/settings/ServerSettings.js | 17 ++++- server/objects/user/User.js | 72 ++++++++++++++++++ 5 files changed, 210 insertions(+), 8 deletions(-) diff --git a/client/components/ui/TextInputWithLabel.vue b/client/components/ui/TextInputWithLabel.vue index 032e24ca6..f653a18be 100644 --- a/client/components/ui/TextInputWithLabel.vue +++ b/client/components/ui/TextInputWithLabel.vue @@ -5,7 +5,7 @@ >{{ label }}{{ note }} - + @@ -14,6 +14,7 @@ export default { props: { value: [String, Number], label: String, + placeholder: String, note: String, type: { type: String, diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 3373e2878..91c6cfe2a 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -70,17 +70,42 @@

{{ $strings.LabelMatchExistingUsersByDescription }}

-
+

{{ $strings.LabelAutoLaunch }}

-
+

{{ $strings.LabelAutoRegister }}

{{ $strings.LabelAutoRegisterDescription }}

+ +
Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.
+
+
+ +
+

+ Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as groups. If configured, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to + multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied. +

+
+ +
+
+ +
+
+

+ Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (if configured). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as false. Ensure the identity provider's claim matches the expected structure: +

+
{{ newAuthSettings.authOpenIDSamplePermissions }}
+                
+
+
@@ -222,6 +247,22 @@ export default { } }) } + + function isValidClaim(claim) { + if (claim === '') return true + + const pattern = new RegExp('^[a-zA-Z][a-zA-Z0-9_-]*$', 'i') + return pattern.test(claim) + } + if (!isValidClaim(this.newAuthSettings.authOpenIDGroupClaim)) { + this.$toast.error('Group Claim: Invalid claim name') + isValid = false + } + if (!isValidClaim(this.newAuthSettings.authOpenIDAdvancedPermsClaim)) { + this.$toast.error('Advanced Permission Claim: Invalid claim name') + isValid = false + } + return isValid }, async saveSettings() { diff --git a/server/Auth.js b/server/Auth.js index 352faf661..a4cdd1fcb 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -98,7 +98,7 @@ class Auth { scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { - Logger.debug(`[Auth] openid callback userinfo=`, userinfo) + Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) let failureMessage = 'Unauthorized' if (!userinfo.sub) { @@ -106,6 +106,35 @@ class Auth { return done(null, null, failureMessage) } + // Check if the claims itself are returned correctly + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + if (groupClaimName) { + if (!userinfo[groupClaimName]) { + Logger.error(`[Auth] openid callback invalid: Group claim ${groupClaimName} configured, but not found or empty in userinfo`) + return done(null, null, failureMessage) + } + + const groupsList = userinfo[groupClaimName] + const targetRoles = ['admin', 'user', 'guest'] + + // Convert the list to lowercase for case-insensitive comparison + const groupsListLowercase = groupsList.map(group => group.toLowerCase()) + + // Check if any of the target roles exist in the groups list + const containsTargetRole = targetRoles.some(role => groupsListLowercase.includes(role.toLowerCase())) + + if (!containsTargetRole) { + Logger.info(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList) + return done(null, null, failureMessage) + } + } + + const advancedPermsClaimName = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (advancedPermsClaimName && !userinfo[advancedPermsClaimName]) { + Logger.error(`[Auth] openid callback invalid: Advanced perms claim ${advancedPermsClaimName} configured, but not found or empty in userinfo`) + return done(null, null, failureMessage) + } + // First check for matching user by sub let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) if (!user) { @@ -157,6 +186,43 @@ class Auth { return } + // Set user group if name of groups claim is configured + if (groupClaimName) { + const groupsList = userinfo[groupClaimName] ? userinfo[groupClaimName].map(group => group.toLowerCase()) : [] + const rolesInOrderOfPriority = ['admin', 'user', 'guest'] + + let userType = null + + for (let role of rolesInOrderOfPriority) { + if (groupsList.includes(role)) { + userType = role // This will override with the highest priority role found + break // Stop searching once the highest priority role is found + } + } + + // Actually already checked above, but just to be sure + if (!userType) { + Logger.error(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList) + return done(null, null, failureMessage) + } + + Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) + user.type = userType + await Database.userModel.updateFromOld(user) + } + + if (advancedPermsClaimName) { + try { + Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(userinfo[advancedPermsClaimName])}`) + + user.updatePermissionsFromExternalJSON(userinfo[advancedPermsClaimName]) + await Database.userModel.updateFromOld(user) + } catch (error) { + Logger.error(`[Auth] openid callback: Error updating advanced perms for user, error: `, error) + return done(null, null, failureMessage) + } + } + // 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 @@ -334,10 +400,19 @@ class Auth { sso_redirect_uri: oidcStrategy._params.redirect_uri // Save the redirect_uri (for the SSO Provider) for the callback } + var scope = 'openid profile email' + if (global.ServerSettings.authOpenIDGroupClaim) { + scope += ' ' + global.ServerSettings.authOpenIDGroupClaim + } + if (global.ServerSettings.authOpenIDAdvancedPermsClaim) { + scope += ' ' + global.ServerSettings.authOpenIDAdvancedPermsClaim + } + const authorizationUrl = client.authorizationUrl({ ...oidcStrategy._params, state: state, response_type: 'code', + scope: scope, code_challenge, code_challenge_method }) @@ -424,12 +499,12 @@ class Auth { } function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) { - Logger.error(logMessage) + Logger.error(JSON.stringify(logMessage, null, 2)) if (response) { // Depending on the error, it can also have a body // We also log the request header the passport plugin sents for the URL const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED') - Logger.debug(header + '\n' + response.body?.toString() + '\n' + JSON.stringify(response.body, null, 2)) + Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2)) } if (isMobile) { diff --git a/server/objects/settings/ServerSettings.js b/server/objects/settings/ServerSettings.js index 5cc68a5c5..5c2da3814 100644 --- a/server/objects/settings/ServerSettings.js +++ b/server/objects/settings/ServerSettings.js @@ -1,6 +1,7 @@ const packageJson = require('../../../package.json') const { BookshelfView } = require('../../utils/constants') const Logger = require('../../Logger') +const User = require('../user/User') class ServerSettings { constructor(settings) { @@ -72,6 +73,8 @@ class ServerSettings { this.authOpenIDAutoRegister = false this.authOpenIDMatchExistingBy = null this.authOpenIDMobileRedirectURIs = ['audiobookshelf://oauth'] + this.authOpenIDGroupClaim = '' + this.authOpenIDAdvancedPermsClaim = '' if (settings) { this.construct(settings) @@ -129,6 +132,8 @@ class ServerSettings { this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null this.authOpenIDMobileRedirectURIs = settings.authOpenIDMobileRedirectURIs || ['audiobookshelf://oauth'] + this.authOpenIDGroupClaim = settings.authOpenIDGroupClaim || '' + this.authOpenIDAdvancedPermsClaim = settings.authOpenIDAdvancedPermsClaim || '' if (!Array.isArray(this.authActiveAuthMethods)) { this.authActiveAuthMethods = ['local'] @@ -216,7 +221,9 @@ class ServerSettings { authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, - authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client + authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim // Do not return to client } } @@ -226,6 +233,8 @@ class ServerSettings { delete json.authOpenIDClientID delete json.authOpenIDClientSecret delete json.authOpenIDMobileRedirectURIs + delete json.authOpenIDGroupClaim + delete json.authOpenIDAdvancedPermsClaim return json } @@ -262,7 +271,11 @@ class ServerSettings { authOpenIDAutoLaunch: this.authOpenIDAutoLaunch, authOpenIDAutoRegister: this.authOpenIDAutoRegister, authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy, - authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs // Do not return to client + authOpenIDMobileRedirectURIs: this.authOpenIDMobileRedirectURIs, // Do not return to client + authOpenIDGroupClaim: this.authOpenIDGroupClaim, // Do not return to client + authOpenIDAdvancedPermsClaim: this.authOpenIDAdvancedPermsClaim, // Do not return to client + + authOpenIDSamplePermissions: User.getSampleAbsPermissions() } } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index d926e8be0..d09e921dd 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -268,6 +268,78 @@ class User { return hasUpdates } + // List of expected permission properties from the client + static permissionMapping = { + canDownload: 'download', + canUpload: 'upload', + canDelete: 'delete', + canUpdate: 'update', + canAccessExplicitContent: 'accessExplicitContent', + canAccessAllLibraries: 'accessAllLibraries', + canAccessAllTags: 'accessAllTags', + tagsAreBlacklist: 'selectedTagsNotAccessible', + // Direct mapping for array-based permissions + allowedLibraries: 'librariesAccessible', + allowedTags: 'itemTagsSelected', + } + + /** + * Update user from external JSON + * + * @param {object} absPermissions JSON containg user permissions + */ + updatePermissionsFromExternalJSON(absPermissions) { + // Initialize all permissions to false first + Object.keys(User.permissionMapping).forEach(mappingKey => { + const userPermKey = User.permissionMapping[mappingKey]; + if (typeof this.permissions[userPermKey] === 'boolean') { + this.permissions[userPermKey] = false; // Default to false for boolean permissions + } else { + this[userPermKey] = []; // Default to empty array for other properties + } + }); + + Object.keys(absPermissions).forEach(absKey => { + const userPermKey = User.permissionMapping[absKey] + if (!userPermKey) { + throw new Error(`Unexpected permission property: ${absKey}`) + } + + // Update the user's permissions based on absPermissions + this.permissions[userPermKey] = absPermissions[absKey] + }); + + // Handle allowedLibraries and allowedTags separately if needed + if (absPermissions.allowedLibraries) { + this.librariesAccessible = absPermissions.allowedLibraries + } + if (absPermissions.allowedTags) { + this.itemTagsSelected = absPermissions.allowedTags + } + } + + /** + * Get a sample to show how a JSON for updatePermissionsFromExternalJSON should look like + * + * @returns JSON string + */ + static getSampleAbsPermissions() { + // Start with a template object where all permissions are false for simplicity + const samplePermissions = Object.keys(User.permissionMapping).reduce((acc, key) => { + // For array-based permissions, provide a sample array + if (key === 'allowedLibraries') { + acc[key] = [`ExampleLibrary`, `AnotherLibrary`]; + } else if (key === 'allowedTags') { + acc[key] = [`ExampleTag`, `AnotherTag`, `ThirdTag`]; + } else { + acc[key] = false; + } + return acc; + }, {}); + + return JSON.stringify(samplePermissions, null, 2); // Pretty print the JSON + } + /** * Get first available library id for user * From f661e0835ce3653640dabcc19559348c0c70dff2 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 19:18:38 +0100 Subject: [PATCH 003/175] Auth: Simplify Code --- server/Auth.js | 277 ++++++++++++++++++++++++++----------------------- 1 file changed, 147 insertions(+), 130 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index a4cdd1fcb..368f9a4d6 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -98,139 +98,156 @@ class Auth { scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { - Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + try { + Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) + + if (!userinfo.sub) { + throw new Error('Invalid userinfo, no sub') + } + + if (!this.validateGroupClaim(userinfo)) { + throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`) + } + + let user = await this.findOrCreateUser(userinfo) + + if (!user || !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 - let failureMessage = 'Unauthorized' - if (!userinfo.sub) { - Logger.error(`[Auth] openid callback invalid userinfo, no sub`) - return done(null, null, failureMessage) + return done(null, user) + } catch (error) { + Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`) + + return done(null, null, 'Unauthorized') } - - // Check if the claims itself are returned correctly - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; - if (groupClaimName) { - if (!userinfo[groupClaimName]) { - Logger.error(`[Auth] openid callback invalid: Group claim ${groupClaimName} configured, but not found or empty in userinfo`) - return done(null, null, failureMessage) - } - - const groupsList = userinfo[groupClaimName] - const targetRoles = ['admin', 'user', 'guest'] - - // Convert the list to lowercase for case-insensitive comparison - const groupsListLowercase = groupsList.map(group => group.toLowerCase()) - - // Check if any of the target roles exist in the groups list - const containsTargetRole = targetRoles.some(role => groupsListLowercase.includes(role.toLowerCase())) - - if (!containsTargetRole) { - Logger.info(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList) - return done(null, null, failureMessage) - } - } - - const advancedPermsClaimName = Database.serverSettings.authOpenIDAdvancedPermsClaim - if (advancedPermsClaimName && !userinfo[advancedPermsClaimName]) { - Logger.error(`[Auth] openid callback invalid: Advanced perms claim ${advancedPermsClaimName} configured, but not found or empty in userinfo`) - return done(null, null, failureMessage) - } - - // First check for matching user by sub - let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) - if (!user) { - // Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy" - if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { - Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) - user = await Database.userModel.getUserByEmail(userinfo.email) - // Check that user is not already matched - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with email "${userinfo.email}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback - failureMessage = 'A matching user was found but is already matched with another user from your auth provider' - user = null - } - } else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { - Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) - user = await Database.userModel.getUserByUsername(userinfo.preferred_username) - // Check that user is not already matched - if (user?.authOpenIDSub) { - Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_username}" but is already matched with sub "${user.authOpenIDSub}"`) - // TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback - failureMessage = 'A matching user was found but is already matched with another user from your auth provider' - user = null - } - } - - // If existing user was matched and isActive then save sub to user - if (user?.isActive) { - Logger.info(`[Auth] openid: New user found matching existing user "${user.username}"`) - user.authOpenIDSub = userinfo.sub - await Database.userModel.updateFromOld(user) - } else if (user && !user.isActive) { - Logger.warn(`[Auth] openid: New user found matching existing user "${user.username}" but that user is deactivated`) - } - - // Optionally auto register the user - if (!user && Database.serverSettings.authOpenIDAutoRegister) { - Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) - user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) - } - } - - if (!user?.isActive) { - if (user && !user.isActive) { - failureMessage = 'Unauthorized' - } - // deny login - done(null, null, failureMessage) - return - } - - // Set user group if name of groups claim is configured - if (groupClaimName) { - const groupsList = userinfo[groupClaimName] ? userinfo[groupClaimName].map(group => group.toLowerCase()) : [] - const rolesInOrderOfPriority = ['admin', 'user', 'guest'] - - let userType = null - - for (let role of rolesInOrderOfPriority) { - if (groupsList.includes(role)) { - userType = role // This will override with the highest priority role found - break // Stop searching once the highest priority role is found - } - } - - // Actually already checked above, but just to be sure - if (!userType) { - Logger.error(`[Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: `, groupsList) - return done(null, null, failureMessage) - } - - Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) - user.type = userType - await Database.userModel.updateFromOld(user) - } - - if (advancedPermsClaimName) { - try { - Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(userinfo[advancedPermsClaimName])}`) - - user.updatePermissionsFromExternalJSON(userinfo[advancedPermsClaimName]) - await Database.userModel.updateFromOld(user) - } catch (error) { - Logger.error(`[Auth] openid callback: Error updating advanced perms for user, error: `, error) - return done(null, null, failureMessage) - } - } - - // 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) })) } + /** + * 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. + */ + async findOrCreateUser(userinfo) { + let user = await Database.userModel.getUserByOpenIDSub(userinfo.sub) + + // Matched by sub + if (user) { + Logger.debug(`[Auth] openid: User found by sub`) + return user + } + + // Match existing user by email + if (Database.serverSettings.authOpenIDMatchExistingBy === 'email' && userinfo.email && userinfo.email_verified) { + Logger.info(`[Auth] openid: User not found, checking existing with email "${userinfo.email}"`) + user = await Database.userModel.getUserByEmail(userinfo.email) + + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] 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. + } + } + // Match existing user by username + else if (Database.serverSettings.authOpenIDMatchExistingBy === 'username' && userinfo.preferred_username) { + Logger.info(`[Auth] openid: User not found, checking existing with username "${userinfo.preferred_username}"`) + user = await Database.userModel.getUserByUsername(userinfo.preferred_username) + + if (user?.authOpenIDSub) { + Logger.warn(`[Auth] openid: User found with username "${userinfo.preferred_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(`[Auth] openid: User found but is not active`) + return null + } + + user.authOpenIDSub = userinfo.sub + await Database.userModel.updateFromOld(user) + + Logger.debug(`[Auth] openid: User found by email/username`) + return user + } + + // If no existing user was matched, auto-register if configured + if (Database.serverSettings.authOpenIDAutoRegister) { + Logger.info(`[Auth] openid: Auto-registering user with sub "${userinfo.sub}"`, userinfo) + user = await Database.userModel.createUserFromOpenIdUserInfo(userinfo, this) + return user + } + + Logger.warn(`[Auth] openid: User not found and auto-register is disabled`) + return null + } + + /** + * Validates the presence and content of the group claim in userinfo. + */ + validateGroupClaim(userinfo) { + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + if (!groupClaimName) // Allow no group claim when configured like this + return true + + // If configured it must exist in userinfo + if (!userinfo[groupClaimName]) { + return false + } + return true + } + +/** + * Sets the user group based on group claim in userinfo. + */ +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) { + Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) + + if (user.type !== userType) { + user.type = userType; + await Database.userModel.updateFromOld(user) + } + } 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. + */ +async updateUserPermissions(user, userinfo) { + const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim + if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything + return + + const absPermissions = userinfo[absPermissionsClaim] + if (!absPermissions) + throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) + + Logger.debug(`[Auth] openid callback: Updating advanced perms for user ${user.username} to ${JSON.stringify(absPermissions)}`) + user.updatePermissionsFromExternalJSON(absPermissions) + await Database.userModel.updateFromOld(user) +} + /** * Unuse strategy * @@ -421,7 +438,7 @@ class Auth { res.redirect(authorizationUrl) } catch (error) { - Logger.error(`[Auth] Error in /auth/openid route: ${error}`) + Logger.error(`[Auth] Error in /auth/openid route: ${error}\n${error?.stack}`) res.status(500).send('Internal Server Error') } @@ -477,7 +494,7 @@ class Auth { // Redirect to the overwrite URI saved in the map res.redirect(redirectUri) } catch (error) { - Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}`) + Logger.error(`[Auth] Error in /auth/openid/mobile-redirect route: ${error}\n${error?.stack}`) res.status(500).send('Internal Server Error') } }) From 50330b0a606901f320cdb8eda802575db2aa3ae6 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 19:18:47 +0100 Subject: [PATCH 004/175] Auth: Add translations --- client/pages/config/authentication.vue | 11 +++-------- client/strings/de.json | 3 +++ client/strings/en-us.json | 3 +++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/client/pages/config/authentication.vue b/client/pages/config/authentication.vue index 91c6cfe2a..cecccee4f 100644 --- a/client/pages/config/authentication.vue +++ b/client/pages/config/authentication.vue @@ -82,15 +82,12 @@

{{ $strings.LabelAutoRegisterDescription }}

-
Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.
+
{{ $strings.LabelOpenIDClaims }}
-

- Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as groups. If configured, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to - multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied. -

+

@@ -98,9 +95,7 @@
-

- Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (if configured). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as false. Ensure the identity provider's claim matches the expected structure: -

+

{{ newAuthSettings.authOpenIDSamplePermissions }}
                 
diff --git a/client/strings/de.json b/client/strings/de.json index ed99f095e..611432f10 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -387,6 +387,9 @@ "LabelNotStarted": "Nicht begonnen", "LabelNumberOfBooks": "Anzahl der Hörbücher", "LabelNumberOfEpisodes": "Anzahl der Episoden", + "LabelOpenIDClaims": "Lass die folgenden Optionen leer, um die erweiterte Zuweisung von Gruppen und Berechtigungen zu deaktivieren und automatisch die 'User'-Gruppe zuzuweisen.", + "LabelOpenIDGroupClaimDescription": "Name des OpenID-Claims, der eine Liste der Benutzergruppen enthält. Wird häufig als groups bezeichnet. Wenn konfiguriert, wird die Anwendung automatisch Rollen basierend auf den Gruppenmitgliedschaften des Benutzers zuweisen, vorausgesetzt, dass diese Gruppen im Claim als 'admin', 'user' oder 'guest' benannt sind (Groß/Kleinschreibung ist irrelevant). Der Claim eine Liste sein, und wenn ein Benutzer mehreren Gruppen angehört, wird die Anwendung die Rolle zuordnen, die dem höchsten Zugriffslevel entspricht. Wenn keine Gruppe übereinstimmt, wird der Zugang verweigert.", + "LabelOpenIDAdvancedPermsClaimDescription": "Name des OpenID-Claims, der erweiterte Berechtigungen für Benutzeraktionen innerhalb der Anwendung enthält, die auf Nicht-Admin-Rollen angewendet werden (wenn konfiguriert). Wenn der Claim in der Antwort fehlt, wird der Zugang zu ABS verweigert. Fehlt eine einzelne Option, wird sie als false behandelt. Stelle sicher, dass der Claim des Identitätsanbieters der erwarteten Struktur entspricht:", "LabelOpenRSSFeed": "Öffne RSS-Feed", "LabelOverwrite": "Überschreiben", "LabelPassword": "Passwort", diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 43a1ef448..b6fe3505c 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -387,6 +387,9 @@ "LabelNotStarted": "Not Started", "LabelNumberOfBooks": "Number of Books", "LabelNumberOfEpisodes": "# of Episodes", + "LabelOpenIDClaims": "Leave the following options empty to disable advanced group and permissions assignment, automatically assigning 'User' group then.", + "LabelOpenIDGroupClaimDescription": "Name of the OpenID claim that contains a list of the user's groups. Commonly referred to as groups. If configured, the application will automatically assign roles based on the user's group memberships, provided that these groups are named case-insensitively 'admin', 'user', or 'guest' in the claim. The claim should contain a list, and if a user belongs to multiple groups, the application will assign the role corresponding to the highest level of access. If no group matches, access will be denied.", + "LabelOpenIDAdvancedPermsClaimDescription": "Name of the OpenID claim that contains advanced permissions for user actions within the application which will apply to non-admin roles (if configured). If the claim is missing from the response, access to ABS will be denied. If a single option is missing, it will be treated as false. Ensure the identity provider's claim matches the expected structure:", "LabelOpenRSSFeed": "Open RSS Feed", "LabelOverwrite": "Overwrite", "LabelPassword": "Password", From 1646f0ebc21505a1ed00866cb7a033c5028ba5c4 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Tue, 19 Mar 2024 19:35:34 +0100 Subject: [PATCH 005/175] OpenID: Ignore admin for advanced permissions Also removed some semicolons --- server/Auth.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 368f9a4d6..e14348c78 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -193,7 +193,7 @@ class Auth { * Validates the presence and content of the group claim in userinfo. */ validateGroupClaim(userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim if (!groupClaimName) // Allow no group claim when configured like this return true @@ -208,7 +208,7 @@ class Auth { * Sets the user group based on group claim in userinfo. */ async setUserGroup(user, userinfo) { - const groupClaimName = Database.serverSettings.authOpenIDGroupClaim; + const groupClaimName = Database.serverSettings.authOpenIDGroupClaim if (!groupClaimName) // No group claim configured, don't set anything return @@ -223,7 +223,7 @@ async setUserGroup(user, userinfo) { Logger.debug(`[Auth] openid callback: Setting user ${user.username} type to ${userType}`) if (user.type !== userType) { - user.type = userType; + user.type = userType await Database.userModel.updateFromOld(user) } } else { @@ -239,6 +239,9 @@ async updateUserPermissions(user, userinfo) { if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything return + if (user.type === 'admin') + return + const absPermissions = userinfo[absPermissionsClaim] if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`) From 2d68fa2c278d5c3b5642486c8d410935a2baf3af Mon Sep 17 00:00:00 2001 From: Lauri Vuorela Date: Mon, 25 Mar 2024 16:32:29 +0100 Subject: [PATCH 006/175] fix book limit for the contiue series shelf --- server/utils/queries/libraryItemsBookFilters.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 07a8458d5..12bc97019 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -658,8 +658,13 @@ module.exports = { let includeAttributes = [ [Sequelize.literal('(SELECT max(mp.updatedAt) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.userId = :userId AND bs.seriesId = series.id)'), 'recent_progress'], ] + let booksNotFinishedQuery = `SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL)` + if (library.settings.onlyShowLaterBooksInContinueSeries) { - includeAttributes.push([Sequelize.literal('(SELECT CAST(max(bs.sequence) as FLOAT) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = 1 AND mp.userId = :userId AND bs.seriesId = series.id)'), 'maxSequence']) + const maxSequenceQuery = `(SELECT CAST(max(bs.sequence) as FLOAT) FROM bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = bs.bookId AND mp.isFinished = 1 AND mp.userId = :userId AND bs.seriesId = series.id)` + includeAttributes.push([Sequelize.literal(`${maxSequenceQuery}`), 'maxSequence']) + + booksNotFinishedQuery = booksNotFinishedQuery + ` AND CAST(bs.sequence as FLOAT) > ${maxSequenceQuery}` } const { rows: series, count } = await Database.seriesModel.findAndCountAll({ @@ -675,8 +680,8 @@ module.exports = { Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM mediaProgresses mp, bookSeries bs WHERE bs.seriesId = series.id AND mp.mediaItemId = bs.bookId AND mp.userId = :userId AND mp.isFinished = 1)`), { [Sequelize.Op.gte]: 1 }), - // Has at least 1 book not finished - Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId AND mp.userId = :userId WHERE bs.seriesId = series.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), { + // Has at least 1 book not finished (that has a sequence number higher than the highest already read, if library config is toggled) + Sequelize.where(Sequelize.literal(`(${booksNotFinishedQuery})`), { [Sequelize.Op.gte]: 1 }), // Has no books in progress From 8ce5a5cdbdbcda02c65fb0dbd1e6a25c2d3b6fe8 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 27 Mar 2024 13:18:02 +0200 Subject: [PATCH 007/175] Add workflow to dispatch an abs-windows event --- .github/workflows/notify-abs-windows.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .github/workflows/notify-abs-windows.yml diff --git a/.github/workflows/notify-abs-windows.yml b/.github/workflows/notify-abs-windows.yml new file mode 100644 index 000000000..9ede33b81 --- /dev/null +++ b/.github/workflows/notify-abs-windows.yml @@ -0,0 +1,17 @@ +name: Dispatch an abs-windows event + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + abs-windows-dispatch: + runs-on: ubuntu-latest + steps: + - name: Send a remote repository dispatch event + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.ABS_WINDOWS_PAT }} + repository: mikiher/audiobookshelf-windows + event-type: build-windows From 1cf0bd0f01d442899fc5943dfbdfde6704a76c74 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 27 Mar 2024 13:30:00 +0200 Subject: [PATCH 008/175] add dummy pull_request event for the workflow to appear in the list --- .github/workflows/notify-abs-windows.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/notify-abs-windows.yml b/.github/workflows/notify-abs-windows.yml index 9ede33b81..2f7414c80 100644 --- a/.github/workflows/notify-abs-windows.yml +++ b/.github/workflows/notify-abs-windows.yml @@ -4,6 +4,8 @@ on: release: types: [published] workflow_dispatch: + pull_request: + types: [opened] jobs: abs-windows-dispatch: From 33e4b51aee873b9a00ae54bb95c36efdcae7c842 Mon Sep 17 00:00:00 2001 From: mikiher Date: Wed, 27 Mar 2024 13:38:17 +0200 Subject: [PATCH 009/175] Revert "add dummy pull_request event for the workflow to appear in the list" This reverts commit 1cf0bd0f01d442899fc5943dfbdfde6704a76c74. --- .github/workflows/notify-abs-windows.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/notify-abs-windows.yml b/.github/workflows/notify-abs-windows.yml index 2f7414c80..9ede33b81 100644 --- a/.github/workflows/notify-abs-windows.yml +++ b/.github/workflows/notify-abs-windows.yml @@ -4,8 +4,6 @@ on: release: types: [published] workflow_dispatch: - pull_request: - types: [opened] jobs: abs-windows-dispatch: From 740640884fa8ec2f85acc20d48d7aeb65d66e967 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 27 Mar 2024 16:11:47 -0500 Subject: [PATCH 010/175] Update:Support for comic files with webp images #2792 --- client/components/readers/ComicReader.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue index d55fc0d61..49e9b093a 100644 --- a/client/components/readers/ComicReader.vue +++ b/client/components/readers/ComicReader.vue @@ -334,7 +334,7 @@ export default { } }, parseFilenames(filenames) { - const acceptableImages = ['.jpeg', '.jpg', '.png'] + const acceptableImages = ['.jpeg', '.jpg', '.png', '.webp'] var imageFiles = filenames.filter((f) => { return acceptableImages.includes((Path.extname(f) || '').toLowerCase()) }) From 617b8f4487d506da962658a8fd371584e8ba7734 Mon Sep 17 00:00:00 2001 From: Denis Arnst Date: Thu, 28 Mar 2024 16:16:26 +0100 Subject: [PATCH 011/175] OpenID: Rename tags switch --- server/objects/user/User.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/objects/user/User.js b/server/objects/user/User.js index d09e921dd..b473637bb 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -277,7 +277,7 @@ class User { canAccessExplicitContent: 'accessExplicitContent', canAccessAllLibraries: 'accessAllLibraries', canAccessAllTags: 'accessAllTags', - tagsAreBlacklist: 'selectedTagsNotAccessible', + tagsAreDenylist: 'selectedTagsNotAccessible', // Direct mapping for array-based permissions allowedLibraries: 'librariesAccessible', allowedTags: 'itemTagsSelected', From 33254654d5fbf589eee90861309a5ef2a51d572b Mon Sep 17 00:00:00 2001 From: mikiher Date: Thu, 28 Mar 2024 23:56:59 +0200 Subject: [PATCH 012/175] Add dir="auto" attribute where it makes sense --- client/components/cards/LazyBookCard.vue | 2 +- client/components/modals/item/tabs/Episodes.vue | 2 +- client/components/modals/podcast/ViewEpisode.vue | 4 ++-- client/components/tables/ChaptersTable.vue | 2 +- client/components/tables/podcast/DownloadQueueTable.vue | 2 +- client/components/tables/podcast/LazyEpisodeRow.vue | 2 +- client/components/ui/TextInput.vue | 2 +- client/components/ui/TextareaInput.vue | 2 +- client/pages/item/_id/index.vue | 2 +- client/pages/library/_library/podcast/latest.vue | 4 ++-- 10 files changed, 12 insertions(+), 12 deletions(-) diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index efeb0165c..faa93997f 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -6,7 +6,7 @@
-
+

{{ displayTitle }}

diff --git a/client/components/modals/item/tabs/Episodes.vue b/client/components/modals/item/tabs/Episodes.vue index 661f41e09..ecf58330d 100644 --- a/client/components/modals/item/tabs/Episodes.vue +++ b/client/components/modals/item/tabs/Episodes.vue @@ -29,7 +29,7 @@

{{ episode.episode }}

- + {{ episode.title }} diff --git a/client/components/modals/podcast/ViewEpisode.vue b/client/components/modals/podcast/ViewEpisode.vue index 79f22a031..411e9efd9 100644 --- a/client/components/modals/podcast/ViewEpisode.vue +++ b/client/components/modals/podcast/ViewEpisode.vue @@ -15,8 +15,8 @@

{{ podcastAuthor }}

-

{{ title }}

-
+

{{ title }}

+

{{ $strings.MessageNoDescription }}

diff --git a/client/components/tables/ChaptersTable.vue b/client/components/tables/ChaptersTable.vue index 0dd9f2ab4..2abe16073 100644 --- a/client/components/tables/ChaptersTable.vue +++ b/client/components/tables/ChaptersTable.vue @@ -21,7 +21,7 @@

{{ chapter.id }}

- + {{ chapter.title }} diff --git a/client/components/tables/podcast/DownloadQueueTable.vue b/client/components/tables/podcast/DownloadQueueTable.vue index 4b9112298..04e631e2f 100644 --- a/client/components/tables/podcast/DownloadQueueTable.vue +++ b/client/components/tables/podcast/DownloadQueueTable.vue @@ -30,7 +30,7 @@
- + {{ downloadQueued.episodeDisplayTitle }} diff --git a/client/components/tables/podcast/LazyEpisodeRow.vue b/client/components/tables/podcast/LazyEpisodeRow.vue index 185763409..0b32609bd 100644 --- a/client/components/tables/podcast/LazyEpisodeRow.vue +++ b/client/components/tables/podcast/LazyEpisodeRow.vue @@ -2,7 +2,7 @@
-
+
{{ episodeTitle }}
diff --git a/client/components/ui/TextInput.vue b/client/components/ui/TextInput.vue index e06740ea3..462118f06 100644 --- a/client/components/ui/TextInput.vue +++ b/client/components/ui/TextInput.vue @@ -1,6 +1,6 @@