From 60b880acdfa85eab5c9ec09ba1283f82ae58ec85 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 30 Dec 2022 10:12:20 +0100 Subject: [PATCH] External auth can update user on login --- .../custom-validators/video-captions.ts | 9 ++-- .../custom-validators/video-imports.ts | 9 ++-- server/initializers/checker-after-init.ts | 3 +- server/initializers/installer.ts | 6 +-- server/lib/auth/external-auth.ts | 18 +++++-- server/lib/auth/oauth-model.ts | 53 ++++++++++++++++++- server/models/video/video-file.ts | 2 +- .../main.js | 9 +++- .../main.js | 13 ++++- server/tests/plugins/external-auth.ts | 34 ++++++++++++ server/tests/plugins/id-and-pass-auth.ts | 34 +++++++++++- server/types/express.d.ts | 1 - server/types/lib.d.ts | 12 +++++ .../plugins/register-server-auth.model.ts | 14 +++++ support/doc/plugins/guide.md | 22 +++++++- 15 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 server/types/lib.d.ts diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts index 59ba005fe..d5b09ea03 100644 --- a/server/helpers/custom-validators/video-captions.ts +++ b/server/helpers/custom-validators/video-captions.ts @@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) { return exists(value) && VIDEO_LANGUAGES[value] !== undefined } -const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) - .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream - .map(m => `(${m})`) - .join('|') +// MacOS sends application/octet-stream +const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ] + .map(m => `(${m})`) + .join('|') + function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { return isFileValid({ files, diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts index af93aea56..da8962cb6 100644 --- a/server/helpers/custom-validators/video-imports.ts +++ b/server/helpers/custom-validators/video-imports.ts @@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) { return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined } -const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) - .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream - .map(m => `(${m})`) - .join('|') +// MacOS sends application/octet-stream +const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ] + .map(m => `(${m})`) + .join('|') + function isVideoImportTorrentFile (files: UploadFilesForCheck) { return isFileValid({ files, diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts index 09e878eee..e6432641b 100644 --- a/server/initializers/checker-after-init.ts +++ b/server/initializers/checker-after-init.ts @@ -174,7 +174,8 @@ function checkRemoteRedundancyConfig () { function checkStorageConfig () { // Check storage directory locations if (isProdInstance()) { - const configStorage = config.get('storage') + const configStorage = config.get<{ [ name: string ]: string }>('storage') + for (const key of Object.keys(configStorage)) { if (configStorage[key].startsWith('storage/')) { logger.warn( diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts index f5d8eedf1..f48f348a7 100644 --- a/server/initializers/installer.ts +++ b/server/initializers/installer.ts @@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () { const tasks: Promise[] = [] // Cache directories - for (const key of Object.keys(cacheDirectories)) { - const dir = cacheDirectories[key] + for (const dir of cacheDirectories) { tasks.push(removeDirectoryOrContent(dir)) } @@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () { } // Cache directories - for (const key of Object.keys(cacheDirectories)) { - const dir = cacheDirectories[key] + for (const dir of cacheDirectories) { tasks.push(ensureDir(dir)) } diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts index 155ec03d8..bc5b74257 100644 --- a/server/lib/auth/external-auth.ts +++ b/server/lib/auth/external-auth.ts @@ -19,6 +19,7 @@ import { RegisterServerExternalAuthenticatedResult } from '@server/types/plugins/register-server-auth.model' import { UserAdminFlag, UserRole } from '@shared/models' +import { BypassLogin } from './oauth-model' export type ExternalUser = Pick & @@ -28,6 +29,7 @@ export type ExternalUser = const authBypassTokens = new Map() @@ -63,7 +65,8 @@ async function onExternalUserAuthenticated (options: { expires, user, npmName, - authName + authName, + userUpdater: authResult.userUpdater }) // Cleanup expired tokens @@ -85,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) { return tokenModel?.authName } -async function getBypassFromPasswordGrant (username: string, password: string) { +async function getBypassFromPasswordGrant (username: string, password: string): Promise { const plugins = PluginManager.Instance.getIdAndPassAuths() const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] @@ -140,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) { bypass: true, pluginName: pluginAuth.npmName, authName: authOptions.authName, - user: buildUserResult(loginResult) + user: buildUserResult(loginResult), + userUpdater: loginResult.userUpdater } } catch (err) { logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) @@ -150,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) { return undefined } -function getBypassFromExternalAuth (username: string, externalAuthToken: string) { +function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin { const obj = authBypassTokens.get(externalAuthToken) if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') @@ -174,6 +178,7 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string) bypass: true, pluginName: npmName, authName, + userUpdater: obj.userUpdater, user } } @@ -194,6 +199,11 @@ function isAuthResultValid (npmName: string, authName: string, result: RegisterS if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota') if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily') + if (result.userUpdater && typeof result.userUpdater !== 'function') { + logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName) + return false + } + return true } diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts index 603cc0f5f..43909284f 100644 --- a/server/lib/auth/oauth-model.ts +++ b/server/lib/auth/oauth-model.ts @@ -1,10 +1,13 @@ import express from 'express' import { AccessDeniedError } from '@node-oauth/oauth2-server' import { PluginManager } from '@server/lib/plugins/plugin-manager' +import { AccountModel } from '@server/models/account/account' +import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types' import { MOAuthClient } from '@server/types/models' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' -import { MUser } from '@server/types/models/user/user' +import { MUser, MUserDefault } from '@server/types/models/user/user' import { pick } from '@shared/core-utils' +import { AttributesOnly } from '@shared/typescript-utils' import { logger } from '../../helpers/logger' import { CONFIG } from '../../initializers/config' import { OAuthClientModel } from '../../models/oauth/oauth-client' @@ -27,6 +30,7 @@ export type BypassLogin = { pluginName: string authName?: string user: ExternalUser + userUpdater: RegisterServerAuthenticatedResult['userUpdater'] } async function getAccessToken (bearerToken: string) { @@ -84,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) let user = await UserModel.loadByEmail(bypassLogin.user.email) + if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) + else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater) // Cannot create a user if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') @@ -234,6 +240,51 @@ async function createUserFromExternal (pluginAuth: string, userOptions: External return user } +async function updateUserFromExternal ( + user: MUserDefault, + userOptions: ExternalUser, + userUpdater: RegisterServerAuthenticatedResult['userUpdater'] +) { + if (!userUpdater) return user + + { + type UserAttributeKeys = keyof AttributesOnly + const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = { + role: 'role', + adminFlags: 'adminFlags', + videoQuota: 'videoQuota', + videoQuotaDaily: 'videoQuotaDaily' + } + + for (const modelKey of Object.keys(mappingKeys)) { + const pluginOptionKey = mappingKeys[modelKey] + + const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] }) + user.set(modelKey, newValue) + } + } + + { + type AccountAttributeKeys = keyof Partial> + const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = { + name: 'displayName' + } + + for (const modelKey of Object.keys(mappingKeys)) { + const optionKey = mappingKeys[modelKey] + + const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] }) + user.Account.set(modelKey, newValue) + } + } + + logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions }) + + user.Account = await user.Account.save() + + return user.save() +} + function checkUserValidityOrThrow (user: MUser) { if (user.blocked) throw new AccessDeniedError('User is blocked.') } diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index 9c4e6d078..9b42955ef 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts @@ -439,7 +439,7 @@ export class VideoFileModel extends Model if (!element) return videoFile.save({ transaction }) for (const k of Object.keys(videoFile.toJSON())) { - element[k] = videoFile[k] + element.set(k, videoFile[k]) } return element.save({ transaction }) diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js index cdbaf11ac..58bc27661 100644 --- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js +++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js @@ -36,7 +36,14 @@ async function register ({ displayName: 'Kefka Palazzo', adminFlags: 1, videoQuota: 42000, - videoQuotaDaily: 42100 + videoQuotaDaily: 42100, + + // Always use new value except for videoQuotaDaily field + userUpdater: ({ fieldName, currentValue, newValue }) => { + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } }) }, hookTokenValidity: (options) => { diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js index ceab7b60d..fad5abf60 100644 --- a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js @@ -33,7 +33,18 @@ async function register ({ if (body.id === 'laguna' && body.password === 'laguna password') { return Promise.resolve({ username: 'laguna', - email: 'laguna@example.com' + email: 'laguna@example.com', + displayName: 'Laguna Loire', + adminFlags: 1, + videoQuota: 42000, + videoQuotaDaily: 42100, + + // Always use new value except for videoQuotaDaily field + userUpdater: ({ fieldName, currentValue, newValue }) => { + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } }) } diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts index ee78ae5aa..e600f958f 100644 --- a/server/tests/plugins/external-auth.ts +++ b/server/tests/plugins/external-auth.ts @@ -51,6 +51,7 @@ describe('Test external auth plugins', function () { let kefkaAccessToken: string let kefkaRefreshToken: string + let kefkaId: number let externalAuthToken: string @@ -184,6 +185,8 @@ describe('Test external auth plugins', function () { expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) expect(body.videoQuota).to.equal(42000) expect(body.videoQuotaDaily).to.equal(42100) + + kefkaId = body.id } }) @@ -246,6 +249,37 @@ describe('Test external auth plugins', function () { expect(body.role.id).to.equal(UserRole.USER) }) + it('Should login Kefka and update the profile', async function () { + { + await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 }) + await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' }) + + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('kefka updated') + expect(body.videoQuota).to.equal(43000) + expect(body.videoQuotaDaily).to.equal(43100) + } + + { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-2', + username: 'kefka' + }) + + kefkaAccessToken = res.access_token + kefkaRefreshToken = res.refresh_token + + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('Kefka Palazzo') + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(43100) + } + }) + it('Should not update an external auth email', async function () { await server.users.updateMe({ token: cyanAccessToken, diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts index fc24a5656..10155c28b 100644 --- a/server/tests/plugins/id-and-pass-auth.ts +++ b/server/tests/plugins/id-and-pass-auth.ts @@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () { let lagunaAccessToken: string let lagunaRefreshToken: string + let lagunaId: number before(async function () { this.timeout(30000) @@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () { const body = await server.users.getMyInfo({ token: lagunaAccessToken }) expect(body.username).to.equal('laguna') - expect(body.account.displayName).to.equal('laguna') + expect(body.account.displayName).to.equal('Laguna Loire') expect(body.role.id).to.equal(UserRole.USER) + + lagunaId = body.id } }) @@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () { expect(body.role.id).to.equal(UserRole.MODERATOR) }) + it('Should login Laguna and update the profile', async function () { + { + await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 }) + await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' }) + + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('laguna updated') + expect(body.videoQuota).to.equal(43000) + expect(body.videoQuotaDaily).to.equal(43100) + } + + { + const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } }) + lagunaAccessToken = body.access_token + lagunaRefreshToken = body.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('Laguna Loire') + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(43100) + } + }) + it('Should reject token of laguna by the plugin hook', async function () { this.timeout(10000) @@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () { await server.servers.waitUntilLog('valid username') await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await server.servers.waitUntilLog('valid display name') + await server.servers.waitUntilLog('valid displayName') await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) await server.servers.waitUntilLog('valid role') diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 99244d2a0..6fea4dac2 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -1,4 +1,3 @@ - import { OutgoingHttpHeaders } from 'http' import { RegisterServerAuthExternalOptions } from '@server/types' import { diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts new file mode 100644 index 000000000..c901e2032 --- /dev/null +++ b/server/types/lib.d.ts @@ -0,0 +1,12 @@ +type ObjectKeys = + T extends object + ? `${Exclude}`[] + : T extends number + ? [] + : T extends any | string + ? string[] + : never + +interface ObjectConstructor { + keys (o: T): ObjectKeys +} diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts index a17fc4b0f..e10968c20 100644 --- a/server/types/plugins/register-server-auth.model.ts +++ b/server/types/plugins/register-server-auth.model.ts @@ -4,15 +4,29 @@ import { MOAuthToken, MUser } from '../models' export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions +export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily' + export interface RegisterServerAuthenticatedResult { + // Update the user profile if it already exists + // Default behaviour is no update + // Introduced in PeerTube >= 5.1 + userUpdater?: (options: { + fieldName: AuthenticatedResultUpdaterFieldName + currentValue: T + newValue: T + }) => T + username: string email: string role?: UserRole displayName?: string + // PeerTube >= 5.1 adminFlags?: UserAdminFlag + // PeerTube >= 5.1 videoQuota?: number + // PeerTube >= 5.1 videoQuotaDaily?: number } diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md index a1131ced5..9ddab3ece 100644 --- a/support/doc/plugins/guide.md +++ b/support/doc/plugins/guide.md @@ -433,7 +433,27 @@ function register (...) { username: 'user' email: 'user@example.com' role: 2 - displayName: 'User display name' + displayName: 'User display name', + + // Custom admin flags (bypass video auto moderation etc.) + // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts + // PeerTube >= 5.1 + adminFlags: 0, + // Quota in bytes + // PeerTube >= 5.1 + videoQuota: 1024 * 1024 * 1024, // 1GB + // PeerTube >= 5.1 + videoQuotaDaily: -1, // Unlimited + + // Update the user profile if it already exists + // Default behaviour is no update + // Introduced in PeerTube >= 5.1 + userUpdater: ({ fieldName, currentValue, newValue }) => { + // Always use new value except for videoQuotaDaily field + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } }) })