Compare commits

...
11 Commits
14 changed files with 30 additions and 129 deletions

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.37.2", "version": "3.0.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.37.2", "version": "3.0.0-beta",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@nuxtjs/axios": "^5.13.6", "@nuxtjs/axios": "^5.13.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "2.37.2", "version": "3.0.0-beta",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast client", "description": "Self-hosted audiobook and podcast client",
"main": "index.js", "main": "index.js",
-11
View File
@@ -13,11 +13,6 @@
<widgets-online-indicator :value="!!userOnline" /> <widgets-online-indicator :value="!!userOnline" />
<h1 class="text-xl pl-2">{{ username }}</h1> <h1 class="text-xl pl-2">{{ username }}</h1>
</div> </div>
<div v-if="legacyToken" class="text-xs space-y-2 mt-4">
<ui-text-input-with-label label="Legacy API Token" :value="legacyToken" readonly show-copy />
<p class="text-warning" v-html="$strings.MessageAuthenticationLegacyTokenWarning" />
</div>
<div class="w-full h-px bg-white/10 my-2" /> <div class="w-full h-px bg-white/10 my-2" />
<div class="py-2"> <div class="py-2">
<h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningStats }}</h1> <h1 class="text-lg mb-2 text-white/90 px-2 sm:px-0">{{ $strings.HeaderListeningStats }}</h1>
@@ -102,12 +97,6 @@ export default {
} }
}, },
computed: { computed: {
legacyToken() {
return this.user.token
},
userToken() {
return this.user.accessToken
},
bookCoverAspectRatio() { bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio'] return this.$store.getters['libraries/getBookCoverAspectRatio']
}, },
+1 -24
View File
@@ -40,15 +40,6 @@
<p v-if="error" class="text-error text-center py-2">{{ error }}</p> <p v-if="error" class="text-error text-center py-2">{{ error }}</p>
<div v-if="showNewAuthSystemMessage" class="mb-4">
<widgets-alert type="warning">
<div>
<p>{{ $strings.MessageAuthenticationSecurityMessage }}</p>
<a v-if="showNewAuthSystemAdminMessage" href="https://github.com/advplyr/audiobookshelf/discussions/4460" target="_blank" class="underline">{{ $strings.LabelMoreInfo }}</a>
</div>
</widgets-alert>
</div>
<form v-show="login_local" @submit.prevent="submitForm"> <form v-show="login_local" @submit.prevent="submitForm">
<label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label> <label class="text-xs text-gray-300 uppercase">{{ $strings.LabelUsername }}</label>
<ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" /> <ui-text-input v-model.trim="username" autocomplete="username" :disabled="processing" class="mb-3 w-full" inputName="username" />
@@ -94,10 +85,7 @@ export default {
MetadataPath: '', MetadataPath: '',
login_local: true, login_local: true,
login_openid: false, login_openid: false,
authFormData: null, authFormData: null
// New JWT auth system re-login flags
showNewAuthSystemMessage: false,
showNewAuthSystemAdminMessage: false
} }
}, },
watch: { watch: {
@@ -201,8 +189,6 @@ export default {
}, },
async submitForm() { async submitForm() {
this.error = null this.error = null
this.showNewAuthSystemMessage = false
this.showNewAuthSystemAdminMessage = false
this.processing = true this.processing = true
const payload = { const payload = {
@@ -238,15 +224,6 @@ export default {
} }
}) })
.then((res) => { .then((res) => {
// Force re-login if user is using an old token with no expiration
if (res.user.isOldToken) {
this.username = res.user.username
this.showNewAuthSystemMessage = true
// Admin user sees link to github discussion
this.showNewAuthSystemAdminMessage = res.user.type === 'admin' || res.user.type === 'root'
return false
}
this.setUser(res) this.setUser(res)
return true return true
}) })
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.37.2", "version": "3.0.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.37.2", "version": "3.0.0-beta",
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "2.37.2", "version": "3.0.0-beta",
"buildNumber": 1, "buildNumber": 1,
"description": "Self-hosted audiobook and podcast server", "description": "Self-hosted audiobook and podcast server",
"main": "dist-server/index.js", "main": "dist-server/index.js",
+2 -16
View File
@@ -62,18 +62,6 @@ class Auth {
return passport.authenticate('jwt', { session: false })(req, res, next) return passport.authenticate('jwt', { session: false })(req, res, next)
} }
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateAccessToken(user) {
return this.tokenManager.generateAccessToken(user)
}
/** /**
* Invalidate all JWT sessions for a given user * Invalidate all JWT sessions for a given user
* If user is current user and refresh token is valid, rotate tokens for the current session * If user is current user and refresh token is valid, rotate tokens for the current session
@@ -269,10 +257,8 @@ class Auth {
} else { } else {
// UI request -> check if we have a callback url // UI request -> check if we have a callback url
if (req.cookies.auth_cb) { if (req.cookies.auth_cb) {
let stateQuery = req.cookies.auth_state ? `&state=${req.cookies.auth_state}` : '' const 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}?accessToken=${userResponse.user.accessToken}${stateQuery}`)
// TODO: Temporarily continue sending the old token as setToken
res.redirect(302, `${req.cookies.auth_cb}?setToken=${userResponse.user.token}&accessToken=${userResponse.user.accessToken}${stateQuery}`)
} else { } else {
Logger.error('[Auth] No callback or already expired') Logger.error('[Auth] No callback or already expired')
res.redirect(`/login?error=${encodeURIComponent('No callback or already expired')}&autoLaunch=0`) res.redirect(`/login?error=${encodeURIComponent('No callback or already expired')}&autoLaunch=0`)
+2 -3
View File
@@ -415,10 +415,9 @@ class Database {
* Create root user * Create root user
* @param {string} username * @param {string} username
* @param {string} pash * @param {string} pash
* @param {Auth} auth
* @returns {Promise<boolean>} true if created * @returns {Promise<boolean>} true if created
*/ */
async createRootUser(username, pash, auth) { async createRootUser(username, pash) {
if (!this.sequelize) return false if (!this.sequelize) return false
const transaction = await this.sequelize.transaction() const transaction = await this.sequelize.transaction()
try { try {
@@ -427,7 +426,7 @@ class Database {
await transaction.rollback() await transaction.rollback()
return false return false
} }
await this.userModel.createRootUser(username, pash, auth, transaction) await this.userModel.createRootUser(username, pash, transaction)
await transaction.commit() await transaction.commit()
this.hasRootUser = true this.hasRootUser = true
return true return true
+1 -1
View File
@@ -479,7 +479,7 @@ class Server {
const rootUsername = newRoot.username || 'root' const rootUsername = newRoot.username || 'root'
const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : '' const rootPash = newRoot.password ? await this.auth.localAuthStrategy.hashPassword(newRoot.password) : ''
if (!rootPash) Logger.warn(`[Server] Creating root user with no password`) if (!rootPash) Logger.warn(`[Server] Creating root user with no password`)
const created = await Database.createRootUser(rootUsername, rootPash, this.auth) const created = await Database.createRootUser(rootUsername, rootPash)
if (!created) { if (!created) {
Logger.error(`[Server] attempt to init server when server already has a root user`) Logger.error(`[Server] attempt to init server when server already has a root user`)
return res.sendStatus(500) return res.sendStatus(500)
+3 -34
View File
@@ -80,7 +80,9 @@ class TokenManager {
*/ */
static isBearerAccessTokenPayload(decoded) { static isBearerAccessTokenPayload(decoded) {
if (!decoded?.userId) return false if (!decoded?.userId) return false
if (decoded.type === 'refresh') return false // Legacy API tokens were signed with only userId and username, and never expired.
if (decoded.type !== 'access') return false
if (!decoded.exp) return false
return true return true
} }
@@ -100,30 +102,6 @@ class TokenManager {
} }
} }
/**
* Generate a JWT token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
static generateAccessToken(user) {
return jwt.sign({ userId: user.id, username: user.username }, TokenManager.TokenSecret)
}
/**
* Function to generate a jwt token for a given user
* TODO: Old method with no expiration
* @deprecated
*
* @param {{ id:string, username:string }} user
* @returns {string}
*/
generateAccessToken(user) {
return TokenManager.generateAccessToken(user)
}
/** /**
* Generate access token for a given user * Generate access token for a given user
* *
@@ -325,15 +303,6 @@ class TokenManager {
return return
} }
// TODO: Temporary flag to report old tokens to users
// May be a better place for this but here means we dont have to decode the token again
if (!jwt_payload.exp && !user.isOldToken) {
Logger.debug(`[TokenManager] User ${user.username} is using an access token without an expiration`)
user.isOldToken = true
} else if (jwt_payload.exp && user.isOldToken !== undefined) {
delete user.isOldToken
}
// approve login // approve login
done(null, user) done(null, user)
} }
+2 -13
View File
@@ -28,13 +28,12 @@ class UserController {
*/ */
async findAll(req, res) { async findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403) if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot
const includes = (req.query.include || '').split(',').map((i) => i.trim()) const includes = (req.query.include || '').split(',').map((i) => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks // Minimal toJSONForBrowser does not include mediaProgress and bookmarks
const allUsers = await Database.userModel.findAll() const allUsers = await Database.userModel.findAll()
const users = allUsers.map((u) => u.toOldJSONForBrowser(hideRootToken, true)) const users = allUsers.map((u) => u.toOldJSONForBrowser(true))
if (includes.includes('latestSession')) { if (includes.includes('latestSession')) {
for (const user of users) { for (const user of users) {
@@ -97,7 +96,7 @@ class UserController {
return oldMediaProgress return oldMediaProgress
}) })
const userJson = req.reqUser.toOldJSONForBrowser(!req.user.isRoot) const userJson = req.reqUser.toOldJSONForBrowser()
userJson.mediaProgress = oldMediaProgresses userJson.mediaProgress = oldMediaProgresses
@@ -128,7 +127,6 @@ class UserController {
const userId = uuidv4() const userId = uuidv4()
const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password) const pash = await this.auth.localAuthStrategy.hashPassword(req.body.password)
const token = this.auth.generateAccessToken({ id: userId, username: req.body.username })
const userType = req.body.type || 'user' const userType = req.body.type || 'user'
// librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions // librariesAccessible and itemTagsSelected can be on req.body or req.body.permissions
@@ -173,7 +171,6 @@ class UserController {
username: req.body.username, username: req.body.username,
email: typeof req.body.email === 'string' ? req.body.email : null, email: typeof req.body.email === 'string' ? req.body.email : null,
pash, pash,
token,
isActive: !!req.body.isActive, isActive: !!req.body.isActive,
permissions, permissions,
bookmarks: [], bookmarks: [],
@@ -236,16 +233,13 @@ class UserController {
} }
let hasUpdates = false let hasUpdates = false
let shouldUpdateToken = false
let shouldInvalidateJwtSessions = false let shouldInvalidateJwtSessions = false
// When changing username create a new API token
if (updatePayload.username && updatePayload.username !== user.username) { if (updatePayload.username && updatePayload.username !== user.username) {
const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username) const usernameExists = await Database.userModel.checkUserExistsWithUsername(updatePayload.username)
if (usernameExists) { if (usernameExists) {
return res.status(400).send('Username already taken') return res.status(400).send('Username already taken')
} }
user.username = updatePayload.username user.username = updatePayload.username
shouldUpdateToken = true
shouldInvalidateJwtSessions = true shouldInvalidateJwtSessions = true
hasUpdates = true hasUpdates = true
} }
@@ -327,11 +321,6 @@ class UserController {
} }
if (hasUpdates) { if (hasUpdates) {
if (shouldUpdateToken) {
user.token = this.auth.generateAccessToken(user)
Logger.info(`[UserController] User ${user.username} has generated a new api token`)
}
// Handle JWT session invalidation for username/password changes // Handle JWT session invalidation for username/password changes
if (shouldInvalidateJwtSessions) { if (shouldInvalidateJwtSessions) {
const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res) const newTokens = await this.auth.invalidateJwtSessionsForUser(user, req, res)
+3 -18
View File
@@ -5,7 +5,6 @@ const { LRUCache } = require('lru-cache')
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const { isNullOrNaN } = require('../utils') const { isNullOrNaN } = require('../utils')
const TokenManager = require('../auth/TokenManager')
class UserCache { class UserCache {
constructor() { constructor() {
@@ -94,8 +93,6 @@ class User extends Model {
this.pash this.pash
/** @type {string} */ /** @type {string} */
this.type this.type
/** @type {string} */
this.token
/** @type {boolean} */ /** @type {boolean} */
this.isActive this.isActive
/** @type {boolean} */ /** @type {boolean} */
@@ -186,20 +183,16 @@ class User extends Model {
* Create root user * Create root user
* @param {string} username * @param {string} username
* @param {string} pash * @param {string} pash
* @param {import('../Auth')} auth
* @returns {Promise<User>} * @returns {Promise<User>}
*/ */
static async createRootUser(username, pash, auth, transaction = null) { static async createRootUser(username, pash, transaction = null) {
const userId = uuidv4() const userId = uuidv4()
const token = auth.generateAccessToken({ id: userId, username })
const newUser = { const newUser = {
id: userId, id: userId,
type: 'root', type: 'root',
username, username,
pash, pash,
token,
isActive: true, isActive: true,
permissions: this.getDefaultPermissionsForUserType('root'), permissions: this.getDefaultPermissionsForUserType('root'),
bookmarks: [], bookmarks: [],
@@ -314,15 +307,12 @@ class User extends Model {
const username = userinfo.preferred_username || userinfo.name || userinfo.sub const username = userinfo.preferred_username || userinfo.name || userinfo.sub
const email = userinfo.email && userinfo.email_verified ? userinfo.email : null const email = userinfo.email && userinfo.email_verified ? userinfo.email : null
const token = TokenManager.generateAccessToken({ id: userId, username })
const newUser = { const newUser = {
id: userId, id: userId,
type: 'user', type: 'user',
username, username,
email, email,
pash: null, pash: null,
token,
isActive: true, isActive: true,
permissions: this.getDefaultPermissionsForUserType('user'), permissions: this.getDefaultPermissionsForUserType('user'),
bookmarks: [], bookmarks: [],
@@ -514,6 +504,7 @@ class User extends Model {
email: DataTypes.STRING, email: DataTypes.STRING,
pash: DataTypes.STRING, pash: DataTypes.STRING,
type: DataTypes.STRING, type: DataTypes.STRING,
// Column remains so existing databases do not need a migration. New users leave it null.
token: DataTypes.STRING, token: DataTypes.STRING,
isActive: { isActive: {
type: DataTypes.BOOLEAN, type: DataTypes.BOOLEAN,
@@ -596,11 +587,10 @@ class User extends Model {
/** /**
* User data for browser using old model * User data for browser using old model
* *
* @param {boolean} [hideRootToken=false]
* @param {boolean} [minimal=false] * @param {boolean} [minimal=false]
* @returns * @returns
*/ */
toOldJSONForBrowser(hideRootToken = false, minimal = false) { toOldJSONForBrowser(minimal = false) {
const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || [] const seriesHideFromContinueListening = this.extraData?.seriesHideFromContinueListening || []
const librariesAccessible = this.permissions?.librariesAccessible || [] const librariesAccessible = this.permissions?.librariesAccessible || []
const itemTagsSelected = this.permissions?.itemTagsSelected || [] const itemTagsSelected = this.permissions?.itemTagsSelected || []
@@ -613,11 +603,6 @@ class User extends Model {
username: this.username, username: this.username,
email: this.email, email: this.email,
type: this.type, type: this.type,
// TODO: Old non-expiring token
token: this.type === 'root' && hideRootToken ? '' : this.token,
// TODO: Temporary flag not saved in db that is set in Auth.js jwtAuthCheck
// Necessary to detect apps using old tokens that no longer match the old token stored on the user
isOldToken: this.isOldToken,
mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [], mediaProgress: this.mediaProgresses?.map((mp) => mp.getOldMediaProgress()) || [],
seriesHideFromContinueListening: [...seriesHideFromContinueListening], seriesHideFromContinueListening: [...seriesHideFromContinueListening],
bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [], bookmarks: this.bookmarks?.map((b) => ({ ...b })) || [],
-3
View File
@@ -75,9 +75,6 @@ class Stream extends EventEmitter {
get codecsToForceAAC() { get codecsToForceAAC() {
return ['alac', 'ac3', 'eac3', 'opus'] return ['alac', 'ac3', 'eac3', 'opus']
} }
get userToken() {
return this.user.token
}
// Fmp4 does not work on iOS devices: https://github.com/advplyr/audiobookshelf-app/issues/85 // Fmp4 does not work on iOS devices: https://github.com/advplyr/audiobookshelf-app/issues/85
// Workaround: Force AAC transcode for FLAC // Workaround: Force AAC transcode for FLAC
get hlsSegmentType() { get hlsSegmentType() {
+10
View File
@@ -39,6 +39,16 @@ describe('TokenManager', () => {
describe('jwtAuthCheck', () => { describe('jwtAuthCheck', () => {
const user = { id: userId, username: 'testuser', isActive: true } const user = { id: userId, username: 'testuser', isActive: true }
it('rejects legacy API tokens that have no type or expiration', async () => {
const legacyToken = jwt.sign({ userId, username: 'testuser' }, secret)
const decoded = jwt.verify(legacyToken, secret)
const done = sinon.spy()
await tokenManager.jwtAuthCheck(decoded, done)
expect(done.calledWith(null, null)).to.be.true
})
it('rejects refresh tokens for API auth', async () => { it('rejects refresh tokens for API auth', async () => {
const refreshToken = tokenManager.generateRefreshToken(user) const refreshToken = tokenManager.generateRefreshToken(user)
const decoded = jwt.verify(refreshToken, secret) const decoded = jwt.verify(refreshToken, secret)