Merge branch 'caching' of https://github.com/mikiher/audiobookshelf into caching

This commit is contained in:
mikiher 2023-11-22 19:00:11 +02:00
parent 107b4b83c1
commit 5aeb6ade72
28 changed files with 6580 additions and 394 deletions

3
.gitignore vendored
View File

@ -7,11 +7,12 @@
/podcasts/
/media/
/metadata/
test/
/client/.nuxt/
/client/dist/
/dist/
/deploy/
/coverage/
/.nyc_output/
sw.*
.DS_STORE

View File

@ -16,5 +16,6 @@
},
"editor.formatOnSave": true,
"editor.detectIndentation": true,
"editor.tabSize": 2
"editor.tabSize": 2,
"javascript.format.semicolons": "remove"
}

View File

@ -258,4 +258,24 @@ Bookshelf Label
.no-bars .Vue-Toastification__container.top-right {
padding-top: 8px;
}
.abs-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.abs-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
.abs-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}

View File

@ -104,6 +104,11 @@ export default {
id: 'config-rss-feeds',
title: this.$strings.HeaderRSSFeeds,
path: '/config/rss-feeds'
},
{
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
}
]

View File

@ -1,5 +1,5 @@
<template>
<nuxt-link v-if="to" :to="to" class="btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<nuxt-link v-if="to" :to="to" class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center" :disabled="disabled || loading" :class="classList">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
@ -7,7 +7,7 @@
</svg>
</div>
</nuxt-link>
<button v-else class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<button v-else class="abs-btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @mousedown.prevent @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
@ -72,23 +72,3 @@ export default {
mounted() {}
}
</script>
<style scoped>
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>

View File

@ -57,6 +57,7 @@ export default {
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail
else if (pageName === 'authentication') return this.$strings.HeaderAuthentication
}
return this.$strings.HeaderSettings
}

View File

