]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
External auth can update user on login
authorChocobozzz <me@florianbigard.com>
Fri, 30 Dec 2022 09:12:20 +0000 (10:12 +0100)
committerChocobozzz <me@florianbigard.com>
Wed, 4 Jan 2023 10:41:29 +0000 (11:41 +0100)
15 files changed:
server/helpers/custom-validators/video-captions.ts
server/helpers/custom-validators/video-imports.ts
server/initializers/checker-after-init.ts
server/initializers/installer.ts
server/lib/auth/external-auth.ts
server/lib/auth/oauth-model.ts
server/models/video/video-file.ts
server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js
server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js
server/tests/plugins/external-auth.ts
server/tests/plugins/id-and-pass-auth.ts
server/types/express.d.ts
server/types/lib.d.ts [new file with mode: 0644]
server/types/plugins/register-server-auth.model.ts
support/doc/plugins/guide.md

index 59ba005fe3f4b8d8feb152b9e210b559c72feac5..d5b09ea03628a998a921958bb72fec5c20c572b4 100644 (file)
@@ -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,
index af93aea56062a86ca17b1e441c8b9593de123632..da8962cb6dace3b340f3d478fa6adf6ef4df4e0b 100644 (file)
@@ -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,
index 09e878eee27df0b42f610d838b23aa9dd5d36587..e6432641b1033680d14d99be1b014a55d63cefdb 100644 (file)
@@ -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(
index f5d8eedf1916a687333ebb118093b6f328466083..f48f348a7bc118a91937b774b7eb1ae4fc92ae46 100644 (file)
@@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () {
   const tasks: Promise<any>[] = []
 
   // 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))
   }
 
index 155ec03d826ef0b43c4d966ec254d054de640fa2..bc5b74257a2a12c559040c5de8690803a2f4e0b4 100644 (file)
@@ -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<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
@@ -28,6 +29,7 @@ export type ExternalUser =
 const authBypassTokens = new Map<string, {
   expires: Date
   user: ExternalUser
+  userUpdater: RegisterServerAuthenticatedResult['userUpdater']
   authName: string
   npmName: string
 }>()
@@ -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<BypassLogin> {
   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
 }
 
index 603cc0f5f541b473f137d047a9f75dc118ceeb17..43909284f8c10b283d36bc490110b240ff5f7da4 100644 (file)
@@ -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<UserModel>
+    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<AttributesOnly<AccountModel>>
+    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.')
 }
index 9c4e6d078afbd5d61057194ce95180330e99995c..9b42955efa654f981e7a1a9328b6a942e787ec83 100644 (file)
@@ -439,7 +439,7 @@ export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>
     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 })
index cdbaf11ac3486aeaaa7be97c912e9b125bc65172..58bc27661b321bf6d6d46391473f1a7f7f69864b 100644 (file)
@@ -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) => {
index ceab7b60de3a96284e0ed688aaac6b9b2da9aebf..fad5abf60e4831f00975a230626f06fbd081d513 100644 (file)
@@ -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
+          }
         })
       }
 
index ee78ae5aa230e407cfc63c39d0d2500d3fd59830..e600f958f89bb68657713cadbf64f17387d2abc0 100644 (file)
@@ -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,
index fc24a56564b2a5f638446257a333a028c5752b3e..10155c28b667be5087fc4d5b28e56b75c2905d82 100644 (file)
@@ -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')
index 99244d2a04c5c63326a087e4f70f224216419ecf..6fea4dac24bffc8a20d3fb6a7167d375c87ef54d 100644 (file)
@@ -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 (file)
index 0000000..c901e20
--- /dev/null
@@ -0,0 +1,12 @@
+type ObjectKeys<T> =
+  T extends object
+    ? `${Exclude<keyof T, symbol>}`[]
+    : T extends number
+      ? []
+      : T extends any | string
+        ? string[]
+        : never
+
+interface ObjectConstructor {
+  keys<T> (o: T): ObjectKeys<T>
+}
index a17fc4b0fca7367344ccb1bb38a59351ed13a34d..e10968c20f507be8404182eac8f971e169d1b366 100644 (file)
@@ -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?: <T> (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
 }
 
index a1131ced542f4bf3e64a94a473f455c3b8a5debf..9ddab3ece7c89da363e03ea2efeb273de3610735 100644 (file)
@@ -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
+      }
     })
   })