Forráskód Böngészése

External auth can update user on login

Chocobozzz 1 éve
szülő
commit
60b880acdf

+ 5 - 4
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,

+ 5 - 4
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,

+ 2 - 1
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(

+ 2 - 4
server/initializers/installer.ts

@@ -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))
   }
 

+ 14 - 4
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<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
 }
 

+ 52 - 1
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<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.')
 }

+ 1 - 1
server/models/video/video-file.ts

@@ -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 })

+ 8 - 1
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) => {

+ 12 - 1
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
+          }
         })
       }
 

+ 34 - 0
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,

+ 32 - 2
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')

+ 0 - 1
server/types/express.d.ts

@@ -1,4 +1,3 @@
-
 import { OutgoingHttpHeaders } from 'http'
 import { RegisterServerAuthExternalOptions } from '@server/types'
 import {

+ 12 - 0
server/types/lib.d.ts

@@ -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>
+}

+ 14 - 0
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?: <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
 }
 

+ 21 - 1
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
+      }
     })
   })