This commit is contained in:
David Leimroth 2022-02-08 19:54:48 +01:00 committed by David Leimroth
commit 5a6609150c
8 changed files with 144 additions and 123 deletions

View File

@ -1,6 +1,3 @@
import Vue from 'vue'
const defaultSSOSettings = { const defaultSSOSettings = {
oidc: { oidc: {
issuer: "", issuer: "",
@ -10,7 +7,7 @@ const defaultSSOSettings = {
clientID: "", clientID: "",
clientSecret: "", clientSecret: "",
callbackURL: "/oidc/callback", callbackURL: "/oidc/callback",
scope: "openid email profile" scope: "openid email profile"
}, },
user: { user: {
createNewUser: false, createNewUser: false,

View File

@ -13,12 +13,12 @@ if (isDev) {
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath process.env.FFMPEG_PATH = devEnv.FFmpegPath
process.env.FFPROBE_PATH = devEnv.FFProbePath process.env.FFPROBE_PATH = devEnv.FFProbePath
process.env.OIDC_CLIENT_ID = devEnv.OIDC.ClientID process.env.OIDC_CLIENT_ID = devEnv.OIDC ? devEnv.OIDC.ClientID : ''
process.env.OIDC_CLIENT_SECRET = devEnv.OIDC.ClientSecret process.env.OIDC_CLIENT_SECRET = devEnv.OIDC ? devEnv.OIDC.ClientSecret : ''
process.env.OIDC_ISSUER = devEnv.OIDC.Issuer process.env.OIDC_ISSUER = devEnv.OIDC ? devEnv.OIDC.Issuer : ''
process.env.OIDC_AUTHORIZATION_URL = devEnv.OIDC.AuthorizationURL process.env.OIDC_AUTHORIZATION_URL = devEnv.OIDC ? devEnv.OIDC.AuthorizationURL : ''
process.env.OIDC_TOKEN_URL = devEnv.OIDC.TokenURL process.env.OIDC_TOKEN_URL = devEnv.OIDC ? devEnv.OIDC.TokenURL : ''
process.env.OIDC_USER_INFO_URL = devEnv.OIDC.UserInfoURL process.env.OIDC_USER_INFO_URL = devEnv.OIDC ? devEnv.OIDC.UserInfoURL : ''
} }
const PORT = process.env.PORT || 80 const PORT = process.env.PORT || 80

17
njodb.properties Normal file
View File

@ -0,0 +1,17 @@
{
"datadir": "data",
"dataname": "data",
"datastores": 2,
"tempdir": "tmp",
"lockoptions": {
"stale": 5000,
"update": 1000,
"retries": {
"retries": 5000,
"minTimeout": 250,
"maxTimeout": 5000,
"factor": 0.15,
"randomize": false
}
}
}

View File

@ -1,7 +1,7 @@
const bcrypt = require('bcryptjs') const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const passport = require('passport')
const Logger = require('./Logger') const Logger = require('./Logger')
const User = require('./objects/User')
class Auth { class Auth {
constructor(db) { constructor(db) {
@ -52,7 +52,7 @@ class Auth {
} }
req.user = user req.user = user
return next(); return next();
} }
@ -213,5 +213,34 @@ class Auth {
}) })
} }
} }
async handleOIDCVerification(issuer, profile, cb) {
Logger.debug(`[Auth] handleOIDCVerification ${issuer}`)
let user = this.db.users.find(u => u.id === profile.id)
if (!user && this.db.SSOSettings.createNewUser) {
// create a user
let account = {}
account.id = profile.id
account.username = profile.username
account.isActive = true
account.type = "guest"
account.permissions = this.db.SSOSettings.getNewUserPermissions()
account.pash = await this.hashPass(getId(profile.id))
account.token = await this.generateAccessToken({ userId: account.id })
account.createdAt = Date.now()
user = new User(account)
const success = await this.db.insertEntity('user', user)
if (!success) {
cb('Failed to save new user')
}
}
if (!user || !user.isActive) {
Logger.debug(`[Auth] Failed login attempt`)
cb("Invalid user or password")
return
}
cb(null, user)
}
} }
module.exports = Auth module.exports = Auth

View File

