diff --git a/client/store/sso.js b/client/store/sso.js index 047a151d8..7cb806c15 100644 --- a/client/store/sso.js +++ b/client/store/sso.js @@ -1,6 +1,3 @@ - -import Vue from 'vue' - const defaultSSOSettings = { oidc: { issuer: "", @@ -10,7 +7,7 @@ const defaultSSOSettings = { clientID: "", clientSecret: "", callbackURL: "/oidc/callback", - scope: "openid email profile" + scope: "openid email profile" }, user: { createNewUser: false, diff --git a/index.js b/index.js index 1e551ab6f..d142342e8 100644 --- a/index.js +++ b/index.js @@ -13,12 +13,12 @@ if (isDev) { process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath process.env.FFMPEG_PATH = devEnv.FFmpegPath process.env.FFPROBE_PATH = devEnv.FFProbePath - process.env.OIDC_CLIENT_ID = devEnv.OIDC.ClientID - process.env.OIDC_CLIENT_SECRET = devEnv.OIDC.ClientSecret - process.env.OIDC_ISSUER = devEnv.OIDC.Issuer - process.env.OIDC_AUTHORIZATION_URL = devEnv.OIDC.AuthorizationURL - process.env.OIDC_TOKEN_URL = devEnv.OIDC.TokenURL - process.env.OIDC_USER_INFO_URL = devEnv.OIDC.UserInfoURL + process.env.OIDC_CLIENT_ID = devEnv.OIDC ? devEnv.OIDC.ClientID : '' + process.env.OIDC_CLIENT_SECRET = devEnv.OIDC ? devEnv.OIDC.ClientSecret : '' + process.env.OIDC_ISSUER = devEnv.OIDC ? devEnv.OIDC.Issuer : '' + process.env.OIDC_AUTHORIZATION_URL = devEnv.OIDC ? devEnv.OIDC.AuthorizationURL : '' + process.env.OIDC_TOKEN_URL = devEnv.OIDC ? devEnv.OIDC.TokenURL : '' + process.env.OIDC_USER_INFO_URL = devEnv.OIDC ? devEnv.OIDC.UserInfoURL : '' } const PORT = process.env.PORT || 80 diff --git a/njodb.properties b/njodb.properties new file mode 100644 index 000000000..340c250fa --- /dev/null +++ b/njodb.properties @@ -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 + } + } +} \ No newline at end of file diff --git a/server/Auth.js b/server/Auth.js index 5dd777cdf..1c736f8d5 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,7 +1,7 @@ const bcrypt = require('bcryptjs') const jwt = require('jsonwebtoken') -const passport = require('passport') const Logger = require('./Logger') +const User = require('./objects/User') class Auth { constructor(db) { @@ -52,7 +52,7 @@ class Auth { } req.user = user - + 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 \ No newline at end of file diff --git a/server/Db.js b/server/Db.js index d005612b5..50221c3b4 100644 --- a/server/Db.js +++ b/server/Db.js @@ -31,7 +31,6 @@ class Db { this.sessionsDb = new njodb.Database(this.SessionsPath) this.librariesDb = new njodb.Database(this.LibrariesPath, { 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.authorsDb = new njodb.Database(this.AuthorsPath) @@ -132,6 +131,11 @@ class Db { this.serverSettings = new ServerSettings() await this.insertEntity('settings', this.serverSettings) } + + if (!this.SSOSettings) { + this.SSOSettings = new SSOSettings() + await this.insertEntity('settings', this.SSOSettings) + } } async load() { @@ -150,6 +154,7 @@ class Db { let p4 = this.settingsDb.select(() => true).then((results) => { if (results.data && results.data.length) { this.settings = results.data + let serverSettings = this.settings.find(s => s.id === 'server-settings') if (serverSettings) { this.serverSettings = new ServerSettings(serverSettings) @@ -159,6 +164,11 @@ class Db { 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) => { @@ -169,10 +179,7 @@ class Db { this.authors = results.data.map(l => new Author(l)) Logger.info(`[DB] ${this.authors.length} Authors Loaded`) }) - let p7 = this.SSODb.select(() => true).then((results) => { - this.SSOSettings = new SSOSettings(results.data) - }) - await Promise.all([p1, p2, p3, p4, p5, p6, p7]) + await Promise.all([p1, p2, p3, p4, p5, p6]) // Update server version in server settings if (this.previousVersion) { diff --git a/server/Server.js b/server/Server.js index e7c43b8fb..2eee785bf 100644 --- a/server/Server.js +++ b/server/Server.js @@ -7,8 +7,8 @@ const SocketIO = require('socket.io') const fs = require('fs-extra') const fileUpload = require('express-fileupload') const rateLimit = require('express-rate-limit') -const passport = require('passport'); -const OidcStrategy = require('passport-openidconnect').Strategy; +const passport = require('passport') +const OidcStrategy = require('passport-openidconnect').Strategy const { version } = require('../package.json') @@ -32,7 +32,6 @@ const RssFeeds = require('./RssFeeds') const DownloadManager = require('./DownloadManager') const CoverController = require('./CoverController') const CacheManager = require('./CacheManager') -const User = require('./objects/User') class Server { constructor(PORT, UID, GID, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { @@ -44,10 +43,6 @@ class Server { this.AudiobookPath = Path.normalize(AUDIOBOOK_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(METADATA_PATH, 0o774) fs.ensureDirSync(AUDIOBOOK_PATH, 0o774) @@ -74,55 +69,6 @@ class Server { this.io = null 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() { @@ -187,6 +133,26 @@ class Server { this.watcher.initWatcher(this.libraries) 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() { @@ -219,7 +185,7 @@ class Server { token = authHeader && authHeader.split(' ')[1] } */ - + // Static path to generated nuxt const distPath = Path.join(global.appRoot, '/client/dist') app.use(express.static(distPath)) @@ -296,10 +262,10 @@ class Server { app.get("/oidc/login", passport.authenticate('openidconnect')) - app.get("/oidc/callback", + app.get("/oidc/callback", passport.authenticate('openidconnect', { failureRedirect: '/oidc/login', failureMessage: true }), 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.redirect('/'); diff --git a/server/objects/SSOSettings.js b/server/objects/SSOSettings.js index 60a114005..76cc58c72 100644 --- a/server/objects/SSOSettings.js +++ b/server/objects/SSOSettings.js @@ -1,68 +1,73 @@ -const { CoverDestination, BookCoverAspectRatio, BookshelfView } = require('../utils/constants') const Logger = require('../Logger') const User = require('./User') +const { isObject } = require('../utils') const defaultSettings = { oidc: { - issuer: "", - authorizationURL: "", - tokenURL: "", - userInfoURL: "", - clientID: "", - clientSecret: "", + 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" + scope: "openid email profile" }, - user: { - createNewUser: false, - 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 - } - } + createNewUser: false, + userPermissions: User.getDefaultUserPermissions('guest') } class SSOSettings { - constructor(settings=defaultSettings) { + constructor(settings = defaultSettings) { this.id = 'sso-settings' - this.oidc = {...settings.oidc} - this.user = {...settings.user} + this.oidc = { ...settings.oidc } + 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() { return { - oidc: {...this.oidc}, - user: {...this.user} + id: this.id, + oidc: { ...this.oidc }, + createNewUser: this.createNewUser, + userPermissions: { ...this.userPermissions } } } update(payload) { let hasUpdates = false for (const key in payload) { - for (const setting in payload) { - if (!this[key] || this[key][setting] === payload[key][setting]) { - continue + if (isObject(payload[key])) { + for (const setting in payload[key]) { + 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 } } return hasUpdates } + + getNewUserPermissions() { + return { + ...this.userPermissions + } + } + + getOIDCSettings() { + return { + ...this.oidc + } + } } module.exports = SSOSettings \ No newline at end of file diff --git a/server/objects/User.js b/server/objects/User.js index 2a946fde2..ddb53b8c2 100644 --- a/server/objects/User.js +++ b/server/objects/User.js @@ -46,7 +46,7 @@ class User { return !!this.pash && !!this.pash.length } - getDefaultUserSettings() { + static getDefaultUserSettings() { return { mobileOrderBy: 'recent', mobileOrderDesc: true, @@ -60,12 +60,12 @@ class User { } } - getDefaultUserPermissions() { + static getDefaultUserPermissions(type) { return { - download: true, - update: true, - delete: this.type === 'root', - upload: this.type === 'root' || this.type === 'admin', + download: type !== 'guest', + update: type !== 'guest', + delete: type === 'root', + upload: type === 'root' || type === 'admin', accessAllLibraries: true } } @@ -152,8 +152,8 @@ class User { this.isLocked = user.type === 'root' ? false : !!user.isLocked this.lastSeen = user.lastSeen || null this.createdAt = user.createdAt || Date.now() - this.settings = user.settings || this.getDefaultUserSettings() - this.permissions = user.permissions || this.getDefaultUserPermissions() + this.settings = user.settings || User.getDefaultUserSettings() + this.permissions = user.permissions || User.getDefaultUserPermissions(this.type) // Upload permission added v1.1.13, make sure root user has upload permissions if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true