mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-12 12:21:34 +00:00
Implemented replay
This commit is contained in:
parent
6a3a37758c
commit
207b7f290f
12 changed files with 441 additions and 227 deletions
|
|
@ -15,11 +15,8 @@
|
|||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
//"forwardPorts": [
|
||||
// 3000,
|
||||
// 3333
|
||||
//],
|
||||
"runArgs": ["--network=host"],
|
||||
"forwardPorts": [3000, 3333],
|
||||
"runArgs": ["--network=host", "-p 3000:3000", "-p 3333:3333"],
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "sh .devcontainer/post-create.sh",
|
||||
// Configure tool-specific properties.
|
||||
|
|
|
|||
28
.vscode/launch.json
vendored
28
.vscode/launch.json
vendored
|
|
@ -9,36 +9,24 @@
|
|||
"request": "launch",
|
||||
"name": "Debug server",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
]
|
||||
"args": ["run", "dev"],
|
||||
"skipFiles": ["<node_internals>/**"]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug client (nuxt)",
|
||||
"runtimeExecutable": "npm",
|
||||
"args": [
|
||||
"run",
|
||||
"dev"
|
||||
],
|
||||
"args": ["run", "dev"],
|
||||
"cwd": "${workspaceFolder}/client",
|
||||
"skipFiles": [
|
||||
"${workspaceFolder}/<node_internals>/**"
|
||||
]
|
||||
"skipFiles": ["${workspaceFolder}/<node_internals>/**"]
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Debug server and client (nuxt)",
|
||||
"configurations": [
|
||||
"Debug server",
|
||||
"Debug client (nuxt)"
|
||||
]
|
||||
"configurations": ["Debug server", "Debug client (nuxt)"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"outputCapture": "std"
|
||||
}
|
||||
|
|
|
|||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -23,5 +23,6 @@
|
|||
},
|
||||
"[vue]": {
|
||||
"editor.defaultFormatter": "octref.vetur"
|
||||
}
|
||||
}
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm DlnaDropdownMenu" tabindex="-1" role="listbox">
|
||||
<template v-for="device in get_devices">
|
||||
<li :key="device.host" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" v-bind:class="{ 'bg-black-600': get_device == device.host }" role="option" tabindex="0" @keydown.enter="selectDevice(device.host)" @click="selectDevice(device.host)">
|
||||
<li :key="device.host" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" v-bind:class="{ 'bg-black-600': get_device == device.UDN }" role="option" tabindex="0" @keydown.enter="selectDevice(device.host)" @click="selectDevice(device.UDN)">
|
||||
<div class="flex items-center px-2">
|
||||
<span class="font-normal block truncate font-sans text-sm">{{ device.name }}</span>
|
||||
</div>
|
||||
|
|
@ -92,4 +92,4 @@ export default {
|
|||
.icon-blue {
|
||||
color: blue;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ export default class DLNAPlayer extends EventEmitter {
|
|||
this.ctx = ctx
|
||||
this.player = null
|
||||
this.playerController = null
|
||||
|
||||
this.state = null
|
||||
this.libraryItem = null
|
||||
this.audioTracks = []
|
||||
this.currentTrackIndex = 0
|
||||
|
|
@ -15,7 +15,8 @@ export default class DLNAPlayer extends EventEmitter {
|
|||
this.currentTime = 0
|
||||
this.playWhenReady = false
|
||||
this.defaultPlaybackRate = 1
|
||||
|
||||
this.paused
|
||||
this.currentTime = null
|
||||
// TODO: Use canDisplayType on receiver to check mime types
|
||||
this.playableMimeTypes = ['audio/flac', 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/aac', 'audio/x-ms-wma', 'audio/x-aiff', 'audio/webm']
|
||||
|
||||
|
|
@ -34,13 +35,15 @@ export default class DLNAPlayer extends EventEmitter {
|
|||
}
|
||||
|
||||
initialize() {
|
||||
this.ctx.$root.socket.on('test', this.destroy)
|
||||
this.ctx.$root.socket.on('dlna_status', (data) => this.setStatus(data))
|
||||
//this.player = this.ctx.$root.castPlayer
|
||||
}
|
||||
|
||||
evtMediaInfoChanged() {}
|
||||
|
||||
destroy() {}
|
||||
destroy() {
|
||||
this.ctx.$root.socket.emit('dlna_exit')
|
||||
}
|
||||
|
||||
async set(libraryItem, tracks, isHlsTranscode, startTime, playWhenReady = false) {
|
||||
this.libraryItem = libraryItem
|
||||
|
|
@ -56,16 +59,28 @@ export default class DLNAPlayer extends EventEmitter {
|
|||
} else {
|
||||
this.coverUrl = `${window.location.origin}${coverImg}`
|
||||
}
|
||||
this.ctx.$root.socket.emit('dlna_start', this.audioTracks)
|
||||
var serverAddress = window.origin
|
||||
if (this.$isDev) serverAddress = `http://localhost:3333${this.$config.routerBasePath}`
|
||||
this.ctx.$root.socket.emit('dlna_start', this.ctx.$store.state.globals.dlnaDevice, this.audioTracks, startTime, serverAddress)
|
||||
}
|
||||
|
||||
resetStream(startTime) {}
|
||||
|
||||
playPause() {}
|
||||
playPause() {
|
||||
if (this.state == 'PLAYING') {
|
||||
this.pause()
|
||||
} else {
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
|
||||
play() {}
|
||||
play() {
|
||||
this.ctx.$root.socket.emit('dlna_play')
|
||||
}
|
||||
|
||||
pause() {}
|
||||
pause() {
|
||||
this.ctx.$root.socket.emit('dlna_pause')
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
//var currentTrackOffset = this.currentTrack.startOffset || 0
|
||||
|
|
@ -83,6 +98,22 @@ export default class DLNAPlayer extends EventEmitter {
|
|||
}
|
||||
|
||||
async seek(time, playWhenReady) {}
|
||||
setStatus(data) {
|
||||
console.log(data)
|
||||
if (this.state != data.status) {
|
||||
this.state = data.status
|
||||
this.emit('stateChange', this.state)
|
||||
}
|
||||
this.currentTrackIndex = data.track_idx
|
||||
this.currentTime = data.pos
|
||||
console.log(this.currentTrackIndex, this.currentTime)
|
||||
}
|
||||
|
||||
getCurrentTime() {
|
||||
return this.currentTime + this.audioTracks[this.currentTrackIndex].startOffset
|
||||
}
|
||||
seek(time) {
|
||||
this.ctx.$root.socket.emit('dlna_seek', time)
|
||||
}
|
||||
setVolume(volume) {}
|
||||
}
|
||||
|
|
|
|||
370
server/Auth.js
370
server/Auth.js
|
|
@ -13,7 +13,6 @@ const Logger = require('./Logger')
|
|||
* @class Class for handling all the authentication related functionality.
|
||||
*/
|
||||
class Auth {
|
||||
|
||||
constructor() {
|
||||
// Map of openId sessions indexed by oauth2 state-variable
|
||||
this.openIdAuthSession = new Map()
|
||||
|
|
@ -24,40 +23,52 @@ class Auth {
|
|||
*/
|
||||
async initPassportJs() {
|
||||
// Check if we should load the local strategy (username + password login)
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes("local")) {
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
|
||||
this.initAuthStrategyPassword()
|
||||
}
|
||||
|
||||
// Check if we should load the openid strategy
|
||||
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) {
|
||||
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)))
|
||||
// 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,
|
||||
}))
|
||||
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.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)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,43 +103,49 @@ class Auth {
|
|||
client_secret: global.ServerSettings.authOpenIDClientSecret,
|
||||
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
|
||||
})
|
||||
passport.use('openid-client', new OpenIDClient.Strategy({
|
||||
client: openIdClient,
|
||||
params: {
|
||||
redirect_uri: '/auth/openid/callback',
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
}, async (tokenset, userinfo, done) => {
|
||||
try {
|
||||
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||
passport.use(
|
||||
'openid-client',
|
||||
new OpenIDClient.Strategy(
|
||||
{
|
||||
client: openIdClient,
|
||||
params: {
|
||||
redirect_uri: '/auth/openid/callback',
|
||||
scope: 'openid profile email'
|
||||
}
|
||||
},
|
||||
async (tokenset, userinfo, done) => {
|
||||
try {
|
||||
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
|
||||
|
||||
if (!userinfo.sub) {
|
||||
throw new Error('Invalid userinfo, no sub')
|
||||
if (!userinfo.sub) {
|
||||
throw new Error('Invalid userinfo, no sub')
|
||||
}
|
||||
|
||||
if (!this.validateGroupClaim(userinfo)) {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
}
|
||||
|
||||
let user = await this.findOrCreateUser(userinfo)
|
||||
|
||||
if (!user?.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
}
|
||||
|
||||
await this.setUserGroup(user, userinfo)
|
||||
await this.updateUserPermissions(user, userinfo)
|
||||
|
||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||
user.openid_id_token = tokenset.id_token
|
||||
|
||||
return done(null, user)
|
||||
} catch (error) {
|
||||
Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||
|
||||
return done(null, null, 'Unauthorized')
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.validateGroupClaim(userinfo)) {
|
||||
throw new Error(`Group claim ${Database.serverSettings.authOpenIDGroupClaim} not found or empty in userinfo`)
|
||||
}
|
||||
|
||||
let user = await this.findOrCreateUser(userinfo)
|
||||
|
||||
if (!user?.isActive) {
|
||||
throw new Error('User not active or not found')
|
||||
}
|
||||
|
||||
await this.setUserGroup(user, userinfo)
|
||||
await this.updateUserPermissions(user, userinfo)
|
||||
|
||||
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
|
||||
user.openid_id_token = tokenset.id_token
|
||||
|
||||
return done(null, user)
|
||||
} catch (error) {
|
||||
Logger.error(`[Auth] openid callback error: ${error?.message}\n${error?.stack}`)
|
||||
|
||||
return done(null, null, 'Unauthorized')
|
||||
}
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -181,7 +198,6 @@ class Auth {
|
|||
return null
|
||||
}
|
||||
|
||||
|
||||
user = await Database.userModel.getUserByUsername(username)
|
||||
|
||||
if (user?.authOpenIDSub) {
|
||||
|
|
@ -220,7 +236,8 @@ class Auth {
|
|||
*/
|
||||
validateGroupClaim(userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName) // Allow no group claim when configured like this
|
||||
if (!groupClaimName)
|
||||
// Allow no group claim when configured like this
|
||||
return true
|
||||
|
||||
// If configured it must exist in userinfo
|
||||
|
|
@ -232,22 +249,22 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Sets the user group based on group claim in userinfo.
|
||||
*
|
||||
*
|
||||
* @param {import('./objects/user/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async setUserGroup(user, userinfo) {
|
||||
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
|
||||
if (!groupClaimName) // No group claim configured, don't set anything
|
||||
if (!groupClaimName)
|
||||
// No group claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (!userinfo[groupClaimName])
|
||||
throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||
if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
|
||||
|
||||
const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase())
|
||||
const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
|
||||
const rolesInOrderOfPriority = ['admin', 'user', 'guest']
|
||||
|
||||
let userType = rolesInOrderOfPriority.find(role => groupsList.includes(role))
|
||||
let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
|
||||
if (userType) {
|
||||
if (user.type === 'root') {
|
||||
// Check OpenID Group
|
||||
|
|
@ -271,21 +288,20 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Updates user permissions based on the advanced permissions claim.
|
||||
*
|
||||
*
|
||||
* @param {import('./objects/user/User')} user
|
||||
* @param {Object} userinfo
|
||||
*/
|
||||
async updateUserPermissions(user, userinfo) {
|
||||
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
|
||||
if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything
|
||||
if (!absPermissionsClaim)
|
||||
// No advanced permissions claim configured, don't set anything
|
||||
return
|
||||
|
||||
if (user.type === 'admin' || user.type === 'root')
|
||||
return
|
||||
if (user.type === 'admin' || user.type === 'root') return
|
||||
|
||||
const absPermissions = userinfo[absPermissionsClaim]
|
||||
if (!absPermissions)
|
||||
throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||
if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
|
||||
|
||||
if (user.updatePermissionsFromExternalJSON(absPermissions)) {
|
||||
Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
|
||||
|
|
@ -295,8 +311,8 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Unuse strategy
|
||||
*
|
||||
* @param {string} name
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
unuseAuthStrategy(name) {
|
||||
passport.unuse(name)
|
||||
|
|
@ -304,8 +320,8 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Use strategy
|
||||
*
|
||||
* @param {string} name
|
||||
*
|
||||
* @param {string} name
|
||||
*/
|
||||
useAuthStrategy(name) {
|
||||
if (name === 'openid') {
|
||||
|
|
@ -335,7 +351,7 @@ class Auth {
|
|||
* - 'api': Authentication for API use
|
||||
* - 'openid': OpenID authentication directly over web
|
||||
* - 'openid-mobile': OpenID authentication, but done via an mobile device
|
||||
*
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {string} authMethod - The authentication method, default is 'local'.
|
||||
|
|
@ -365,7 +381,7 @@ class Auth {
|
|||
/**
|
||||
* 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
|
||||
*/
|
||||
|
|
@ -391,8 +407,8 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Creates all (express) routes required for authentication.
|
||||
*
|
||||
* @param {import('express').Router} router
|
||||
*
|
||||
* @param {import('express').Router} router
|
||||
*/
|
||||
async initAuthRoutes(router) {
|
||||
// Local strategy login route (takes username and password)
|
||||
|
|
@ -422,7 +438,7 @@ class Auth {
|
|||
}
|
||||
|
||||
// Generate a state on web flow or if no state supplied
|
||||
const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state
|
||||
const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
|
||||
|
||||
// Redirect URL for the SSO provider
|
||||
let redirectUri
|
||||
|
|
@ -508,8 +524,7 @@ class Auth {
|
|||
|
||||
function isValidRedirectUri(uri) {
|
||||
// Check if the redirect_uri is in the whitelist
|
||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) ||
|
||||
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -545,77 +560,79 @@ class Auth {
|
|||
})
|
||||
|
||||
// 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
|
||||
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
|
||||
}
|
||||
|
||||
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
|
||||
Logger.error(JSON.stringify(logMessage, null, 2))
|
||||
if (response) {
|
||||
// Depending on the error, it can also have a body
|
||||
// We also log the request header the passport plugin sents for the URL
|
||||
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
|
||||
Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
|
||||
if (!req.session[sessionKey]) {
|
||||
return res.status(400).send('No session')
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return res.status(errorCode).send(errorMessage)
|
||||
} else {
|
||||
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
function passportCallback(req, res, next) {
|
||||
return (err, user, info) => {
|
||||
const isMobile = req.session[sessionKey]?.mobile === true
|
||||
if (err) {
|
||||
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
|
||||
function handleAuthError(isMobile, errorCode, errorMessage, logMessage, response) {
|
||||
Logger.error(JSON.stringify(logMessage, null, 2))
|
||||
if (response) {
|
||||
// Depending on the error, it can also have a body
|
||||
// We also log the request header the passport plugin sents for the URL
|
||||
const header = response.req?._header.replace(/Authorization: [^\r\n]*/i, 'Authorization: REDACTED')
|
||||
Logger.debug(header + '\n' + JSON.stringify(response.body, null, 2))
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
// Info usually contains the error message from the SSO provider
|
||||
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
|
||||
if (isMobile) {
|
||||
return res.status(errorCode).send(errorMessage)
|
||||
} else {
|
||||
return res.redirect(`/login?error=${encodeURIComponent(errorMessage)}&autoLaunch=0`)
|
||||
}
|
||||
}
|
||||
|
||||
req.logIn(user, (loginError) => {
|
||||
if (loginError) {
|
||||
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
|
||||
function passportCallback(req, res, next) {
|
||||
return (err, user, info) => {
|
||||
const isMobile = req.session[sessionKey]?.mobile === true
|
||||
if (err) {
|
||||
return handleAuthError(isMobile, 500, 'Error in callback', `[Auth] Error in openid callback - ${err}`, err?.response)
|
||||
}
|
||||
|
||||
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider
|
||||
// instead it containts a JWT with userinfo like user email, username, etc.
|
||||
// the client will get to know it anyway in the logout url according to the oauth2 spec
|
||||
// so it is safe to send it to the client, but we use strict settings
|
||||
res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
|
||||
next()
|
||||
})
|
||||
if (!user) {
|
||||
// Info usually contains the error message from the SSO provider
|
||||
return handleAuthError(isMobile, 401, 'Unauthorized', `[Auth] No data in openid callback - ${info}`, info?.response)
|
||||
}
|
||||
|
||||
req.logIn(user, (loginError) => {
|
||||
if (loginError) {
|
||||
return handleAuthError(isMobile, 500, 'Error during login', `[Auth] Error in openid callback: ${loginError}`)
|
||||
}
|
||||
|
||||
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider
|
||||
// instead it containts a JWT with userinfo like user email, username, etc.
|
||||
// the client will get to know it anyway in the logout url according to the oauth2 spec
|
||||
// so it is safe to send it to the client, but we use strict settings
|
||||
res.cookie('openid_id_token', user.openid_id_token, { maxAge: 1000 * 60 * 60 * 24 * 365 * 10, httpOnly: true, secure: true, sameSite: 'Strict' })
|
||||
next()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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
|
||||
// We set it here again because the passport param can change between requests
|
||||
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
|
||||
},
|
||||
// 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
|
||||
// We set it here again because the passport param can change between requests
|
||||
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
|
||||
},
|
||||
// on a successfull login: read the cookies and react like the client requested (callback or json)
|
||||
this.handleLoginSuccessBasedOnCookie.bind(this))
|
||||
this.handleLoginSuccessBasedOnCookie.bind(this)
|
||||
)
|
||||
|
||||
/**
|
||||
* Helper route used to auto-populate the openid URLs in config/authentication
|
||||
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
|
||||
*
|
||||
*
|
||||
* @example /auth/openid/config?issuer=http://192.168.1.66:9000/application/o/audiobookshelf/
|
||||
*/
|
||||
router.get('/auth/openid/config', this.isAuthenticated, async (req, res) => {
|
||||
|
|
@ -625,7 +642,7 @@ class Auth {
|
|||
}
|
||||
|
||||
if (!req.query.issuer) {
|
||||
return res.status(400).send('Invalid request. Query param \'issuer\' is required')
|
||||
return res.status(400).send("Invalid request. Query param 'issuer' is required")
|
||||
}
|
||||
|
||||
// Strip trailing slash
|
||||
|
|
@ -641,23 +658,26 @@ class Auth {
|
|||
}
|
||||
} catch (error) {
|
||||
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
|
||||
return res.status(400).send('Invalid request. Query param \'issuer\' is invalid')
|
||||
return res.status(400).send("Invalid request. Query param 'issuer' is invalid")
|
||||
}
|
||||
|
||||
axios.get(configUrl.toString()).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,
|
||||
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||
axios
|
||||
.get(configUrl.toString())
|
||||
.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,
|
||||
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
|
||||
})
|
||||
})
|
||||
.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`)
|
||||
})
|
||||
}).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
|
||||
|
|
@ -683,7 +703,7 @@ class Auth {
|
|||
let postLogoutRedirectUri = null
|
||||
|
||||
if (authMethod === 'openid') {
|
||||
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http'
|
||||
const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
|
||||
const host = req.get('host')
|
||||
// TODO: ABS does currently not support subfolders for installation
|
||||
// If we want to support it we need to include a config for the serverurl
|
||||
|
|
@ -717,9 +737,9 @@ class Auth {
|
|||
|
||||
/**
|
||||
* 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
|
||||
* @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
|
||||
|
|
@ -727,14 +747,14 @@ class Auth {
|
|||
next()
|
||||
} else {
|
||||
// try JWT to authenticate
|
||||
passport.authenticate("jwt")(req, res, next)
|
||||
passport.authenticate('jwt')(req, res, next)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Function to generate a jwt token for a given user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
*
|
||||
* @param {{ id:string, username:string }} user
|
||||
* @returns {string} token
|
||||
*/
|
||||
generateAccessToken(user) {
|
||||
|
|
@ -743,15 +763,14 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Function to validate a jwt token for a given user
|
||||
*
|
||||
* @param {string} token
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {Object} tokens data
|
||||
*/
|
||||
static validateAccessToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, global.ServerSettings.tokenSecret)
|
||||
}
|
||||
catch (err) {
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -760,7 +779,8 @@ class Auth {
|
|||
* 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
|
||||
if (process.env.TOKEN_SECRET) {
|
||||
// User can supply their own token secret
|
||||
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
|
||||
} else {
|
||||
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')
|
||||
|
|
@ -779,8 +799,8 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Checks if the user in the validated jwt_payload really exists and is active.
|
||||
* @param {Object} jwt_payload
|
||||
* @param {function} done
|
||||
* @param {Object} jwt_payload
|
||||
* @param {function} done
|
||||
*/
|
||||
async jwtAuthCheck(jwt_payload, done) {
|
||||
// load user by id from the jwt token
|
||||
|
|
@ -798,9 +818,9 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Checks if a username and password tuple is valid and the user active.
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {Promise<function>} done
|
||||
* @param {string} username
|
||||
* @param {string} password
|
||||
* @param {Promise<function>} done
|
||||
*/
|
||||
async localAuthCheckUserPw(username, password, done) {
|
||||
// Load the user given it's username
|
||||
|
|
@ -841,8 +861,8 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Hashes a password with bcrypt.
|
||||
* @param {string} password
|
||||
* @returns {Promise<string>} hash
|
||||
* @param {string} password
|
||||
* @returns {Promise<string>} hash
|
||||
*/
|
||||
hashPass(password) {
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -858,8 +878,8 @@ class Auth {
|
|||
|
||||
/**
|
||||
* Return the login info payload for a user
|
||||
*
|
||||
* @param {Object} user
|
||||
*
|
||||
* @param {Object} user
|
||||
* @returns {Promise<Object>} jsonPayload
|
||||
*/
|
||||
async getUserLoginResponsePayload(user) {
|
||||
|
|
@ -874,9 +894,9 @@ class Auth {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} password
|
||||
* @param {import('./models/User')} user
|
||||
*
|
||||
* @param {string} password
|
||||
* @param {import('./models/User')} user
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
comparePassword(password, user) {
|
||||
|
|
@ -887,9 +907,9 @@ class Auth {
|
|||
|
||||
/**
|
||||
* User changes their password from request
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
*/
|
||||
async userChangePassword(req, res) {
|
||||
let { password, newPassword } = req.body
|
||||
|
|
@ -937,4 +957,4 @@ class Auth {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = Auth
|
||||
module.exports = Auth
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ class Server {
|
|||
this.cronManager = new CronManager(this.podcastManager)
|
||||
this.apiCacheManager = new ApiCacheManager()
|
||||
this.binaryManager = new BinaryManager()
|
||||
this.DLNAManager = new DLNAManager()
|
||||
this.DLNAManager = new DLNAManager(this.auth)
|
||||
|
||||
// Routers
|
||||
this.apiRouter = new ApiRouter(this)
|
||||
|
|
@ -101,7 +101,7 @@ class Server {
|
|||
}
|
||||
|
||||
/**
|
||||
* Initialize database, backups, logs, rss feeds, cron jobs & watcher
|
||||
* Initialize , backups, logs, rss feeds, cron jobs & watcher
|
||||
* Cleanup stale/invalid data
|
||||
*/
|
||||
async init() {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,11 @@ class SocketAuthority {
|
|||
|
||||
//DLNA
|
||||
|
||||
socket.on('dlna_start', (data) => this.Server.DLNAManager.start_playback(socket.id, data))
|
||||
socket.on('dlna_start', (player, audiobook, start_time, serverAddress) => this.Server.DLNAManager.start_playback(socket.id, player, audiobook, start_time, serverAddress))
|
||||
socket.on('dlna_exit', () => this.Server.DLNAManager.exit_session(socket.id))
|
||||
socket.on('dlna_play', () => this.Server.DLNAManager.continue_session(socket.id))
|
||||
socket.on('dlna_pause', () => this.Server.DLNAManager.pause_session(socket.id))
|
||||
socket.on('dlna_seek', (time) => this.Server.DLNAManager.seek(socket.id, time))
|
||||
// Logs
|
||||
socket.on('set_log_listener', (level) => Logger.addSocketListener(socket, level))
|
||||
socket.on('remove_log_listener', () => Logger.removeSocketListener(socket.id))
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
class DLNAController {
|
||||
constructor() {}
|
||||
|
||||
async get_Devices(req, res) {
|
||||
var count = 0
|
||||
const players = await this.DLNAManager.Client.players
|
||||
|
||||
const players = await this.DLNAManager.devices
|
||||
return res.status(200).send(players)
|
||||
}
|
||||
middleware(req, res, next) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,80 @@
|
|||
const { clientEmitter } = require('../SocketAuthority')
|
||||
const DLNADevice = require('../objects/DLNADevice')
|
||||
const DLNASession = require('../objects/DLNASession')
|
||||
const SSDPclient = require('node-ssdp').Client
|
||||
const Auth = require('../Auth')
|
||||
class DLNAManager {
|
||||
constructor() {
|
||||
console.log('It worked')
|
||||
this.Client = require('dlnacasts')()
|
||||
this.Client.on('update', function (player) {
|
||||
console.log('all players: ', this.players)
|
||||
})
|
||||
constructor(auth) {
|
||||
this.playback_sessions = []
|
||||
this.devices = []
|
||||
this.ssdpclient = new SSDPclient()
|
||||
this.ssdpclient.on('response', this.add_device.bind(this))
|
||||
setInterval(this.update_sessions.bind(this), 500)
|
||||
this.ssdpclient.search('urn:schemas-upnp-org:service:AVTransport:1')
|
||||
this.auth = auth
|
||||
}
|
||||
|
||||
start_playback(id, data) {
|
||||
console.log(id, data)
|
||||
add_device(header) {
|
||||
console.log('header', header.LOCATION)
|
||||
if (
|
||||
!this.devices ||
|
||||
!this.devices.find((dev) => {
|
||||
return dev.url == header.LOCATION
|
||||
})
|
||||
) {
|
||||
console.log('header', header.LOCATION)
|
||||
this.devices.push(new DLNADevice(header.LOCATION))
|
||||
console.log(this.devices)
|
||||
}
|
||||
}
|
||||
start_playback(id, player, audiobook, start_time, serverAddress) {
|
||||
var user = {
|
||||
id: 42,
|
||||
username: 'DLNA'
|
||||
}
|
||||
var token = this.auth.generateAccessToken(user)
|
||||
this.playback_sessions.push(
|
||||
new DLNASession(
|
||||
id,
|
||||
this.devices.find((pl) => {
|
||||
return pl.UDN == player
|
||||
}),
|
||||
audiobook,
|
||||
start_time,
|
||||
serverAddress,
|
||||
token
|
||||
)
|
||||
)
|
||||
}
|
||||
exit_session(id) {
|
||||
//Stop playback when player is closed
|
||||
console.log('Stop')
|
||||
for (let item of this.playback_sessions) {
|
||||
if (item.socket_id == id) {
|
||||
item.player.pause()
|
||||
}
|
||||
}
|
||||
this.playback_sessions = this.playback_sessions.filter((item) => !(item.socket_id == id))
|
||||
console.log('sessions:', this.playback_sessions)
|
||||
}
|
||||
update_sessions() {
|
||||
for (let pb_session of this.playback_sessions) {
|
||||
pb_session.update()
|
||||
}
|
||||
}
|
||||
pause_session(id) {
|
||||
var session = this.playback_sessions.find((item) => item.socket_id == id)
|
||||
console.log('bla', session)
|
||||
session.player.pause()
|
||||
}
|
||||
continue_session(id) {
|
||||
var session = this.playback_sessions.find((item) => item.socket_id == id)
|
||||
console.log('playing', session)
|
||||
session.player.play()
|
||||
}
|
||||
seek(id, time) {
|
||||
console.log(id, time)
|
||||
var session = this.playback_sessions.find((item) => item.socket_id == id)
|
||||
session.load(time)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
19
server/objects/DLNADevice.js
Normal file
19
server/objects/DLNADevice.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const MediaClient = require('upnp-mediarenderer-client')
|
||||
|
||||
class DLNADevice extends MediaClient {
|
||||
constructor(url) {
|
||||
console.log(url)
|
||||
super(url)
|
||||
this.name = ''
|
||||
this.udn = ''
|
||||
this.getDeviceDescription(this.update_Info.bind(this))
|
||||
}
|
||||
|
||||
update_Info(err, desc) {
|
||||
if (err) throw err
|
||||
this.name = desc.friendlyName
|
||||
this.UDN = desc.UDN
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DLNADevice
|
||||
90
server/objects/DLNASession.js
Normal file
90
server/objects/DLNASession.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
const SocketAuthority = require('../SocketAuthority')
|
||||
const timespan = require('../libs/jsonwebtoken/lib/timespan')
|
||||
const clientEmitter = require('../SocketAuthority')
|
||||
class DLNASession {
|
||||
constructor(id, player, audiobook, start_time, serverAddress, token) {
|
||||
this.socket_id = id
|
||||
this.player = player
|
||||
this.tracks = audiobook
|
||||
this.serverAddress = serverAddress
|
||||
this.playtime = 0
|
||||
this.is_active = true
|
||||
this.token = token
|
||||
this.status = 'PAUSED'
|
||||
console.log(serverAddress)
|
||||
this.trackIndex = null
|
||||
this.player.pause()
|
||||
this.load(start_time)
|
||||
this.player.on('status', function (status) {
|
||||
console.log('Session: ', status)
|
||||
})
|
||||
this.socket = SocketAuthority.clients[this.socket_id].socket
|
||||
}
|
||||
update() {
|
||||
if (this.is_active) {
|
||||
this.player.getTransportInfo(
|
||||
function (err, info) {
|
||||
var status = ''
|
||||
if (info.CurrentTransportState == 'PLAYING') {
|
||||
status = 'PLAYING'
|
||||
} else if (info.CurrentTransportState == 'PAUSED_PLAYBACK') {
|
||||
status = 'PAUSED'
|
||||
} else if (info.CurrentTransportState == 'STOPPED') {
|
||||
var t = this.tracks[this.trackIndex]
|
||||
if (t.duration - this.playtime < 2) {
|
||||
if (this.trackIndex < this.tracks.length) {
|
||||
this.load(this.tracks[this.trackIndex + 1].startOffset)
|
||||
this.playtime = 0
|
||||
return
|
||||
} else {
|
||||
this.socket.emit('dlna_finished')
|
||||
}
|
||||
}
|
||||
}
|
||||
this.player.getPosition(
|
||||
function (err, position) {
|
||||
this.set_status(status, this.trackIndex, position)
|
||||
console.log('pos:', position) // Current position in seconds
|
||||
}.bind(this)
|
||||
)
|
||||
}.bind(this)
|
||||
)
|
||||
}
|
||||
}
|
||||
load(time) {
|
||||
var idx = this.tracks.findIndex((t) => time >= t.startOffset && time < t.startOffset + t.duration)
|
||||
var track = this.tracks[idx]
|
||||
if (this.trackIndex != idx) {
|
||||
this.trackIndex = idx
|
||||
var options = {
|
||||
autoplay: false,
|
||||
contentType: track.mimeType,
|
||||
metadata: {
|
||||
type: 'audio' // can be 'video', 'audio' or 'image'
|
||||
}
|
||||
}
|
||||
var url = this.serverAddress + track.contentUrl + '?token=' + track.userToken
|
||||
console.log(this.tracks[this.trackIndex])
|
||||
console.log(url)
|
||||
this.player.load(
|
||||
url,
|
||||
options,
|
||||
function (err, result) {
|
||||
if (err) throw err
|
||||
console.log('playing ...', this.playtime - track.startOffset)
|
||||
this.player.play()
|
||||
this.player.seek(time - track.startOffset)
|
||||
}.bind(this)
|
||||
)
|
||||
} else {
|
||||
this.player.seek(time - track.startOffset)
|
||||
}
|
||||
}
|
||||
set_status(val, track, position) {
|
||||
this.playtime = position
|
||||
this.status = val
|
||||
this.socket.emit('dlna_status', { status: val, track_idx: track, pos: position })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DLNASession
|
||||
Loading…
Add table
Add a link
Reference in a new issue