@ -31,7 +31,6 @@ class Db {
this.sessionsDb = new njodb.Database(this.SessionsPath) this.sessionsDb = new njodb.Database(this.SessionsPath)
this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 }) this.librariesDb = new njodb.Database(this.LibrariesPath, { datastores: 2 })
this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 }) this.settingsDb = new njodb.Database(this.SettingsPath, { datastores: 2 })
this.SSODb = new njodb.Database(this.SSODb, { datastores: 2 })
this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 }) this.collectionsDb = new njodb.Database(this.CollectionsPath, { datastores: 2 })
this.authorsDb = new njodb.Database(this.AuthorsPath) this.authorsDb = new njodb.Database(this.AuthorsPath)
@ -132,6 +131,11 @@ class Db {
this.serverSettings = new ServerSettings() this.serverSettings = new ServerSettings()
await this.insertEntity('settings', this.serverSettings) await this.insertEntity('settings', this.serverSettings)
} }
if (!this.SSOSettings) {
this.SSOSettings = new SSOSettings()
await this.insertEntity('settings', this.SSOSettings)
}
} }
async load() { async load() {
@ -150,6 +154,7 @@ class Db {
let p4 = this.settingsDb.select(() => true).then((results) => { let p4 = this.settingsDb.select(() => true).then((results) => {
if (results.data && results.data.length) { if (results.data && results.data.length) {
this.settings = results.data this.settings = results.data
let serverSettings = this.settings.find(s => s.id === 'server-settings') let serverSettings = this.settings.find(s => s.id === 'server-settings')
if (serverSettings) { if (serverSettings) {
this.serverSettings = new ServerSettings(serverSettings) this.serverSettings = new ServerSettings(serverSettings)
@ -159,6 +164,11 @@ class Db {
this.previousVersion = this.serverSettings.version || '1.0.0' this.previousVersion = this.serverSettings.version || '1.0.0'
} }
} }
let ssoSettings = this.settings.find(s => s.id === 'sso-settings')
if (ssoSettings) {
this.SSOSettings = new SSOSettings(ssoSettings)
}
} }
}) })
let p5 = this.collectionsDb.select(() => true).then((results) => { let p5 = this.collectionsDb.select(() => true).then((results) => {
@ -169,10 +179,7 @@ class Db {
this.authors = results.data.map(l => new Author(l)) this.authors = results.data.map(l => new Author(l))
Logger.info(`[DB] ${this.authors.length} Authors Loaded`) Logger.info(`[DB] ${this.authors.length} Authors Loaded`)
}) })
let p7 = this.SSODb.select(() => true).then((results) => { await Promise.all([p1, p2, p3, p4, p5, p6])
this.SSOSettings = new SSOSettings(results.data)
})
await Promise.all([p1, p2, p3, p4, p5, p6, p7])
// Update server version in server settings // Update server version in server settings
if (this.previousVersion) { if (this.previousVersion) {

View File

@ -7,8 +7,8 @@ const SocketIO = require('socket.io')
const fs = require('fs-extra') const fs = require('fs-extra')
const fileUpload = require('express-fileupload') const fileUpload = require('express-fileupload')
const rateLimit = require('express-rate-limit') const rateLimit = require('express-rate-limit')
const passport = require('passport'); const passport = require('passport')
const OidcStrategy = require('passport-openidconnect').Strategy; const OidcStrategy = require('passport-openidconnect').Strategy
const { version } = require('../package.json') const { version } = require('../package.json')
@ -32,7 +32,6 @@ const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager') const DownloadManager = require('./DownloadManager')
const CoverController = require('./CoverController') const CoverController = require('./CoverController')
const CacheManager = require('./CacheManager') const CacheManager = require('./CacheManager')
const User = require('./objects/User')
class Server { class Server {
constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
@ -44,10 +43,6 @@ class Server {
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH) this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
this.MetadataPath = Path.normalize(METADATA_PATH) this.MetadataPath = Path.normalize(METADATA_PATH)
console.info(this.ConfigPath)
console.info(this.MetadataPath)
console.info(this.AudiobookPath)
fs.ensureDirSync(CONFIG_PATH, 0o774) fs.ensureDirSync(CONFIG_PATH, 0o774)
fs.ensureDirSync(METADATA_PATH, 0o774) fs.ensureDirSync(METADATA_PATH, 0o774)
fs.ensureDirSync(AUDIOBOOK_PATH, 0o774) fs.ensureDirSync(AUDIOBOOK_PATH, 0o774)
@ -74,55 +69,6 @@ class Server {
this.io = null this.io = null
this.clients = {} this.clients = {}
passport.serializeUser((user, next) => {
next(null, {userId: user.id});
});
passport.deserializeUser((obj, next) => {
const user = this.db.users.find(u => u.id === obj.userId)
next(null, obj);
});
passport.use(new OidcStrategy({
issuer: process.env.OIDC_ISSUER,
authorizationURL: process.env.OIDC_AUTHORIZATION_URL,
tokenURL: process.env.OIDC_TOKEN_URL,
userInfoURL: process.env.OIDC_USER_INFO_URL,
clientID: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
callbackURL: '/oidc/callback',
scope: "openid email profile"
}, async (issuer, profile, cb) => {
let user = this.db.users.find(u => u.id === profile.id)
if (!user) {
// create a user
let account = {}
account.id = profile.id
account.username = profile.username
account.type = "guest"
account.permissions = {
download: false,
update: false,
delete: false,
upload: false,
accessAllLibraries: false
}
account.pash = await this.auth.hashPass(getId(profile.id))
account.token = await this.auth.generateAccessToken({ userId: account.id })
account.createdAt = Date.now()
user = new User(account)
const success = await this.db.insertEntity('user', user)
if (!success) {
cb('Failed to save new user')
}
}
if (!user || !user.isActive) {
Logger.debug(`[Auth] Failed login attempt`)
cb("Invalid user or password")
return
}
cb(null, user)
})
)
} }
get audiobooks() { get audiobooks() {
@ -187,6 +133,26 @@ class Server {
this.watcher.initWatcher(this.libraries) this.watcher.initWatcher(this.libraries)
this.watcher.on('files', this.filesChanged.bind(this)) this.watcher.on('files', this.filesChanged.bind(this))
this.passportInit()
}
passportInit() {
if (this.db.SSOSettings.isOIDCConfigured) {
Logger.debug(`[Server] passportInit OIDC is configured - init`)
passport.serializeUser((user, next) => {
next(null, user);
})
passport.deserializeUser((obj, next) => {
next(null, obj);
})
// Initialize passport OIDC verification
passport.use(new OidcStrategy(this.db.SSOSettings.getOIDCSettings(), this.auth.handleOIDCVerification))
} else {
Logger.debug(`[Server] passportInit OIDC not configured`)
}
} }
async start() { async start() {
@ -219,7 +185,7 @@ class Server {
token = authHeader && authHeader.split(' ')[1] token = authHeader && authHeader.split(' ')[1]
} }
*/ */
// Static path to generated nuxt // Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist') const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath)) app.use(express.static(distPath))
@ -296,10 +262,10 @@ class Server {
app.get("/oidc/login", passport.authenticate('openidconnect')) app.get("/oidc/login", passport.authenticate('openidconnect'))
app.get("/oidc/callback", app.get("/oidc/callback",
passport.authenticate('openidconnect', { failureRedirect: '/oidc/login', failureMessage: true }), passport.authenticate('openidconnect', { failureRedirect: '/oidc/login', failureMessage: true }),
async (req, res) => { async (req, res) => {
const token = this.auth.generateAccessToken({userId: req.user.id}) const token = this.auth.generateAccessToken({ userId: req.user.id })
res.cookie('sso', true, { httpOnly: false /* TODO: Set secure: true */ }); res.cookie('sso', true, { httpOnly: false /* TODO: Set secure: true */ });
res.redirect('/'); res.redirect('/');

View File

@ -1,68 +1,73 @@
const { CoverDestination, BookCoverAspectRatio, BookshelfView } = require('../utils/constants')
const Logger = require('../Logger') const Logger = require('../Logger')
const User = require('./User') const User = require('./User')
const { isObject } = require('../utils')
const defaultSettings = { const defaultSettings = {
oidc: { oidc: {
issuer: "", issuer: process.env.OIDC_ISSUER || '',
authorizationURL: "", authorizationURL: process.env.OIDC_AUTHORIZATION_URL || '',
tokenURL: "", tokenURL: process.env.OIDC_TOKEN_URL || '',
userInfoURL: "", userInfoURL: process.env.OIDC_USER_INFO_URL || '',
clientID: "", clientID: process.env.OIDC_CLIENT_ID || '',
clientSecret: "", clientSecret: process.env.OIDC_CLIENT_SECRET || '',
callbackURL: "/oidc/callback", callbackURL: "/oidc/callback",
scope: "openid email profile" scope: "openid email profile"
}, },
user: { createNewUser: false,
createNewUser: false, userPermissions: User.getDefaultUserPermissions('guest')
isActive: true,
userSettings: {
mobileOrderBy: 'recent',
mobileOrderDesc: true,
mobileFilterBy: 'all',
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120,
collapseSeries: false
},
permissions: {
download: false,
update: false,
delete: false,
upload: false,
accessAllLibraries: false
}
}
} }
class SSOSettings { class SSOSettings {
constructor(settings=defaultSettings) { constructor(settings = defaultSettings) {
this.id = 'sso-settings' this.id = 'sso-settings'
this.oidc = {...settings.oidc} this.oidc = { ...settings.oidc }
this.user = {...settings.user} this.createNewUser = !!settings.createNewUser
this.userPermissions = { ...settings.userPermissions }
}
get isOIDCConfigured() {
// Check required OIDC settings are set
return !['issue', 'authorizationURL', 'tokenURL', 'clientID', 'clientSecret'].some(key => !this.oidc[key])
} }
toJSON() { toJSON() {
return { return {
oidc: {...this.oidc}, id: this.id,
user: {...this.user} oidc: { ...this.oidc },
createNewUser: this.createNewUser,
userPermissions: { ...this.userPermissions }
} }
} }
update(payload) { update(payload) {
let hasUpdates = false let hasUpdates = false
for (const key in payload) { for (const key in payload) {
for (const setting in payload) { if (isObject(payload[key])) {
if (!this[key] || this[key][setting] === payload[key][setting]) { for (const setting in payload[key]) {
continue if (!this[key] || this[key][setting] === payload[key][setting]) {
continue
}
this[key][setting] = payload[key][setting]
hasUpdates = true
} }
this[key][setting] = payload[key][setting] } else if (this[key] !== undefined && this[key] !== payload[key]) {
this[key] = payload[key]
hasUpdates = true hasUpdates = true
} }
} }
return hasUpdates return hasUpdates
} }
getNewUserPermissions() {
return {
...this.userPermissions
}
}
getOIDCSettings() {
return {
...this.oidc
}
}
} }
module.exports = SSOSettings module.exports = SSOSettings

View File

@ -46,7 +46,7 @@ class User {
return !!this.pash && !!this.pash.length return !!this.pash && !!this.pash.length
} }
getDefaultUserSettings() { static getDefaultUserSettings() {
return { return {
mobileOrderBy: 'recent', mobileOrderBy: 'recent',
mobileOrderDesc: true, mobileOrderDesc: true,
@ -60,12 +60,12 @@ class User {
} }
} }
getDefaultUserPermissions() { static getDefaultUserPermissions(type) {
return { return {
download: true, download: type !== 'guest',
update: true, update: type !== 'guest',
delete: this.type === 'root', delete: type === 'root',
upload: this.type === 'root' || this.type === 'admin', upload: type === 'root' || type === 'admin',
accessAllLibraries: true accessAllLibraries: true
} }
} }
@ -152,8 +152,8 @@ class User {
this.isLocked = user.type === 'root' ? false : !!user.isLocked this.isLocked = user.type === 'root' ? false : !!user.isLocked
this.lastSeen = user.lastSeen || null this.lastSeen = user.lastSeen || null
this.createdAt = user.createdAt || Date.now() this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings() this.settings = user.settings || User.getDefaultUserSettings()
this.permissions = user.permissions || this.getDefaultUserPermissions() this.permissions = user.permissions || User.getDefaultUserPermissions(this.type)
// Upload permission added v1.1.13, make sure root user has upload permissions // Upload permission added v1.1.13, make sure root user has upload permissions
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true