@ -0,0 +1,229 @@
<template>
<div>
<app-settings-content :header-text="$strings.HeaderAuthentication">
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableLocalAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">Password Authentication</p>
</div>
</div>
<div class="w-full border border-white/10 rounded-xl p-4 my-4 bg-primary/25">
<div class="flex items-center">
<ui-checkbox v-model="enableOpenIDAuth" checkbox-bg="bg" />
<p class="text-lg pl-4">OpenID Connect Authentication</p>
</div>
<transition name="slide">
<div v-if="enableOpenIDAuth" class="flex flex-wrap pt-4">
<div class="w-full flex items-center mb-2">
<div class="flex-grow">
<ui-text-input-with-label ref="issuerUrl" v-model="newAuthSettings.authOpenIDIssuerURL" :disabled="savingSettings" :label="'Issuer URL'" />
</div>
<div class="w-36 mx-1 mt-[1.375rem]">
<ui-btn class="h-[2.375rem] text-sm inline-flex items-center justify-center w-full" type="button" :padding-y="0" :padding-x="4" @click.stop="autoPopulateOIDCClick">
<span class="material-icons text-base">auto_fix_high</span>
<span class="whitespace-nowrap break-keep pl-1">Auto-populate</span></ui-btn
>
</div>
</div>
<ui-text-input-with-label ref="authorizationUrl" v-model="newAuthSettings.authOpenIDAuthorizationURL" :disabled="savingSettings" :label="'Authorize URL'" class="mb-2" />
<ui-text-input-with-label ref="tokenUrl" v-model="newAuthSettings.authOpenIDTokenURL" :disabled="savingSettings" :label="'Token URL'" class="mb-2" />
<ui-text-input-with-label ref="userInfoUrl" v-model="newAuthSettings.authOpenIDUserInfoURL" :disabled="savingSettings" :label="'Userinfo URL'" class="mb-2" />
<ui-text-input-with-label ref="jwksUrl" v-model="newAuthSettings.authOpenIDJwksURL" :disabled="savingSettings" :label="'JWKS URL'" class="mb-2" />
<ui-text-input-with-label ref="logoutUrl" v-model="newAuthSettings.authOpenIDLogoutURL" :disabled="savingSettings" :label="'Logout URL'" class="mb-2" />
<ui-text-input-with-label ref="openidClientId" v-model="newAuthSettings.authOpenIDClientID" :disabled="savingSettings" :label="'Client ID'" class="mb-2" />
<ui-text-input-with-label ref="openidClientSecret" v-model="newAuthSettings.authOpenIDClientSecret" :disabled="savingSettings" :label="'Client Secret'" class="mb-2" />
<ui-text-input-with-label ref="buttonTextInput" v-model="newAuthSettings.authOpenIDButtonText" :disabled="savingSettings" :label="'Button Text'" class="mb-2" />
<div class="flex items-center pt-1 mb-2">
<div class="w-44">
<ui-dropdown v-model="newAuthSettings.authOpenIDMatchExistingBy" small :items="matchingExistingOptions" label="Match existing users by" :disabled="savingSettings" />
</div>
<p class="pl-4 text-sm text-gray-300 mt-5">Used for connecting existing users. Once connected, users will be matched by a unique id from your SSO provider</p>
</div>
<div class="flex items-center py-4 px-1">
<ui-toggle-switch labeledBy="auto-redirect-toggle" v-model="newAuthSettings.authOpenIDAutoLaunch" :disabled="savingSettings" />
<p id="auto-redirect-toggle" class="pl-4">Auto Launch</p>
<p class="pl-4 text-sm text-gray-300">Redirect to the auth provider automatically when navigating to the login page</p>
</div>
<div class="flex items-center py-4 px-1">
<ui-toggle-switch labeledBy="auto-register-toggle" v-model="newAuthSettings.authOpenIDAutoRegister" :disabled="savingSettings" />
<p id="auto-register-toggle" class="pl-4">Auto Register</p>
<p class="pl-4 text-sm text-gray-300">Automatically create new users after logging in</p>
</div>
</div>
</transition>
</div>
<div class="w-full flex items-center justify-end p-4">
<ui-btn color="success" :padding-x="8" small class="text-base" :loading="savingSettings" @click="saveSettings">{{ $strings.ButtonSave }}</ui-btn>
</div>
</app-settings-content>
</div>
</template>
<script>
export default {
async asyncData({ store, redirect, app }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
const authSettings = await app.$axios.$get('/api/auth-settings').catch((error) => {
console.error('Failed', error)
return null
})
if (!authSettings) {
redirect('/config')
return
}
return {
authSettings
}
},
data() {
return {
enableLocalAuth: false,
enableOpenIDAuth: false,
savingSettings: false,
newAuthSettings: {}
}
},
computed: {
authMethods() {
return this.authSettings.authActiveAuthMethods || []
},
matchingExistingOptions() {
return [
{
text: 'Do not match',
value: null
},
{
text: 'Match by email',
value: 'email'
},
{
text: 'Match by username',
value: 'username'
}
]
}
},
methods: {
autoPopulateOIDCClick() {
if (!this.newAuthSettings.authOpenIDIssuerURL) {
this.$toast.error('Issuer URL required')
return
}
// Remove trailing slash
let issuerUrl = this.newAuthSettings.authOpenIDIssuerURL
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
// If the full config path is on the issuer url then remove it
if (issuerUrl.endsWith('/.well-known/openid-configuration')) {
issuerUrl = issuerUrl.replace('/.well-known/openid-configuration', '')
this.newAuthSettings.authOpenIDIssuerURL = this.newAuthSettings.authOpenIDIssuerURL.replace('/.well-known/openid-configuration', '')
}
this.$axios
.$get(`/auth/openid/config?issuer=${issuerUrl}`)
.then((data) => {
if (data.issuer) this.newAuthSettings.authOpenIDIssuerURL = data.issuer
if (data.authorization_endpoint) this.newAuthSettings.authOpenIDAuthorizationURL = data.authorization_endpoint
if (data.token_endpoint) this.newAuthSettings.authOpenIDTokenURL = data.token_endpoint
if (data.userinfo_endpoint) this.newAuthSettings.authOpenIDUserInfoURL = data.userinfo_endpoint
if (data.end_session_endpoint) this.newAuthSettings.authOpenIDLogoutURL = data.end_session_endpoint
if (data.jwks_uri) this.newAuthSettings.authOpenIDJwksURL = data.jwks_uri
})
.catch((error) => {
console.error('Failed to receive data', error)
const errorMsg = error.response?.data || 'Unknown error'
this.$toast.error(errorMsg)
})
},
validateOpenID() {
let isValid = true
if (!this.newAuthSettings.authOpenIDIssuerURL) {
this.$toast.error('Issuer URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDAuthorizationURL) {
this.$toast.error('Authorize URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDTokenURL) {
this.$toast.error('Token URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDUserInfoURL) {
this.$toast.error('Userinfo URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDJwksURL) {
this.$toast.error('JWKS URL required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientID) {
this.$toast.error('Client ID required')
isValid = false
}
if (!this.newAuthSettings.authOpenIDClientSecret) {
this.$toast.error('Client Secret required')
isValid = false
}
return isValid
},
async saveSettings() {
if (!this.enableLocalAuth && !this.enableOpenIDAuth) {
this.$toast.error('Must have at least one authentication method enabled')
return
}
if (this.enableOpenIDAuth && !this.validateOpenID()) {
return
}
this.newAuthSettings.authActiveAuthMethods = []
if (this.enableLocalAuth) this.newAuthSettings.authActiveAuthMethods.push('local')
if (this.enableOpenIDAuth) this.newAuthSettings.authActiveAuthMethods.push('openid')
this.savingSettings = true
this.$axios
.$patch('/api/auth-settings', this.newAuthSettings)
.then((data) => {
this.$store.commit('setServerSettings', data.serverSettings)
this.$toast.success('Server settings updated')
})
.catch((error) => {
console.error('Failed to update server settings', error)
this.$toast.error('Failed to update server settings')
})
.finally(() => {
this.savingSettings = false
})
},
init() {
this.newAuthSettings = {
...this.authSettings
}
this.enableLocalAuth = this.authMethods.includes('local')
this.enableOpenIDAuth = this.authMethods.includes('openid')
}
},
mounted() {
this.init()
}
}
</script>

View File

@ -25,9 +25,12 @@
</div>
<div v-else-if="isInit" class="w-full max-w-md px-8 pb-8 pt-4 -mt-40">
<p class="text-3xl text-white text-center mb-4">{{ $strings.HeaderLogin }}</p>
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
<p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<form @submit.prevent="submitForm">
<form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model="username" :disabled="processing" class="mb-3 w-full" />
@ -37,6 +40,14 @@
<ui-btn type="submit" :disabled="processing" color="primary" class="leading-none">{{ processing ? 'Checking...' : $strings.ButtonSubmit }}</ui-btn>
</div>
</form>
<div v-if="login_local && login_openid" class="w-full h-px bg-white bg-opacity-10 my-4" />
<div class="w-full flex py-3">
<a v-if="login_openid" :href="openidAuthUri" class="w-full abs-btn outline-none rounded-md shadow-md relative border border-gray-600 text-center bg-primary text-white px-8 py-2 leading-none">
{{ openIDButtonText }}
</a>
</div>
</div>
</div>
</div>
@ -60,7 +71,10 @@ export default {
},
confirmPassword: '',
ConfigPath: '',
MetadataPath: ''
MetadataPath: '',
login_local: true,
login_openid: false,
authFormData: null
}
},
watch: {
@ -93,6 +107,12 @@ export default {
computed: {
user() {
return this.$store.state.user.user
},
openidAuthUri() {
return `${process.env.serverUrl}/auth/openid?callback=${location.href.split('?').shift()}`
},
openIDButtonText() {
return this.authFormData?.authOpenIDButtonText || 'Login with OpenId'
}
},
methods: {
@ -162,6 +182,7 @@ export default {
else this.error = 'Unknown Error'
return false
})
if (authRes?.error) {
this.error = authRes.error
} else if (authRes) {
@ -196,28 +217,62 @@ export default {
this.processing = true
this.$axios
.$get('/status')
.then((res) => {
this.processing = false
this.isInit = res.isInit
this.showInitScreen = !res.isInit
this.$setServerLanguageCode(res.language)
.then((data) => {
this.isInit = data.isInit
this.showInitScreen = !data.isInit
this.$setServerLanguageCode(data.language)
if (this.showInitScreen) {
this.ConfigPath = res.ConfigPath || ''
this.MetadataPath = res.MetadataPath || ''
this.ConfigPath = data.ConfigPath || ''
this.MetadataPath = data.MetadataPath || ''
} else {
this.authFormData = data.authFormData
this.updateLoginVisibility(data.authMethods || [])
}
})
.catch((error) => {
console.error('Status check failed', error)
this.processing = false
this.criticalError = 'Status check failed'
})
.finally(() => {
this.processing = false
})
},
updateLoginVisibility(authMethods) {
if (this.$route.query?.error) {
this.error = this.$route.query.error
// Remove error query string
const newurl = new URL(location.href)
newurl.searchParams.delete('error')
window.history.replaceState({ path: newurl.href }, '', newurl.href)
}
if (authMethods.includes('local') || !authMethods.length) {
this.login_local = true
} else {
this.login_local = false
}
if (authMethods.includes('openid')) {
// Auto redirect unless query string ?autoLaunch=0
if (this.authFormData?.authOpenIDAutoLaunch && this.$route.query?.autoLaunch !== '0') {
window.location.href = this.openidAuthUri
}
this.login_openid = true
} else {
this.login_openid = false
}
}
},
async mounted() {
if (localStorage.getItem('token')) {
var userfound = await this.checkAuth()
if (userfound) return // if valid user no need to check status
if (this.$route.query?.setToken) {
localStorage.setItem('token', this.$route.query.setToken)
}
if (localStorage.getItem('token')) {
if (await this.checkAuth()) return // if valid user no need to check status
}
this.checkStatus()
}
}

View File

@ -66,7 +66,7 @@ export const getters = {
export const actions = {
updateServerSettings({ commit }, payload) {
var updatePayload = {
const updatePayload = {
...payload
}
return this.$axios.$patch('/api/settings', updatePayload).then((result) => {

View File

@ -92,6 +92,7 @@
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
"HeaderAudiobookTools": "Audiobook File Management Tools",
"HeaderAudioTracks": "Audio Tracks",
"HeaderAuthentication": "Authentication",
"HeaderBackups": "Backups",
"HeaderChangePassword": "Change Password",
"HeaderChapters": "Chapters",

4743
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,9 @@
"docker-amd64-local": "docker buildx build --platform linux/amd64 --load . -t advplyr/audiobookshelf-amd64-local",
"docker-arm64-local": "docker buildx build --platform linux/arm64 --load . -t advplyr/audiobookshelf-arm64-local",
"docker-armv7-local": "docker buildx build --platform linux/arm/v7 --load . -t advplyr/audiobookshelf-armv7-local",
"deploy-linux": "node deploy/linux"
"deploy-linux": "node deploy/linux",
"test": "mocha",
"coverage": "nyc mocha"
},
"bin": "prod.js",
"pkg": {
@ -28,16 +30,24 @@
"server/**/*.js"
]
},
"mocha": {
"recursive": true
},
"author": "advplyr",
"license": "GPL-3.0",
"dependencies": {
"axios": "^0.27.2",
"cookie-parser": "^1.4.6",
"express": "^4.17.1",
"express-session": "^1.17.3",
"graceful-fs": "^4.2.10",
"htmlparser2": "^8.0.1",
"lru-cache": "^10.0.2",
"lru-cache": "^10.0.3",
"node-tone": "^1.0.1",
"nodemailer": "^6.9.2",
"openid-client": "^5.6.1",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"sequelize": "^6.32.1",
"socket.io": "^4.5.4",
"sqlite3": "^5.1.6",
@ -45,6 +55,10 @@
"xml2js": "^0.5.0"
},
"devDependencies": {
"nodemon": "^2.0.20"
"chai": "^4.3.10",
"mocha": "^10.2.0",
"nodemon": "^2.0.20",
"nyc": "^15.1.0",
"sinon": "^17.0.1"
}
}

View File

@ -1,32 +1,466 @@
const axios = require('axios')
const passport = require('passport')
const bcrypt = require('./libs/bcryptjs')
const jwt = require('./libs/jsonwebtoken')
const requestIp = require('./libs/requestIp')
const Logger = require('./Logger')
const LocalStrategy = require('./libs/passportLocal')
const JwtStrategy = require('passport-jwt').Strategy
const ExtractJwt = require('passport-jwt').ExtractJwt
const OpenIDClient = require('openid-client')
const Database = require('./Database')
const Logger = require('./Logger')
/**
* @class Class for handling all the authentication related functionality.
*/
class Auth {
constructor() { }
cors(req, res, next) {
res.header('Access-Control-Allow-Origin', '*')
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')
// TODO: Make sure allowing all headers is not a security concern. It is required for adding custom headers for SSO
// res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Accept-Encoding, Range, Authorization")
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
res.sendStatus(200)
constructor() {
}
/**
* Inializes all passportjs strategies and other passportjs ralated initialization.
*/
async initPassportJs() {
// Check if we should load the local strategy (username + password login)
if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
this.initAuthStrategyPassword()
}
// Check if we should load the openid strategy
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
this.initAuthStrategyOpenID()
}
// Load the JwtStrategy (always) -> for bearer token auth
passport.use(new JwtStrategy({
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
secretOrKey: Database.serverSettings.tokenSecret
}, this.jwtAuthCheck.bind(this)))
// define how to seralize a user (to be put into the session)
passport.serializeUser(function (user, cb) {
process.nextTick(function () {
// only store id to session
return cb(null, JSON.stringify({
id: user.id,
}))
})
})
// define how to deseralize a user (use the ID to get it from the database)
passport.deserializeUser((function (user, cb) {
process.nextTick((async function () {
const parsedUserInfo = JSON.parse(user)
// load the user by ID that is stored in the session
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
return cb(null, dbUser)
}).bind(this))
}).bind(this))
}
/**
* Passport use LocalStrategy
*/
initAuthStrategyPassword() {
passport.use(new LocalStrategy(this.localAuthCheckUserPw.bind(this)))
}
/**
* Passport use OpenIDClient.Strategy
*/
initAuthStrategyOpenID() {
if (!Database.serverSettings.isOpenIDAuthSettingsValid) {
Logger.error(`[Auth] Cannot init openid auth strategy - invalid settings`)
return
}
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
}).Client
const openIdClient = new openIdIssuerClient({
client_id: global.ServerSettings.authOpenIDClientID,
client_secret: global.ServerSettings.authOpenIDClientSecret
})
passport.use('openid-client', new OpenIDClient.Strategy({
client: openIdClient,
params: {
redirect_uri: '/auth/openid/callback',
scope: 'openid profile email'
}
}, async (tokenset, userinfo, done) => {
Logger.debug(`[Auth] openid callback userinfo=`, userinfo)
let failureMessage = 'Unauthorized'
if (!userinfo.sub) {
Logger.error(`[Auth] openid callback invalid userinfo, no sub`)
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
}
// permit login
return done(null, user)
}))
}
/**
* Unuse strategy
*
* @param {string} name
*/
unuseAuthStrategy(name) {
passport.unuse(name)
}
/**
* Use strategy
*
* @param {string} name
*/
useAuthStrategy(name) {
if (name === 'openid') {
this.initAuthStrategyOpenID()
} else if (name === 'local') {
this.initAuthStrategyPassword()
} else {
next()
Logger.error('[Auth] Invalid auth strategy ' + name)
}
}
/**
* Stores the client's choice how the login callback should happen in temp cookies
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
paramsToCookies(req, res) {
if (req.query.isRest?.toLowerCase() == 'true') {
// store the isRest flag to the is_rest cookie
res.cookie('is_rest', req.query.isRest.toLowerCase(), {
maxAge: 120000, // 2 min
httpOnly: true
})
} else {
// no isRest-flag set -> set is_rest cookie to false
res.cookie('is_rest', 'false', {
maxAge: 120000, // 2 min
httpOnly: true
})
// persist state if passed in
if (req.query.state) {
res.cookie('auth_state', req.query.state, {
maxAge: 120000, // 2 min
httpOnly: true
})
}
const callback = req.query.redirect_uri || req.query.callback
// check if we are missing a callback parameter - we need one if isRest=false
if (!callback) {
res.status(400).send({
message: 'No callback parameter'
})
return
}
// store the callback url to the auth_cb cookie
res.cookie('auth_cb', callback, {
maxAge: 120000, // 2 min
httpOnly: true
})
}
}
/**
* Informs the client in the right mode about a successfull login and the token
* (clients choise is restored from cookies).
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async handleLoginSuccessBasedOnCookie(req, res) {
// get userLogin json (information about the user, server and the session)
const data_json = await this.getUserLoginResponsePayload(req.user)
if (req.cookies.is_rest === 'true') {
// REST request - send data
res.json(data_json)
} else {
// UI request -> check if we have a callback url
// TODO: do we want to somehow limit the values for auth_cb?
if (req.cookies.auth_cb) {
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : ''
// UI request -> redirect to auth_cb url and send the jwt token as parameter
res.redirect(302, `${req.cookies.auth_cb}?setToken=${data_json.user.token}${stateQuery}`)
} else {
res.status(400).send('No callback or already expired')
}
}
}
/**
* Creates all (express) routes required for authentication.
*
* @param {import('express').Router} router
*/
async initAuthRoutes(router) {
// Local strategy login route (takes username and password)
router.post('/login', passport.authenticate('local'), async (req, res) => {
// return the user login response json if the login was successfull
res.json(await this.getUserLoginResponsePayload(req.user))
})
// openid strategy login route (this redirects to the configured openid login provider)
router.get('/auth/openid', (req, res, next) => {
try {
// helper function from openid-client
function pick(object, ...paths) {
const obj = {}
for (const path of paths) {
if (object[path] !== undefined) {
obj[path] = object[path]
}
}
return obj
}
// Get the OIDC client from the strategy
// We need to call the client manually, because the strategy does not support forwarding the code challenge
// for API or mobile clients
const oidcStrategy = passport._strategy('openid-client')
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
oidcStrategy._params.redirect_uri = new URL(`${protocol}://${req.get('host')}/auth/openid/callback`).toString()
Logger.debug(`[Auth] Set oidc redirect_uri=${oidcStrategy._params.redirect_uri}`)
const client = oidcStrategy._client
const sessionKey = oidcStrategy._key
let code_challenge
let code_challenge_method
// If code_challenge is provided, expect that code_verifier will be handled by the client (mobile app)
// The web frontend of ABS does not need to do a PKCE itself, because it never handles the "code" of the oauth flow
// and as such will not send a code challenge, we will generate then one
if (req.query.code_challenge) {
code_challenge = req.query.code_challenge
code_challenge_method = req.query.code_challenge_method || 'S256'
if (!['S256', 'plain'].includes(code_challenge_method)) {
return res.status(400).send('Invalid code_challenge_method')
}
} else {
// If no code_challenge is provided, assume a web application flow and generate one
const code_verifier = OpenIDClient.generators.codeVerifier()
code_challenge = OpenIDClient.generators.codeChallenge(code_verifier)
code_challenge_method = 'S256'
// Store the code_verifier in the session for later use in the token exchange
req.session[sessionKey] = { ...req.session[sessionKey], code_verifier }
}
const params = {
state: OpenIDClient.generators.random(),
// Other params by the passport strategy
...oidcStrategy._params
}
if (!params.nonce && params.response_type.includes('id_token')) {
params.nonce = OpenIDClient.generators.random()
}
req.session[sessionKey] = {
...req.session[sessionKey],
...pick(params, 'nonce', 'state', 'max_age', 'response_type'),
mobile: req.query.isRest?.toLowerCase() === 'true' // Used in the abs callback later
}
// Now get the URL to direct to
const authorizationUrl = client.authorizationUrl({
...params,
scope: 'openid profile email',
response_type: 'code',
code_challenge,
code_challenge_method,
})
// params (isRest, callback) to a cookie that will be send to the client
this.paramsToCookies(req, res)
// Redirect the user agent (browser) to the authorization URL
res.redirect(authorizationUrl)
} catch (error) {
Logger.error(`[Auth] Error in /auth/openid route: ${error}`)
res.status(500).send('Internal Server Error')
}
})
// openid strategy callback route (this receives the token from the configured openid login provider)
router.get('/auth/openid/callback', (req, res, next) => {
const oidcStrategy = passport._strategy('openid-client')
const sessionKey = oidcStrategy._key
if (!req.session[sessionKey]) {
return res.status(400).send('No session')
}
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
// Crucial for API/Mobile clients
if (req.query.code_verifier) {
req.session[sessionKey].code_verifier = req.query.code_verifier
}
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
if (req.session[sessionKey].mobile) {
return passport.authenticate('openid-client', { redirect_uri: 'audiobookshelf://oauth' })(req, res, next)
} else {
return passport.authenticate('openid-client', { failureRedirect: '/login?error=Unauthorized&autoLaunch=0' })(req, res, next)
}
},
// on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this))
/**
* Used to auto-populate the openid URLs in config/authentication
*/
router.get('/auth/openid/config', async (req, res) => {
if (!req.query.issuer) {
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
}
let issuerUrl = req.query.issuer
if (issuerUrl.endsWith('/')) issuerUrl = issuerUrl.slice(0, -1)
const configUrl = `${issuerUrl}/.well-known/openid-configuration`
axios.get(configUrl).then(({ data }) => {
res.json({
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
userinfo_endpoint: data.userinfo_endpoint,
end_session_endpoint: data.end_session_endpoint,
jwks_uri: data.jwks_uri
})
}).catch((error) => {
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
})
})
// Logout route
router.post('/logout', (req, res) => {
// TODO: invalidate possible JWTs
req.logout((err) => {
if (err) {
res.sendStatus(500)
} else {
res.sendStatus(200)
}
})
})
}
/**
* middleware to use in express to only allow authenticated users.
* @param {import('express').Request} req
* @param {import('express').Response} res
* @param {import('express').NextFunction} next
*/
isAuthenticated(req, res, next) {
// check if session cookie says that we are authenticated
if (req.isAuthenticated()) {
next()
} else {
// try JWT to authenticate
passport.authenticate("jwt")(req, res, next)
}
}
/**
* Function to generate a jwt token for a given user
*
* @param {{ id:string, username:string }} user
* @returns {string} token
*/
generateAccessToken(user) {
return jwt.sign({ userId: user.id, username: user.username }, global.ServerSettings.tokenSecret)
}
/**
* Function to validate a jwt token for a given user
*
* @param {string} token
* @returns {Object} tokens data
*/
static validateAccessToken(token) {
try {
return jwt.verify(token, global.ServerSettings.tokenSecret)
}
catch (err) {
return null
}
}
/**
* Generate a token which is used to encrpt/protect the jwts.
*/
async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret
Logger.debug(`[Auth] Setting token secret - using user passed in TOKEN_SECRET env var`)
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else {
Logger.debug(`[Auth] Setting token secret - using random bytes`)
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
}
await Database.updateServerSettings()
@ -35,47 +469,79 @@ class Auth {
const users = await Database.userModel.getOldUsers()
if (users.length) {
for (const user of users) {
user.token = await this.generateAccessToken({ userId: user.id, username: user.username })
Logger.warn(`[Auth] User ${user.username} api token has been updated using new token secret`)
user.token = await this.generateAccessToken(user)
}
await Database.updateBulkUsers(users)
}
}
async authMiddleware(req, res, next) {
var token = null
/**
* Checks if the user in the validated jwt_payload really exists and is active.
* @param {Object} jwt_payload
* @param {function} done
*/
async jwtAuthCheck(jwt_payload, done) {
// load user by id from the jwt token
const user = await Database.userModel.getUserByIdOrOldId(jwt_payload.userId)
// If using a get request, the token can be passed as a query string
if (req.method === 'GET' && req.query && req.query.token) {
token = req.query.token
} else {
const authHeader = req.headers['authorization']
token = authHeader && authHeader.split(' ')[1]
if (!user?.isActive) {
// deny login
done(null, null)
return
}
if (token == null) {
Logger.error('Api called without a token', req.path)
return res.sendStatus(401)
}
const user = await this.verifyToken(token)
if (!user) {
Logger.error('Verify Token User Not Found', token)
return res.sendStatus(404)
}
if (!user.isActive) {
Logger.error('Verify Token User is disabled', token, user.username)
return res.sendStatus(403)
}
req.user = user
next()
// approve login
done(null, user)
return
}
/**
* Checks if a username and password tuple is valid and the user active.
* @param {string} username
* @param {string} password
* @param {function} done
*/
async localAuthCheckUserPw(username, password, done) {
// Load the user given it's username
const user = await Database.userModel.getUserByUsername(username.toLowerCase())
if (!user || !user.isActive) {
done(null, null)
return
}
// Check passwordless root user
if (user.type === 'root' && (!user.pash || user.pash === '')) {
if (password) {
// deny login
done(null, null)
return
}
// approve login
done(null, user)
return
}
// Check password match
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
// approve login
done(null, user)
return
}
// deny login
done(null, null)
return
}
/**
* Hashes a password with bcrypt.
* @param {string} password
* @returns {string} hash
*/
hashPass(password) {
return new Promise((resolve) => {
bcrypt.hash(password, 8, (err, hash) => {
if (err) {
Logger.error('Hash failed', err)
resolve(null)
} else {
resolve(hash)
@ -84,36 +550,11 @@ class Auth {
})
}
generateAccessToken(payload) {
return jwt.sign(payload, Database.serverSettings.tokenSecret)
}
authenticateUser(token) {
return this.verifyToken(token)
}
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, Database.serverSettings.tokenSecret, async (err, payload) => {
if (!payload || err) {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
const user = await Database.userModel.getUserByIdOrOldId(payload.userId)
if (user && user.username === payload.username) {
resolve(user)
} else {
resolve(null)
}
})
})
}
/**
* Payload returned to a user after successful login
* @param {oldUser} user
* @returns {object}
* Return the login info payload for a user
*
* @param {Object} user
* @returns {Promise<Object>} jsonPayload
*/
async getUserLoginResponsePayload(user) {
const libraryIds = await Database.libraryModel.getAllLibraryIds()
@ -125,97 +566,6 @@ class Auth {
Source: global.Source
}
}
async login(req, res) {
const ipAddress = requestIp.getClientIp(req)
const username = (req.body.username || '').toLowerCase()
const password = req.body.password || ''
const user = await Database.userModel.getUserByUsername(username)
if (!user?.isActive) {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for username ${username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
// Check passwordless root user
if (user.type === 'root' && (!user.pash || user.pash === '')) {
if (password) {
return res.status(401).send('Invalid root password (hint: there is none)')
} else {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
return res.json(userLoginResponsePayload)
}
}
// Check password match
const compare = await bcrypt.compare(password, user.pash)
if (compare) {
Logger.info(`[Auth] ${user.username} logged in from ${ipAddress}`)
const userLoginResponsePayload = await this.getUserLoginResponsePayload(user)
res.json(userLoginResponsePayload)
} else {
Logger.warn(`[Auth] Failed login attempt ${req.rateLimit.current} of ${req.rateLimit.limit} from ${ipAddress}`)
if (req.rateLimit.remaining <= 2) {
Logger.error(`[Auth] Failed login attempt for user ${user.username} from ip ${ipAddress}. Attempts: ${req.rateLimit.current}`)
return res.status(401).send(`Invalid user or password (${req.rateLimit.remaining === 0 ? '1 attempt remaining' : `${req.rateLimit.remaining + 1} attempts remaining`})`)
}
return res.status(401).send('Invalid user or password')
}
}
comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false
return bcrypt.compare(password, user.pash)
}
async userChangePassword(req, res) {
var { password, newPassword } = req.body
newPassword = newPassword || ''
const matchingUser = await Database.userModel.getUserById(req.user.id)
// Only root can have an empty password
if (matchingUser.type !== 'root' && !newPassword) {
return res.json({
error: 'Invalid new password - Only root can have an empty password'
})
}
const compare = await this.comparePassword(password, matchingUser)
if (!compare) {
return res.json({
error: 'Invalid password'
})
}
let pw = ''
if (newPassword) {
pw = await this.hashPass(newPassword)
if (!pw) {
return res.json({
error: 'Hash failed'
})
}
}
matchingUser.pash = pw
const success = await Database.updateUser(matchingUser)
if (success) {
res.json({
success: true
})
} else {
res.json({
error: 'Unknown error'
})
}
}
}
module.exports = Auth

View File

@ -11,7 +11,7 @@ class Logger {
}
get timestamp() {
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss')
return date.format(new Date(), 'YYYY-MM-DD HH:mm:ss.SSS')
}
get levelString() {

View File

@ -5,6 +5,7 @@ const http = require('http')
const fs = require('./libs/fsExtra')
const fileUpload = require('./libs/expressFileupload')
const rateLimit = require('./libs/expressRateLimit')
const cookieParser = require("cookie-parser")
const { version } = require('../package.json')
@ -35,6 +36,11 @@ const ApiCacheManager = require('./managers/ApiCacheManager')
const LibraryScanner = require('./scanner/LibraryScanner')
const { measureMiddleware } = require('./utils/timing')
//Import the main Passport and Express-Session library
const passport = require('passport')
const expressSession = require('express-session')
class Server {
constructor(SOURCE, PORT, HOST, UID, GID, CONFIG_PATH, METADATA_PATH, ROUTER_BASE_PATH) {
this.Port = PORT
@ -82,7 +88,8 @@ class Server {
}
authMiddleware(req, res, next) {
this.auth.authMiddleware(req, res, next)
// ask passportjs if the current request is authenticated
this.auth.isAuthenticated(req, res, next)
}
cancelLibraryScan(libraryId) {
@ -128,6 +135,50 @@ class Server {
await this.init()
const app = express()
/**
* @temporary
* This is necessary for the ebook API endpoint in the mobile apps
* The mobile app ereader is using fetch api in Capacitor that is currently difficult to switch to native requests
* so we have to allow cors for specific origins to the /api/items/:id/ebook endpoint
* @see https://ionicframework.com/docs/troubleshooting/cors
*/
app.use((req, res, next) => {
if (req.path.match(/\/api\/items\/([a-z0-9-]{36})\/ebook(\/[0-9]+)?/)) {
const allowedOrigins = ['capacitor://localhost', 'http://localhost']
if (allowedOrigins.some(o => o === req.get('origin'))) {
res.header('Access-Control-Allow-Origin', req.get('origin'))
res.header("Access-Control-Allow-Methods", 'GET, POST, PATCH, PUT, DELETE, OPTIONS')
res.header('Access-Control-Allow-Headers', '*')
res.header('Access-Control-Allow-Credentials', true)
if (req.method === 'OPTIONS') {
return res.sendStatus(200)
}
}
}
next()
})
// parse cookies in requests
app.use(cookieParser())
// enable express-session
app.use(expressSession({
secret: global.ServerSettings.tokenSecret,
resave: false,
saveUninitialized: false,
cookie: {
// also send the cookie if were are not on https (not every use has https)
secure: false
},
}))
// init passport.js
app.use(passport.initialize())
// register passport in express-session
app.use(passport.session())
// config passport.js
await this.auth.initPassportJs()
const router = express.Router()
app.use(global.RouterBasePath, router)
app.disable('x-powered-by')
@ -135,14 +186,13 @@ class Server {
this.server = http.createServer(app)
router.use(measureMiddleware)
router.use(this.auth.cors)
router.use(fileUpload({
defCharset: 'utf8',
defParamCharset: 'utf8',
useTempFiles: true,
tempFileDir: Path.join(global.MetadataPath, 'tmp')
}))
router.use(express.urlencoded({ extended: true, limit: "5mb" }));
router.use(express.urlencoded({ extended: true, limit: "5mb" }))
router.use(express.json({ limit: "5mb" }))
// Static path to generated nuxt
@ -168,6 +218,9 @@ class Server {
this.rssFeedManager.getFeedItem(req, res)
})
// Auth routes
await this.auth.initAuthRoutes(router)
// Client dynamic routes
const dyanimicRoutes = [
'/item/:id',
@ -191,8 +244,8 @@ class Server {
]
dyanimicRoutes.forEach((route) => router.get(route, (req, res) => res.sendFile(Path.join(distPath, 'index.html'))))
router.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res))
router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
// router.post('/login', passport.authenticate('local', this.auth.login), this.auth.loginResult.bind(this))
// router.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this))
router.post('/init', (req, res) => {
if (Database.hasRootUser) {
Logger.error(`[Server] attempt to init server when server already has a root user`)
@ -204,8 +257,12 @@ class Server {
// status check for client to see if server has been initialized
// server has been initialized if a root user exists
const payload = {
app: 'audiobookshelf',
serverVersion: version,
isInit: Database.hasRootUser,
language: Database.serverSettings.language
language: Database.serverSettings.language,
authMethods: Database.serverSettings.authActiveAuthMethods,
authFormData: Database.serverSettings.authFormData
}
if (!payload.isInit) {
payload.ConfigPath = global.ConfigPath

View File

@ -1,6 +1,7 @@
const SocketIO = require('socket.io')
const Logger = require('./Logger')
const Database = require('./Database')
const Auth = require('./Auth')
class SocketAuthority {
constructor() {
@ -81,6 +82,7 @@ class SocketAuthority {
methods: ["GET", "POST"]
}
})
this.io.on('connection', (socket) => {
this.clients[socket.id] = {
id: socket.id,
@ -144,14 +146,31 @@ 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
/**
* 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
*
* @param {SocketIO.Socket} socket
* @param {string} token JWT
*/
async authenticateSocket(socket, token) {
const user = await this.Server.auth.authenticateUser(token)
if (!user) {
// 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)
if (!token_data?.userId) {
// Token invalid
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
// get the user via the id from the decoded jwt.
const user = await Database.userModel.getUserByIdOrOldId(token_data.userId)
if (!user) {
// user not found
Logger.error('Cannot validate socket - invalid token')
return socket.emit('invalid_token')
}
const client = this.clients[socket.id]
if (!client) {
Logger.error(`[SocketAuthority] Socket for user ${user.username} has no client`)

View File

@ -119,8 +119,9 @@ class MiscController {
/**
* PATCH: /api/settings
* Update server settings
* @param {*} req
* @param {*} res
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async updateServerSettings(req, res) {
if (!req.user.isAdminOrUp) {
@ -128,7 +129,7 @@ class MiscController {
return res.sendStatus(403)
}
const settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid settings update object')
}
@ -248,8 +249,8 @@ class MiscController {
* POST: /api/authorize
* Used to authorize an API token
*
* @param {*} req
* @param {*} res
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async authorize(req, res) {
if (!req.user) {
@ -555,10 +556,10 @@ class MiscController {
switch (type) {
case 'add':
this.watcher.onFileAdded(libraryId, path)
break;
break
case 'unlink':
this.watcher.onFileRemoved(libraryId, path)
break;
break
case 'rename':
const oldPath = req.body.oldPath
if (!oldPath) {
@ -566,7 +567,7 @@ class MiscController {
return res.sendStatus(400)
}
this.watcher.onFileRename(libraryId, oldPath, path)
break;
break
default:
Logger.error(`[MiscController] Invalid type for updateWatchedPath. type: "${type}"`)
return res.sendStatus(400)
@ -589,5 +590,105 @@ class MiscController {
res.status(400).send(error.message)
}
}
/**
* GET: api/auth-settings (admin only)
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
getAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get auth settings`)
return res.sendStatus(403)
}
return res.json(Database.serverSettings.authenticationSettings)
}
/**
* PATCH: api/auth-settings
* @this import('../routers/ApiRouter')
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async updateAuthSettings(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to update auth settings`)
return res.sendStatus(403)
}
const settingsUpdate = req.body
if (!isObject(settingsUpdate)) {
return res.status(400).send('Invalid auth settings update object')
}
let hasUpdates = false
const currentAuthenticationSettings = Database.serverSettings.authenticationSettings
const originalAuthMethods = [...currentAuthenticationSettings.authActiveAuthMethods]
// TODO: Better validation of auth settings once auth settings are separated from server settings
for (const key in currentAuthenticationSettings) {
if (settingsUpdate[key] === undefined) continue
if (key === 'authActiveAuthMethods') {
let updatedAuthMethods = settingsUpdate[key]?.filter?.((authMeth) => Database.serverSettings.supportedAuthMethods.includes(authMeth))
if (Array.isArray(updatedAuthMethods) && updatedAuthMethods.length) {
updatedAuthMethods.sort()
currentAuthenticationSettings[key].sort()
if (updatedAuthMethods.join() !== currentAuthenticationSettings[key].join()) {
Logger.debug(`[MiscController] Updating auth settings key "authActiveAuthMethods" from "${currentAuthenticationSettings[key].join()}" to "${updatedAuthMethods.join()}"`)
Database.serverSettings[key] = updatedAuthMethods
hasUpdates = true
}
} else {
Logger.warn(`[MiscController] Invalid value for authActiveAuthMethods`)
}
} else {
const updatedValueType = typeof settingsUpdate[key]
if (['authOpenIDAutoLaunch', 'authOpenIDAutoRegister'].includes(key)) {
if (updatedValueType !== 'boolean') {
Logger.warn(`[MiscController] Invalid value for ${key}. Expected boolean`)
continue
}
} else if (settingsUpdate[key] !== null && updatedValueType !== 'string') {
Logger.warn(`[MiscController] Invalid value for ${key}. Expected string or null`)
continue
}
let updatedValue = settingsUpdate[key]
if (updatedValue === '') updatedValue = null
let currentValue = currentAuthenticationSettings[key]
if (currentValue === '') currentValue = null
if (updatedValue !== currentValue) {
Logger.debug(`[MiscController] Updating auth settings key "${key}" from "${currentValue}" to "${updatedValue}"`)
Database.serverSettings[key] = updatedValue
hasUpdates = true
}
}
}
if (hasUpdates) {
await Database.updateServerSettings()
// Use/unuse auth methods
Database.serverSettings.supportedAuthMethods.forEach((authMethod) => {
if (originalAuthMethods.includes(authMethod) && !Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
// Auth method has been removed
Logger.info(`[MiscController] Disabling active auth method "${authMethod}"`)
this.auth.unuseAuthStrategy(authMethod)
} else if (!originalAuthMethods.includes(authMethod) && Database.serverSettings.authActiveAuthMethods.includes(authMethod)) {
// Auth method has been added
Logger.info(`[MiscController] Enabling active auth method "${authMethod}"`)
this.auth.useAuthStrategy(authMethod)
}
})
}
res.json({
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
}
module.exports = new MiscController()

View File

@ -6,7 +6,7 @@ class SessionController {
constructor() { }
async findOne(req, res) {
return res.json(req.session)
return res.json(req.playbackSession)
}
async getAllWithUserData(req, res) {
@ -63,32 +63,32 @@ class SessionController {
}
async getOpenSession(req, res) {
const libraryItem = await Database.libraryItemModel.getOldById(req.session.libraryItemId)
const sessionForClient = req.session.toJSONForClient(libraryItem)
const libraryItem = await Database.libraryItemModel.getOldById(req.playbackSession.libraryItemId)
const sessionForClient = req.playbackSession.toJSONForClient(libraryItem)
res.json(sessionForClient)
}
// POST: api/session/:id/sync
sync(req, res) {
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)
this.playbackSessionManager.syncSessionRequest(req.user, req.playbackSession, req.body, res)
}
// POST: api/session/:id/close
close(req, res) {
let syncData = req.body
if (syncData && !Object.keys(syncData).length) syncData = null
this.playbackSessionManager.closeSessionRequest(req.user, req.session, syncData, res)
this.playbackSessionManager.closeSessionRequest(req.user, req.playbackSession, syncData, res)
}
// DELETE: api/session/:id
async delete(req, res) {
// if session is open then remove it
const openSession = this.playbackSessionManager.getSession(req.session.id)
const openSession = this.playbackSessionManager.getSession(req.playbackSession.id)
if (openSession) {
await this.playbackSessionManager.removeSession(req.session.id)
await this.playbackSessionManager.removeSession(req.playbackSession.id)
}
await Database.removePlaybackSession(req.session.id)
await Database.removePlaybackSession(req.playbackSession.id)
res.sendStatus(200)
}
@ -111,7 +111,7 @@ class SessionController {
return res.sendStatus(404)
}
req.session = playbackSession
req.playbackSession = playbackSession
next()
}
@ -130,7 +130,7 @@ class SessionController {
return res.sendStatus(403)
}
req.session = playbackSession
req.playbackSession = playbackSession
next()
}
}

View File

@ -100,7 +100,7 @@ class UserController {
account.id = uuidv4()
account.pash = await this.auth.hashPass(account.password)
delete account.password
account.token = await this.auth.generateAccessToken({ userId: account.id, username })
account.token = await this.auth.generateAccessToken(account)
account.createdAt = Date.now()
const newUser = new User(account)
@ -150,7 +150,7 @@ class UserController {
if (user.update(account)) {
if (shouldUpdateToken) {
user.token = await this.auth.generateAccessToken({ userId: user.id, username: user.username })
user.token = await this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} was generated a new api token`)
}
await Database.updateUser(user)

View File

@ -31,52 +31,11 @@ class BookFinder {
return book
}
stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
return title
}
replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
}
}
cleanTitleForCompares(title) {
if (!title) return ''
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
let stripped = this.stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
return this.replaceAccentedChars(cleaned).toLowerCase()
}
cleanAuthorForCompares(author) {
if (!author) return ''
let cleanAuthor = this.replaceAccentedChars(author).toLowerCase()
// separate initials
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
// remove middle initials
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
return cleanAuthor
}
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title)
var searchAuthor = this.cleanAuthorForCompares(author)
var searchTitle = cleanTitleForCompares(title)
var searchAuthor = cleanAuthorForCompares(author)
return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title)
b.cleanedTitle = cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
// Total length of search (title or both title & author)
@ -87,7 +46,7 @@ class BookFinder {
b.authorDistance = author.length
} else {
b.totalPossibleDistance += b.author.length
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
b.cleanedAuthor = cleanAuthorForCompares(b.author)
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author)
@ -190,20 +149,17 @@ class BookFinder {
static TitleCandidates = class {
constructor(bookFinder, cleanAuthor) {
this.bookFinder = bookFinder
constructor(cleanAuthor) {
this.candidates = new Set()
this.cleanAuthor = cleanAuthor
this.priorities = {}
this.positions = {}
this.currentPosition = 0
}
add(title, position = 0) {
add(title) {
// if title contains the author, remove it
if (this.cleanAuthor) {
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
title = this.bookFinder.cleanAuthorForCompares(title).replace(authorRe, '').trim()
}
title = this.#removeAuthorFromTitle(title)
const titleTransformers = [
[/([,:;_]| by ).*/g, ''], // Remove subtitle
@ -215,11 +171,11 @@ class BookFinder {
]
// Main variant
const cleanTitle = this.bookFinder.cleanTitleForCompares(title).trim()
const cleanTitle = cleanTitleForCompares(title).trim()
if (!cleanTitle) return
this.candidates.add(cleanTitle)
this.priorities[cleanTitle] = 0
this.positions[cleanTitle] = position
this.positions[cleanTitle] = this.currentPosition
let candidate = cleanTitle
@ -230,10 +186,11 @@ class BookFinder {
if (candidate) {
this.candidates.add(candidate)
this.priorities[candidate] = 0
this.positions[candidate] = position
this.positions[candidate] = this.currentPosition
}
this.priorities[cleanTitle] = 1
}
this.currentPosition++
}
get size() {
@ -243,23 +200,16 @@ class BookFinder {
getCandidates() {
var candidates = [...this.candidates]
candidates.sort((a, b) => {
// Candidates that include the author are likely low quality
const includesAuthorDiff = !b.includes(this.cleanAuthor) - !a.includes(this.cleanAuthor)
if (includesAuthorDiff) return includesAuthorDiff
// Candidates that include only digits are also likely low quality
const onlyDigits = /^\d+$/
const includesOnlyDigitsDiff = !onlyDigits.test(b) - !onlyDigits.test(a)
const includesOnlyDigitsDiff = onlyDigits.test(a) - onlyDigits.test(b)
if (includesOnlyDigitsDiff) return includesOnlyDigitsDiff
// transformed candidates receive higher priority
const priorityDiff = this.priorities[a] - this.priorities[b]
if (priorityDiff) return priorityDiff
// if same priorirty, prefer candidates that are closer to the beginning (e.g. titles before subtitles)
const positionDiff = this.positions[a] - this.positions[b]
if (positionDiff) return positionDiff
// Start with longer candidaets, as they are likely more specific
const lengthDiff = b.length - a.length
if (lengthDiff) return lengthDiff
return b.localeCompare(a)
return positionDiff // candidates with same priority always have different positions
})
Logger.debug(`[${this.constructor.name}] Found ${candidates.length} fuzzy title candidates`)
Logger.debug(candidates)
@ -269,21 +219,32 @@ class BookFinder {
delete(title) {
return this.candidates.delete(title)
}
#removeAuthorFromTitle(title) {
if (!this.cleanAuthor) return title
const authorRe = new RegExp(`(^| | by |)${escapeRegExp(this.cleanAuthor)}(?= |$)`, "g")
const authorCleanedTitle = cleanAuthorForCompares(title)
const authorCleanedTitleWithoutAuthor = authorCleanedTitle.replace(authorRe, '')
if (authorCleanedTitleWithoutAuthor !== authorCleanedTitle) {
return authorCleanedTitleWithoutAuthor.trim()
}
return title
}
}
static AuthorCandidates = class {
constructor(bookFinder, cleanAuthor) {
this.bookFinder = bookFinder
constructor(cleanAuthor, audnexus) {
this.audnexus = audnexus
this.candidates = new Set()
this.cleanAuthor = cleanAuthor
if (cleanAuthor) this.candidates.add(cleanAuthor)
}
validateAuthor(name, region = '', maxLevenshtein = 2) {
return this.bookFinder.audnexus.authorASINsRequest(name, region).then((asins) => {
return this.audnexus.authorASINsRequest(name, region).then((asins) => {
for (const [i, asin] of asins.entries()) {
if (i > 10) break
let cleanName = this.bookFinder.cleanAuthorForCompares(asin.name)
let cleanName = cleanAuthorForCompares(asin.name)
if (!cleanName) continue
if (cleanName.includes(name)) return name
if (name.includes(cleanName)) return cleanName
@ -294,7 +255,7 @@ class BookFinder {
}
add(author) {
const cleanAuthor = this.bookFinder.cleanAuthorForCompares(author).trim()
const cleanAuthor = cleanAuthorForCompares(author).trim()
if (!cleanAuthor) return
this.candidates.add(cleanAuthor)
}
@ -362,10 +323,10 @@ class BookFinder {
title = title.trim().toLowerCase()
author = author?.trim().toLowerCase() || ''
const cleanAuthor = this.cleanAuthorForCompares(author)
const cleanAuthor = cleanAuthorForCompares(author)
// Now run up to maxFuzzySearches fuzzy searches
let authorCandidates = new BookFinder.AuthorCandidates(this, cleanAuthor)
let authorCandidates = new BookFinder.AuthorCandidates(cleanAuthor, this.audnexus)
// Remove underscores and parentheses with their contents, and replace with a separator
const cleanTitle = title.replace(/\[.*?\]|\(.*?\)|{.*?}|_/g, " - ")
@ -375,9 +336,9 @@ class BookFinder {
authorCandidates.add(titlePart)
authorCandidates = await authorCandidates.getCandidates()
for (const authorCandidate of authorCandidates) {
let titleCandidates = new BookFinder.TitleCandidates(this, authorCandidate)
for (const [position, titlePart] of titleParts.entries())
titleCandidates.add(titlePart, position)
let titleCandidates = new BookFinder.TitleCandidates(authorCandidate)
for (const titlePart of titleParts)
titleCandidates.add(titlePart)
titleCandidates = titleCandidates.getCandidates()
for (const titleCandidate of titleCandidates) {
if (titleCandidate == title && authorCandidate == author) continue // We already tried this
@ -457,3 +418,52 @@ class BookFinder {
}
}
module.exports = new BookFinder()
function stripSubtitle(title) {
if (title.includes(':')) {
return title.split(':')[0].trim()
} else if (title.includes(' - ')) {
return title.split(' - ')[0].trim()
}
return title
}
function replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
}
}
function cleanTitleForCompares(title) {
if (!title) return ''
title = stripRedundantSpaces(title)
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
let stripped = stripSubtitle(title)
// Remove text in paranthesis (i.e. "Ender's Game (Ender's Saga)" becomes "Ender's Game")
let cleaned = stripped.replace(/ *\([^)]*\) */g, "")
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
return replaceAccentedChars(cleaned).toLowerCase()
}
function cleanAuthorForCompares(author) {
if (!author) return ''
author = stripRedundantSpaces(author)
let cleanAuthor = replaceAccentedChars(author).toLowerCase()
// separate initials
cleanAuthor = cleanAuthor.replace(/([a-z])\.([a-z])/g, '$1. $2')
// remove middle initials
cleanAuthor = cleanAuthor.replace(/(?<=\w\w)(\s+[a-z]\.?)+(?=\s+\w\w)/g, '')
return cleanAuthor
}
function stripRedundantSpaces(str) {
return str.replace(/\s+/g, ' ').trim()
}

View File

@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2011-2014 Jared Hanson
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,20 @@
//
// modified for audiobookshelf
// Source: https://github.com/jaredhanson/passport-local
//
/**
* Module dependencies.
*/
var Strategy = require('./strategy');
/**
* Expose `Strategy` directly from package.
*/
exports = module.exports = Strategy;
/**
* Export constructors.
*/
exports.Strategy = Strategy;

View File

@ -0,0 +1,119 @@
/**
* Module dependencies.
*/
const passport = require('passport-strategy')
const util = require('util')
function lookup(obj, field) {
if (!obj) { return null; }
var chain = field.split(']').join('').split('[');
for (var i = 0, len = chain.length; i < len; i++) {
var prop = obj[chain[i]];
if (typeof (prop) === 'undefined') { return null; }
if (typeof (prop) !== 'object') { return prop; }
obj = prop;
}
return null;
}
/**
* `Strategy` constructor.
*
* The local authentication strategy authenticates requests based on the
* credentials submitted through an HTML-based login form.
*
* Applications must supply a `verify` callback which accepts `username` and
* `password` credentials, and then calls the `done` callback supplying a
* `user`, which should be set to `false` if the credentials are not valid.
* If an exception occured, `err` should be set.
*
* Optionally, `options` can be used to change the fields in which the
* credentials are found.
*
* Options:
* - `usernameField` field name where the username is found, defaults to _username_
* - `passwordField` field name where the password is found, defaults to _password_
* - `passReqToCallback` when `true`, `req` is the first argument to the verify callback (default: `false`)
*
* Examples:
*
* passport.use(new LocalStrategy(
* function(username, password, done) {
* User.findOne({ username: username, password: password }, function (err, user) {
* done(err, user);
* });
* }
* ));
*
* @param {Object} options
* @param {Function} verify
* @api public
*/
function Strategy(options, verify) {
if (typeof options == 'function') {
verify = options;
options = {};
}
if (!verify) { throw new TypeError('LocalStrategy requires a verify callback'); }
this._usernameField = options.usernameField || 'username';
this._passwordField = options.passwordField || 'password';
passport.Strategy.call(this);
this.name = 'local';
this._verify = verify;
this._passReqToCallback = options.passReqToCallback;
}
/**
* Inherit from `passport.Strategy`.
*/
util.inherits(Strategy, passport.Strategy);
/**
* Authenticate request based on the contents of a form submission.
*
* @param {Object} req
* @api protected
*/
Strategy.prototype.authenticate = function (req, options) {
options = options || {};
var username = lookup(req.body, this._usernameField)
if (username === null) {
lookup(req.query, this._usernameField);
}
var password = lookup(req.body, this._passwordField)
if (password === null) {
password = lookup(req.query, this._passwordField);
}
if (username === null || password === null) {
return this.fail({ message: options.badRequestMessage || 'Missing credentials' }, 400);
}
var self = this;
function verified(err, user, info) {
if (err) { return self.error(err); }
if (!user) { return self.fail(info); }
self.success(user, info);
}
try {
if (self._passReqToCallback) {
this._verify(req, username, password, verified);
} else {
this._verify(username, password, verified);
}
} catch (ex) {
return self.error(ex);
}
};
/**
* Expose `Strategy`.
*/
module.exports = Strategy;

View File

@ -1,7 +1,9 @@
const uuidv4 = require("uuid").v4
const { DataTypes, Model, Op } = require('sequelize')
const sequelize = require('sequelize')
const Logger = require('../Logger')
const oldUser = require('../objects/user/User')
const SocketAuthority = require('../SocketAuthority')
const { DataTypes, Model } = sequelize
class User extends Model {
constructor(values, options) {
@ -46,6 +48,12 @@ class User extends Model {
return users.map(u => this.getOldUser(u))
}
/**
* Get old user model from new
*
* @param {Object} userExpanded
* @returns {oldUser}
*/
static getOldUser(userExpanded) {
const mediaProgress = userExpanded.mediaProgresses.map(mp => mp.getOldMediaProgress())
@ -72,15 +80,27 @@ class User extends Model {
createdAt: userExpanded.createdAt.valueOf(),
permissions,
librariesAccessible,
itemTagsSelected
itemTagsSelected,
authOpenIDSub: userExpanded.extraData?.authOpenIDSub || null
})
}
/**
*
* @param {oldUser} oldUser
* @returns {Promise<User>}
*/
static createFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.create(user)
}
/**
* Update User from old user model
*
* @param {oldUser} oldUser
* @returns {Promise<boolean>}
*/
static updateFromOld(oldUser) {
const user = this.getFromOld(oldUser)
return this.update(user, {
@ -93,7 +113,21 @@ class User extends Model {
})
}
/**
* Get new User model from old
*
* @param {oldUser} oldUser
* @returns {Object}
*/
static getFromOld(oldUser) {
const extraData = {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
}
if (oldUser.authOpenIDSub) {
extraData.authOpenIDSub = oldUser.authOpenIDSub
}
return {
id: oldUser.id,
username: oldUser.username,
@ -103,10 +137,7 @@ class User extends Model {
token: oldUser.token || null,
isActive: !!oldUser.isActive,
lastSeen: oldUser.lastSeen || null,
extraData: {
seriesHideFromContinueListening: oldUser.seriesHideFromContinueListening || [],
oldUserId: oldUser.oldUserId
},
extraData,
createdAt: oldUser.createdAt || Date.now(),
permissions: {
...oldUser.permissions,
@ -130,12 +161,12 @@ class User extends Model {
* @param {string} username
* @param {string} pash
* @param {Auth} auth
* @returns {oldUser}
* @returns {Promise<oldUser>}
*/
static async createRootUser(username, pash, auth) {
const userId = uuidv4()
const token = await auth.generateAccessToken({ userId, username })
const token = await auth.generateAccessToken({ id: userId, username })
const newRoot = new oldUser({
id: userId,
@ -150,6 +181,38 @@ class User extends Model {
return newRoot
}
/**
* Create user from openid userinfo
* @param {Object} userinfo
* @param {Auth} auth
* @returns {Promise<oldUser>}
*/
static async createUserFromOpenIdUserInfo(userinfo, auth) {
const userId = uuidv4()
// TODO: Ensure username is unique?
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 newUser = new oldUser({
id: userId,
type: 'user',
username,
email,
pash: null,
token,
isActive: true,
authOpenIDSub: userinfo.sub,
createdAt: Date.now()
})
if (await this.createFromOld(newUser)) {
SocketAuthority.adminEmitter('user_added', newUser.toJSONForBrowser())
return newUser
}
return null
}
/**
* Get a user by id or by the old database id
* @temp User ids were updated in v2.3.0 migration and old API tokens may still use that id
@ -160,13 +223,13 @@ class User extends Model {
if (!userId) return null
const user = await this.findOne({
where: {
[Op.or]: [
[sequelize.Op.or]: [
{
id: userId
},
{
extraData: {
[Op.substring]: userId
[sequelize.Op.substring]: userId
}
}
]
@ -187,7 +250,26 @@ class User extends Model {
const user = await this.findOne({
where: {
username: {
[Op.like]: username
[sequelize.Op.like]: username
}
},
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get user by email case insensitive
* @param {string} username
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByEmail(email) {
if (!email) return null
const user = await this.findOne({
where: {
email: {
[sequelize.Op.like]: email
}
},
include: this.sequelize.models.mediaProgress
@ -210,6 +292,21 @@ class User extends Model {
return this.getOldUser(user)
}
/**
* Get user by openid sub
* @param {string} sub
* @returns {Promise<oldUser|null>} returns null if not found
*/
static async getUserByOpenIDSub(sub) {
if (!sub) return null
const user = await this.findOne({
where: sequelize.where(sequelize.literal(`extraData->>"authOpenIDSub"`), sub),
include: this.sequelize.models.mediaProgress
})
if (!user) return null
return this.getOldUser(user)
}
/**
* Get array of user id and username
* @returns {object[]} { id, username }

View File

@ -54,6 +54,24 @@ class ServerSettings {
this.version = packageJson.version
this.buildNumber = packageJson.buildNumber
// Auth settings
// Active auth methodes
this.authActiveAuthMethods = ['local']
// openid settings
this.authOpenIDIssuerURL = null
this.authOpenIDAuthorizationURL = null
this.authOpenIDTokenURL = null
this.authOpenIDUserInfoURL = null
this.authOpenIDJwksURL = null
this.authOpenIDLogoutURL = null
this.authOpenIDClientID = null
this.authOpenIDClientSecret = null
this.authOpenIDButtonText = 'Login with OpenId'
this.authOpenIDAutoLaunch = false
this.authOpenIDAutoRegister = false
this.authOpenIDMatchExistingBy = null
if (settings) {
this.construct(settings)
}
@ -94,6 +112,36 @@ class ServerSettings {
this.version = settings.version || null
this.buildNumber = settings.buildNumber || 0 // Added v2.4.5
this.authActiveAuthMethods = settings.authActiveAuthMethods || ['local']
this.authOpenIDIssuerURL = settings.authOpenIDIssuerURL || null
this.authOpenIDAuthorizationURL = settings.authOpenIDAuthorizationURL || null
this.authOpenIDTokenURL = settings.authOpenIDTokenURL || null
this.authOpenIDUserInfoURL = settings.authOpenIDUserInfoURL || null
this.authOpenIDJwksURL = settings.authOpenIDJwksURL || null
this.authOpenIDLogoutURL = settings.authOpenIDLogoutURL || null
this.authOpenIDClientID = settings.authOpenIDClientID || null
this.authOpenIDClientSecret = settings.authOpenIDClientSecret || null
this.authOpenIDButtonText = settings.authOpenIDButtonText || 'Login with OpenId'
this.authOpenIDAutoLaunch = !!settings.authOpenIDAutoLaunch
this.authOpenIDAutoRegister = !!settings.authOpenIDAutoRegister
this.authOpenIDMatchExistingBy = settings.authOpenIDMatchExistingBy || null
if (!Array.isArray(this.authActiveAuthMethods)) {
this.authActiveAuthMethods = ['local']
}
// remove uninitialized methods
// OpenID
if (this.authActiveAuthMethods.includes('openid') && !this.isOpenIDAuthSettingsValid) {
this.authActiveAuthMethods.splice(this.authActiveAuthMethods.indexOf('openid', 0), 1)
}
// fallback to local
if (!Array.isArray(this.authActiveAuthMethods) || this.authActiveAuthMethods.length == 0) {
this.authActiveAuthMethods = ['local']
}
// Migrations
if (settings.storeCoverWithBook != undefined) { // storeCoverWithBook was renamed to storeCoverWithItem in 2.0.0
this.storeCoverWithItem = !!settings.storeCoverWithBook
@ -150,23 +198,96 @@ class ServerSettings {
language: this.language,
logLevel: this.logLevel,
version: this.version,
buildNumber: this.buildNumber
buildNumber: this.buildNumber,
authActiveAuthMethods: this.authActiveAuthMethods,
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDJwksURL: this.authOpenIDJwksURL,
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
}
}
toJSONForBrowser() {
const json = this.toJSON()
delete json.tokenSecret
delete json.authOpenIDClientID
delete json.authOpenIDClientSecret
return json
}
get supportedAuthMethods() {
return ['local', 'openid']
}
/**
* Auth settings required for openid to be valid
*/
get isOpenIDAuthSettingsValid() {
return this.authOpenIDIssuerURL &&
this.authOpenIDAuthorizationURL &&
this.authOpenIDTokenURL &&
this.authOpenIDUserInfoURL &&
this.authOpenIDJwksURL &&
this.authOpenIDClientID &&
this.authOpenIDClientSecret
}
get authenticationSettings() {
return {
authActiveAuthMethods: this.authActiveAuthMethods,
authOpenIDIssuerURL: this.authOpenIDIssuerURL,
authOpenIDAuthorizationURL: this.authOpenIDAuthorizationURL,
authOpenIDTokenURL: this.authOpenIDTokenURL,
authOpenIDUserInfoURL: this.authOpenIDUserInfoURL,
authOpenIDJwksURL: this.authOpenIDJwksURL,
authOpenIDLogoutURL: this.authOpenIDLogoutURL,
authOpenIDClientID: this.authOpenIDClientID, // Do not return to client
authOpenIDClientSecret: this.authOpenIDClientSecret, // Do not return to client
authOpenIDButtonText: this.authOpenIDButtonText,
authOpenIDAutoLaunch: this.authOpenIDAutoLaunch,
authOpenIDAutoRegister: this.authOpenIDAutoRegister,
authOpenIDMatchExistingBy: this.authOpenIDMatchExistingBy
}
}
get authFormData() {
const clientFormData = {}
if (this.authActiveAuthMethods.includes('openid')) {
clientFormData.authOpenIDButtonText = this.authOpenIDButtonText
clientFormData.authOpenIDAutoLaunch = this.authOpenIDAutoLaunch
}
return clientFormData
}
/**
* Update server settings
*
* @param {Object} payload
* @returns {boolean} true if updates were made
*/
update(payload) {
var hasUpdates = false
let hasUpdates = false
for (const key in payload) {
if (key === 'sortingPrefixes' && payload[key] && payload[key].length) {
var prefixesCleaned = payload[key].filter(prefix => !!prefix).map(prefix => prefix.toLowerCase())
if (prefixesCleaned.join(',') !== this[key].join(',')) {
this[key] = [...prefixesCleaned]
if (key === 'sortingPrefixes') {
// Sorting prefixes are updated with the /api/sorting-prefixes endpoint
continue
} else if (key === 'authActiveAuthMethods') {
if (!payload[key]?.length) {
Logger.error(`[ServerSettings] Invalid authActiveAuthMethods`, payload[key])
continue
}
this.authActiveAuthMethods.sort()
payload[key].sort()
if (payload[key].join() !== this.authActiveAuthMethods.join()) {
this.authActiveAuthMethods = payload[key]
hasUpdates = true
}
} else if (this[key] !== payload[key]) {

View File

@ -24,6 +24,8 @@ class User {
this.librariesAccessible = [] // Library IDs (Empty if ALL libraries)
this.itemTagsSelected = [] // Empty if ALL item tags accessible
this.authOpenIDSub = null
if (user) {
this.construct(user)
}
@ -66,7 +68,7 @@ class User {
getDefaultUserPermissions() {
return {
download: true,
update: true,
update: this.type === 'root' || this.type === 'admin',
delete: this.type === 'root',
upload: this.type === 'root' || this.type === 'admin',
accessAllLibraries: true,
@ -93,7 +95,8 @@ class User {
createdAt: this.createdAt,
permissions: this.permissions,
librariesAccessible: [...this.librariesAccessible],
itemTagsSelected: [...this.itemTagsSelected]
itemTagsSelected: [...this.itemTagsSelected],
authOpenIDSub: this.authOpenIDSub
}
}
@ -186,6 +189,8 @@ class User {
this.librariesAccessible = [...(user.librariesAccessible || [])]
this.itemTagsSelected = [...(user.itemTagsSelected || [])]
this.authOpenIDSub = user.authOpenIDSub || null
}
update(payload) {

View File

@ -36,6 +36,7 @@ const { measureMiddleware } = require('../utils/timing')
class ApiRouter {
constructor(Server) {
/** @type {import('../Auth')} */
this.auth = Server.auth
this.playbackSessionManager = Server.playbackSessionManager
this.abMergeManager = Server.abMergeManager
@ -312,6 +313,8 @@ class ApiRouter {
this.router.post('/genres/rename', MiscController.renameGenre.bind(this))
this.router.delete('/genres/:genre', MiscController.deleteGenre.bind(this))
this.router.post('/validate-cron', MiscController.validateCronExpression.bind(this))
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
}

View File

@ -0,0 +1,344 @@
const sinon = require('sinon')
const chai = require('chai')
const expect = chai.expect
const bookFinder = require('../../../server/finders/BookFinder')
const { LogLevel } = require('../../../server/utils/constants')
const Logger = require('../../../server/Logger')
Logger.setLogLevel(LogLevel.INFO)
describe('TitleCandidates', () => {
describe('cleanAuthor non-empty', () => {
let titleCandidates
const cleanAuthor = 'leo tolstoy'
beforeEach(() => {
titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)
})
describe('no adds', () => {
it('returns no candidates', () => {
expect(titleCandidates.getCandidates()).to.deep.equal([])
})
})
describe('single add', () => {
[
['adds candidate', 'anna karenina', ['anna karenina']],
['adds lowercased candidate', 'ANNA KARENINA', ['anna karenina']],
['adds candidate, removing redundant spaces', 'anna karenina', ['anna karenina']],
['adds candidate, removing author', `anna karenina by ${cleanAuthor}`, ['anna karenina']],
['does not add empty candidate after removing author', cleanAuthor, []],
['adds candidate, removing subtitle', 'anna karenina: subtitle', ['anna karenina']],
['adds candidate + variant, removing "by ..."', 'anna karenina by arnold schwarzenegger', ['anna karenina', 'anna karenina by arnold schwarzenegger']],
['adds candidate + variant, removing bitrate', 'anna karenina 64kbps', ['anna karenina', 'anna karenina 64kbps']],
['adds candidate + variant, removing edition 1', 'anna karenina 2nd edition', ['anna karenina', 'anna karenina 2nd edition']],
['adds candidate + variant, removing edition 2', 'anna karenina 4th ed.', ['anna karenina', 'anna karenina 4th ed.']],
['adds candidate + variant, removing fie type', 'anna karenina.mp3', ['anna karenina', 'anna karenina.mp3']],
['adds candidate + variant, removing "a novel"', 'anna karenina a novel', ['anna karenina', 'anna karenina a novel']],
['adds candidate + variant, removing preceding/trailing numbers', '1 anna karenina 2', ['anna karenina', '1 anna karenina 2']],
['does not add empty candidate', '', []],
['does not add spaces-only candidate', ' ', []],
['does not add empty variant', '1984', ['1984']],
].forEach(([name, title, expected]) => it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
})
describe('multiple adds', () => {
[
['demotes digits-only candidates', ['01', 'anna karenina'], ['anna karenina', '01']],
['promotes transformed variants', ['title1 1', 'title2 1'], ['title1', 'title2', 'title1 1', 'title2 1']],
['orders by position', ['title2', 'title1'], ['title2', 'title1']],
['dedupes candidates', ['title1', 'title1'], ['title1']],
].forEach(([name, titles, expected]) => it(name, () => {
for (const title of titles) titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
})
})
describe('cleanAuthor empty', () => {
let titleCandidates
let cleanAuthor = ''
beforeEach(() => {
titleCandidates = new bookFinder.constructor.TitleCandidates(cleanAuthor)
})
describe('single add', () => {
[
['adds a candidate', 'leo tolstoy', ['leo tolstoy']],
].forEach(([name, title, expected]) => it(name, () => {
titleCandidates.add(title)
expect(titleCandidates.getCandidates()).to.deep.equal(expected)
}))
})
})
})
describe('AuthorCandidates', () => {
let authorCandidates
const audnexus = {
authorASINsRequest: sinon.stub().resolves([
{ name: 'Leo Tolstoy' },
{ name: 'Nikolai Gogol' },
{ name: 'J. K. Rowling' },
]),
}
describe('cleanAuthor is null', () => {
beforeEach(() => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(null, audnexus)
})
describe('no adds', () => {
[
['returns empty author candidate', []],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate', 'nikolai gogol', ['nikolai gogol']],
['does not add unrecognized candidate', 'fyodor dostoevsky', []],
['adds recognized author if candidate is a superstring', 'dr. nikolai gogol', ['nikolai gogol']],
['adds candidate if it is a substring of recognized author', 'gogol', ['gogol']],
['adds recognized author if edit distance from candidate is small', 'nicolai gogol', ['nikolai gogol']],
['does not add candidate if edit distance from any recognized author is large', 'nikolai google', []],
['adds normalized recognized candidate (contains redundant spaces)', 'nikolai gogol', ['nikolai gogol']],
['adds normalized recognized candidate (normalized initials)', 'j.k. rowling', ['j. k. rowling']],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('multi add', () => {
[
['adds recognized author candidates', ['nikolai gogol', 'leo tolstoy'], ['nikolai gogol', 'leo tolstoy']],
['dedupes author candidates', ['nikolai gogol', 'nikolai gogol'], ['nikolai gogol']],
].forEach(([name, authors, expected]) => it(name, async () => {
for (const author of authors) authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
describe('cleanAuthor is a recognized author', () => {
const cleanAuthor = 'leo tolstoy'
beforeEach(() => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
})
describe('no adds', () => {
[
['adds cleanAuthor as candidate', [cleanAuthor]],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate', 'nikolai gogol', [cleanAuthor, 'nikolai gogol']],
['does not add candidate if it is a dupe of cleanAuthor', cleanAuthor, [cleanAuthor]],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
describe('cleanAuthor is an unrecognized author', () => {
const cleanAuthor = 'Fyodor Dostoevsky'
beforeEach(() => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
})
describe('no adds', () => {
[
['adds cleanAuthor as candidate', [cleanAuthor]],
].forEach(([name, expected]) => it(name, async () => {
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate and removes cleanAuthor', 'nikolai gogol', ['nikolai gogol']],
['does not add unrecognized candidate', 'jackie chan', [cleanAuthor]],
].forEach(([name, author, expected]) => it(name, async () => {
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
describe('cleanAuthor is unrecognized and dirty', () => {
describe('no adds', () => {
[
['adds aggressively cleaned cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', ['fyodor dostoevsky']],
['adds cleanAuthor if aggresively cleaned cleanAuthor is empty', ', jackie chan', [', jackie chan']],
].forEach(([name, cleanAuthor, expected]) => it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
describe('single add', () => {
[
['adds recognized candidate and removes cleanAuthor', 'fyodor dostoevsky, translated by jackie chan', 'nikolai gogol', ['nikolai gogol']],
].forEach(([name, cleanAuthor, author, expected]) => it(name, async () => {
authorCandidates = new bookFinder.constructor.AuthorCandidates(cleanAuthor, audnexus)
authorCandidates.add(author)
expect(await authorCandidates.getCandidates()).to.deep.equal([...expected, ''])
}))
})
})
})
describe('search', () => {
const t = 'title'
const a = 'author'
const u = 'unrecognized'
const r = ['book']
const runSearchStub = sinon.stub(bookFinder, 'runSearch')
runSearchStub.resolves([])
runSearchStub.withArgs(t, a).resolves(r)
runSearchStub.withArgs(t, u).resolves(r)
const audnexusStub = sinon.stub(bookFinder.audnexus, 'authorASINsRequest')
audnexusStub.resolves([{ name: a }])
beforeEach(() => {
bookFinder.runSearch.resetHistory()
})
describe('search title is empty', () => {
it('returns empty result', async () => {
expect(await bookFinder.search('', '', a)).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 0)
})
})
describe('search title is a recognized title and search author is a recognized author', () => {
it('returns non-empty result (no fuzzy searches)', async () => {
expect(await bookFinder.search('', t, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 1)
})
})
describe('search title contains recognized title and search author is a recognized author', () => {
[
[`${t} -`],
[`${t} - ${a}`],
[`${a} - ${t}`],
[`${t}- ${a}`],
[`${t} -${a}`],
[`${t} ${a}`],
[`${a} - ${t} (unabridged)`],
[`${a} - ${t} (subtitle) - mp3`],
[`${t} {narrator} - series-01 64kbps 10:00:00`],
[`${a} - ${t} (2006) narrated by narrator [unabridged]`],
[`${t} - ${a} 2022 mp3`],
[`01 ${t}`],
[`2022_${t}_HQ`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns non-empty result (with 1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
[`${a} - series 01 - ${t}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns non-empty result (with 2 fuzzy searches)`, async () => {
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 3)
})
});
[
[`${t}-${a}`],
[`${t} junk`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result`, async () => {
expect(await bookFinder.search('', searchTitle, a)).to.deep.equal([])
})
})
describe('maxFuzzySearches = 0', () => {
[
[`${t} - ${a}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result (with no fuzzy searches)`, async () => {
expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 0 })).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 1)
})
})
})
describe('maxFuzzySearches = 1', () => {
[
[`s-01 - ${t} (narrator) 64kbps 10:00:00`],
[`${a} - series 01 - ${t}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${a}') returns an empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, a, null, null, { maxFuzzySearches: 1 })).to.deep.equal([])
sinon.assert.callCount(bookFinder.runSearch, 2)
})
})
})
})
describe('search title contains recognized title and search author is empty', () => {
[
[`${t} - ${a}`],
[`${a} - ${t}`],
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '') returns a non-empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`${t}`],
[`${t} - ${u}`],
[`${u} - ${t}`]
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '') returns an empty result`, async () => {
expect(await bookFinder.search('', searchTitle, '')).to.deep.equal([])
})
})
})
describe('search title contains recognized title and search author is an unrecognized author', () => {
[
[`${t} - ${u}`],
[`${u} - ${t}`]
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${u}') returns a non-empty result (1 fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 2)
})
});
[
[`${t}`]
].forEach(([searchTitle]) => {
it(`search('${searchTitle}', '${u}') returns a non-empty result (no fuzzy search)`, async () => {
expect(await bookFinder.search('', searchTitle, u)).to.deep.equal(r)
sinon.assert.callCount(bookFinder.runSearch, 1)
})
})
})
})