From ee75d672e6d46c324f817e949aea72cd487d04b4 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 8 Nov 2023 16:14:57 -0600 Subject: [PATCH] Matching user by openid sub, email or username based on server settings. Auto register user. Persist sub on User records --- server/Auth.js | 53 ++++++++++++++++--- server/models/User.js | 102 +++++++++++++++++++++++++++++++----- server/objects/user/User.js | 9 +++- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 361380f8..eeb7ad47 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -82,14 +82,51 @@ class Auth { scope: 'openid profile email' } }, async (tokenset, userinfo, done) => { - // TODO: Here is where to lookup the Abs user or register a new Abs user Logger.debug(`[Auth] openid callback userinfo=`, userinfo) - let user = null - // TODO: Temporary lookup existing user by email. May be replaced by a setting to toggle this or use name - if (userinfo.email && userinfo.email_verified) { - user = await Database.userModel.getUserByEmail(userinfo.email) - // TODO: If using existing user then save userinfo.sub on user + if (!userinfo.sub) { + Logger.error(`[Auth] openid callback invalid userinfo, no sub`) + return done(null, null) + } + + // 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: Show some error log? + 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: Show some error log? + 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) { @@ -368,7 +405,7 @@ class Auth { /** * Function to generate a jwt token for a given user * - * @param {Object} user + * @param {{ id:string, username:string }} user * @returns {string} token */ generateAccessToken(user) { @@ -405,7 +442,7 @@ 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 }) + user.token = await this.generateAccessToken(user) } await Database.updateBulkUsers(users) } diff --git a/server/models/User.js b/server/models/User.js index d3028d9a..4c348f42 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -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} + */ static createFromOld(oldUser) { const user = this.getFromOld(oldUser) return this.create(user) } + /** + * Update User from old user model + * + * @param {oldUser} oldUser + * @returns {Promise} + */ 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} */ 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} + */ + 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,7 @@ class User extends Model { const user = await this.findOne({ where: { username: { - [Op.like]: username + [sequelize.Op.like]: username } }, include: this.sequelize.models.mediaProgress @@ -206,7 +269,7 @@ class User extends Model { const user = await this.findOne({ where: { email: { - [Op.like]: email + [sequelize.Op.like]: email } }, include: this.sequelize.models.mediaProgress @@ -229,6 +292,21 @@ class User extends Model { return this.getOldUser(user) } + /** + * Get user by openid sub + * @param {string} sub + * @returns {Promise} 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 } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index 5192752a..b503872d 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -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) {