From b65f5367baf799b425be0bcfb9220922751bb6eb Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 29 Dec 2022 14:18:07 +0100 Subject: [PATCH 1/4] Add ability to customize token lifetime --- config/default.yaml | 5 + config/production.yaml.example | 5 + server/initializers/checker-before-init.ts | 1 + server/initializers/config.ts | 6 + server/initializers/constants.ts | 6 - server/lib/auth/oauth.ts | 14 +- server/lib/auth/tokens-cache.ts | 8 +- server/tests/api/users/index.ts | 1 + server/tests/api/users/oauth.ts | 192 ++++++++++++++++++++ server/tests/api/users/users.ts | 184 +------------------ shared/server-commands/requests/requests.ts | 2 +- 11 files changed, 229 insertions(+), 195 deletions(-) create mode 100644 server/tests/api/users/oauth.ts diff --git a/config/default.yaml b/config/default.yaml index 1b7c3314d..d4977d003 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -37,6 +37,11 @@ rates_limit: window: 10 minutes max: 10 +oauth2: + token_lifetime: + access_token: '1 day' + refresh_token: '2 weeks' + # Proxies to trust to get real client IP # If you run PeerTube just behind a local proxy (nginx), keep 'loopback' # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) diff --git a/config/production.yaml.example b/config/production.yaml.example index da067b3b5..17dc6839b 100644 --- a/config/production.yaml.example +++ b/config/production.yaml.example @@ -35,6 +35,11 @@ rates_limit: window: 10 minutes max: 10 +oauth2: + token_lifetime: + access_token: '1 day' + refresh_token: '2 weeks' + # Proxies to trust to get real client IP # If you run PeerTube just behind a local proxy (nginx), keep 'loopback' # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 39713a266..57852241c 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -13,6 +13,7 @@ function checkMissedConfig () { 'webserver.https', 'webserver.hostname', 'webserver.port', 'secrets.peertube', 'trust_proxy', + 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token', 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 'email.body.signature', 'email.subject.prefix', diff --git a/server/initializers/config.ts b/server/initializers/config.ts index c2f8b19fd..28aaf36a9 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -149,6 +149,12 @@ const CONFIG = { HOSTNAME: config.get('webserver.hostname'), PORT: config.get('webserver.port') }, + OAUTH2: { + TOKEN_LIFETIME: { + ACCESS_TOKEN: parseDurationToMs(config.get('oauth2.token_lifetime.access_token')), + REFRESH_TOKEN: parseDurationToMs(config.get('oauth2.token_lifetime.refresh_token')) + } + }, RATES_LIMIT: { API: { WINDOW_MS: parseDurationToMs(config.get('rates_limit.api.window')), diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index ec5045078..0dab524d9 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -101,11 +101,6 @@ const SORTABLE_COLUMNS = { VIDEO_REDUNDANCIES: [ 'name' ] } -const OAUTH_LIFETIME = { - ACCESS_TOKEN: 3600 * 24, // 1 day, for upload - REFRESH_TOKEN: 1209600 // 2 weeks -} - const ROUTE_CACHE_LIFETIME = { FEEDS: '15 minutes', ROBOTS: '2 hours', @@ -1033,7 +1028,6 @@ export { JOB_ATTEMPTS, AP_CLEANER, LAST_MIGRATION_VERSION, - OAUTH_LIFETIME, CUSTOM_HTML_TAG_COMMENTS, STATS_TIMESERIE, BROADCAST_CONCURRENCY, diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts index bc0d4301f..2905c79a2 100644 --- a/server/lib/auth/oauth.ts +++ b/server/lib/auth/oauth.ts @@ -10,10 +10,11 @@ import OAuth2Server, { } from '@node-oauth/oauth2-server' import { randomBytesPromise } from '@server/helpers/core-utils' import { isOTPValid } from '@server/helpers/otp' +import { CONFIG } from '@server/initializers/config' import { MOAuthClient } from '@server/types/models' import { sha1 } from '@shared/extra-utils' import { HttpStatusCode } from '@shared/models' -import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' +import { OTP } from '../../initializers/constants' import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' class MissingTwoFactorError extends Error { @@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error { * */ const oAuthServer = new OAuth2Server({ - accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, - refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, + // Wants seconds + accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000, + refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000, // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications model: require('./oauth-model') @@ -182,10 +184,10 @@ function generateRandomToken () { function getTokenExpiresAt (type: 'access' | 'refresh') { const lifetime = type === 'access' - ? OAUTH_LIFETIME.ACCESS_TOKEN - : OAUTH_LIFETIME.REFRESH_TOKEN + ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN + : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN - return new Date(Date.now() + lifetime * 1000) + return new Date(Date.now() + lifetime) } async function buildToken () { diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts index 410708a35..43efc7d02 100644 --- a/server/lib/auth/tokens-cache.ts +++ b/server/lib/auth/tokens-cache.ts @@ -36,8 +36,8 @@ export class TokensCache { const token = this.userHavingToken.get(userId) if (token !== undefined) { - this.accessTokenCache.del(token) - this.userHavingToken.del(userId) + this.accessTokenCache.delete(token) + this.userHavingToken.delete(userId) } } @@ -45,8 +45,8 @@ export class TokensCache { const tokenModel = this.accessTokenCache.get(token) if (tokenModel !== undefined) { - this.userHavingToken.del(tokenModel.userId) - this.accessTokenCache.del(token) + this.userHavingToken.delete(tokenModel.userId) + this.accessTokenCache.delete(token) } } } diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts index 643f1a531..0313845ef 100644 --- a/server/tests/api/users/index.ts +++ b/server/tests/api/users/index.ts @@ -1,3 +1,4 @@ +import './oauth' import './two-factor' import './user-subscriptions' import './user-videos' diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts new file mode 100644 index 000000000..6a3da5ea2 --- /dev/null +++ b/server/tests/api/users/oauth.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@shared/core-utils' +import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models' +import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' + +describe('Test oauth', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + login: { + max: 30 + } + } + }) + + await setAccessTokensToServers([ server ]) + }) + + describe('OAuth client', function () { + + function expectInvalidClient (body: PeerTubeProblemDocument) { + expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) + expect(body.error).to.contain('client is invalid') + expect(body.type.startsWith('https://')).to.be.true + expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) + } + + it('Should create a new client') + + it('Should return the first client') + + it('Should remove the last client') + + it('Should not login with an invalid client id', async function () { + const client = { id: 'client', secret: server.store.client.secret } + const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidClient(body) + }) + + it('Should not login with an invalid client secret', async function () { + const client = { id: server.store.client.id, secret: 'coucou' } + const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidClient(body) + }) + }) + + describe('Login', function () { + + function expectInvalidCredentials (body: PeerTubeProblemDocument) { + expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) + expect(body.error).to.contain('credentials are invalid') + expect(body.type.startsWith('https://')).to.be.true + expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) + } + + it('Should not login with an invalid username', async function () { + const user = { username: 'captain crochet', password: server.store.user.password } + const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidCredentials(body) + }) + + it('Should not login with an invalid password', async function () { + const user = { username: server.store.user.username, password: 'mew_three' } + const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidCredentials(body) + }) + + it('Should be able to login', async function () { + await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should be able to login with an insensitive username', async function () { + const user = { username: 'RoOt', password: server.store.user.password } + await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) + + const user2 = { username: 'rOoT', password: server.store.user.password } + await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) + + const user3 = { username: 'ROOt', password: server.store.user.password } + await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Logout', function () { + + it('Should logout (revoke token)', async function () { + await server.login.logout({ token: server.accessToken }) + }) + + it('Should not be able to get the user information', async function () { + await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to upload a video', async function () { + await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should be able to login again', async function () { + const body = await server.login.login() + server.accessToken = body.access_token + server.refreshToken = body.refresh_token + }) + + it('Should be able to get my user information again', async function () { + await server.users.getMyInfo() + }) + + it('Should have an expired access token', async function () { + this.timeout(60000) + + await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) + await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) + + await killallServers([ server ]) + await server.run() + + await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to refresh an access token with an expired refresh token', async function () { + await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should refresh the token', async function () { + this.timeout(50000) + + const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() + await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) + + await killallServers([ server ]) + await server.run() + + const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) + server.accessToken = res.body.access_token + server.refreshToken = res.body.refresh_token + }) + + it('Should be able to get my user information again', async function () { + await server.users.getMyInfo() + }) + }) + + describe('Custom token lifetime', function () { + before(async function () { + this.timeout(120_000) + + await server.kill() + await server.run({ + oauth2: { + token_lifetime: { + access_token: '2 seconds', + refresh_token: '2 seconds' + } + } + }) + }) + + it('Should have a very short access token lifetime', async function () { + this.timeout(50000) + + const { access_token: accessToken } = await server.login.login() + await server.users.getMyInfo({ token: accessToken }) + + await wait(3000) + await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should have a very short refresh token lifetime', async function () { + this.timeout(50000) + + const { refresh_token: refreshToken } = await server.login.login() + await server.login.refreshToken({ refreshToken }) + + await wait(3000) + await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 421b3ce16..93e2e489a 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -2,15 +2,8 @@ import { expect } from 'chai' import { testImage } from '@server/tests/shared' -import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' -import { - cleanupTests, - createSingleServer, - killallServers, - makePutBodyRequest, - PeerTubeServer, - setAccessTokensToServers -} from '@shared/server-commands' +import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' describe('Test users', function () { let server: PeerTubeServer @@ -39,166 +32,6 @@ describe('Test users', function () { await server.plugins.install({ npmName: 'peertube-theme-background-red' }) }) - describe('OAuth client', function () { - it('Should create a new client') - - it('Should return the first client') - - it('Should remove the last client') - - it('Should not login with an invalid client id', async function () { - const client = { id: 'client', secret: server.store.client.secret } - const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) - expect(body.error).to.contain('client is invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) - }) - - it('Should not login with an invalid client secret', async function () { - const client = { id: server.store.client.id, secret: 'coucou' } - const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) - expect(body.error).to.contain('client is invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) - }) - }) - - describe('Login', function () { - - it('Should not login with an invalid username', async function () { - const user = { username: 'captain crochet', password: server.store.user.password } - const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) - expect(body.error).to.contain('credentials are invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) - }) - - it('Should not login with an invalid password', async function () { - const user = { username: server.store.user.username, password: 'mew_three' } - const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) - expect(body.error).to.contain('credentials are invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) - }) - - it('Should not be able to upload a video', async function () { - token = 'my_super_token' - - await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to follow', async function () { - token = 'my_super_token' - - await server.follows.follow({ - hosts: [ 'http://example.com' ], - token, - expectedStatus: HttpStatusCode.UNAUTHORIZED_401 - }) - }) - - it('Should not be able to unfollow') - - it('Should be able to login', async function () { - const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) - - token = body.access_token - }) - - it('Should be able to login with an insensitive username', async function () { - const user = { username: 'RoOt', password: server.store.user.password } - await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) - - const user2 = { username: 'rOoT', password: server.store.user.password } - await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) - - const user3 = { username: 'ROOt', password: server.store.user.password } - await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) - }) - }) - - describe('Logout', function () { - it('Should logout (revoke token)', async function () { - await server.login.logout({ token: server.accessToken }) - }) - - it('Should not be able to get the user information', async function () { - await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to upload a video', async function () { - await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to rate a video', async function () { - const path = '/api/v1/videos/' - const data = { - rating: 'likes' - } - - const options = { - url: server.url, - path: path + videoId, - token: 'wrong token', - fields: data, - expectedStatus: HttpStatusCode.UNAUTHORIZED_401 - } - await makePutBodyRequest(options) - }) - - it('Should be able to login again', async function () { - const body = await server.login.login() - server.accessToken = body.access_token - server.refreshToken = body.refresh_token - }) - - it('Should be able to get my user information again', async function () { - await server.users.getMyInfo() - }) - - it('Should have an expired access token', async function () { - this.timeout(60000) - - await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) - await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) - - await killallServers([ server ]) - await server.run() - - await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to refresh an access token with an expired refresh token', async function () { - await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - }) - - it('Should refresh the token', async function () { - this.timeout(50000) - - const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() - await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) - - await killallServers([ server ]) - await server.run() - - const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) - server.accessToken = res.body.access_token - server.refreshToken = res.body.refresh_token - }) - - it('Should be able to get my user information again', async function () { - await server.users.getMyInfo() - }) - }) - describe('Creating a user', function () { it('Should be able to create a new user', async function () { @@ -512,6 +345,7 @@ describe('Test users', function () { }) describe('Updating another user', function () { + it('Should be able to update another user', async function () { await server.users.update({ userId, @@ -562,13 +396,6 @@ describe('Test users', function () { }) }) - describe('Video blacklists', function () { - - it('Should be able to list my video blacklist', async function () { - await server.blacklist.list({ token: userToken }) - }) - }) - describe('Remove a user', function () { before(async function () { @@ -653,8 +480,9 @@ describe('Test users', function () { }) describe('User blocking', function () { - let user16Id - let user16AccessToken + let user16Id: number + let user16AccessToken: string + const user16 = { username: 'user_16', password: 'my super password' diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts index dc9cf4e01..cb0e1a5fb 100644 --- a/shared/server-commands/requests/requests.ts +++ b/shared/server-commands/requests/requests.ts @@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) { return req.expect((res) => { if (options.expectedStatus && res.status !== options.expectedStatus) { throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + - `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` + + `\nThe server responded: "${res.body?.error ?? res.text}".\n` + 'You may take a closer look at the logs. To see how to do so, check out this page: ' + 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs') } From 7e0c26066a5c59af742ae56bddaff9635debe034 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 29 Dec 2022 15:31:40 +0100 Subject: [PATCH 2/4] External auth can set more user fields videoQuota, videoQuotaDaily, adminFlags --- server/lib/auth/external-auth.ts | 60 ++++++++++--------- server/lib/auth/oauth-model.ts | 22 ++----- .../main.js | 5 +- server/tests/plugins/external-auth.ts | 8 ++- .../plugins/register-server-auth.model.ts | 7 ++- 5 files changed, 54 insertions(+), 48 deletions(-) diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts index 053112801..155ec03d8 100644 --- a/server/lib/auth/external-auth.ts +++ b/server/lib/auth/external-auth.ts @@ -1,26 +1,33 @@ -import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' +import { + isUserAdminFlagsValid, + isUserDisplayNameValid, + isUserRoleValid, + isUserUsernameValid, + isUserVideoQuotaDailyValid, + isUserVideoQuotaValid +} from '@server/helpers/custom-validators/users' import { logger } from '@server/helpers/logger' import { generateRandomString } from '@server/helpers/utils' import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' import { PluginManager } from '@server/lib/plugins/plugin-manager' import { OAuthTokenModel } from '@server/models/oauth/oauth-token' +import { MUser } from '@server/types/models' import { RegisterServerAuthenticatedResult, RegisterServerAuthPassOptions, RegisterServerExternalAuthenticatedResult } from '@server/types/plugins/register-server-auth.model' -import { UserRole } from '@shared/models' +import { UserAdminFlag, UserRole } from '@shared/models' + +export type ExternalUser = + Pick & + { displayName: string } // Token is the key, expiration date is the value const authBypassTokens = new Map() @@ -172,30 +179,20 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string) } function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { - if (!isUserUsernameValid(result.username)) { - logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) + const returnError = (field: string) => { + logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] }) return false } - if (!result.email) { - logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) - return false - } + if (!isUserUsernameValid(result.username)) return returnError('username') + if (!result.email) return returnError('email') - // role is optional - if (result.role && !isUserRoleValid(result.role)) { - logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) - return false - } - - // display name is optional - if (result.displayName && !isUserDisplayNameValid(result.displayName)) { - logger.error( - 'Auth method %s of plugin %s did not provide a valid display name.', - authName, npmName, { displayName: result.displayName } - ) - return false - } + // Following fields are optional + if (result.role && !isUserRoleValid(result.role)) return returnError('role') + if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName') + if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags') + if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota') + if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily') return true } @@ -205,7 +202,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) { username: pluginResult.username, email: pluginResult.email, role: pluginResult.role ?? UserRole.USER, - displayName: pluginResult.displayName || pluginResult.username + displayName: pluginResult.displayName || pluginResult.username, + + adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE, + + videoQuota: pluginResult.videoQuota, + videoQuotaDaily: pluginResult.videoQuotaDaily } } diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts index 322b69e3a..603cc0f5f 100644 --- a/server/lib/auth/oauth-model.ts +++ b/server/lib/auth/oauth-model.ts @@ -5,7 +5,6 @@ import { MOAuthClient } from '@server/types/models' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' import { MUser } from '@server/types/models/user/user' import { pick } from '@shared/core-utils' -import { UserRole } from '@shared/models/users/user-role' import { logger } from '../../helpers/logger' import { CONFIG } from '../../initializers/config' import { OAuthClientModel } from '../../models/oauth/oauth-client' @@ -13,6 +12,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token' import { UserModel } from '../../models/user/user' import { findAvailableLocalActorName } from '../local-actor' import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' +import { ExternalUser } from './external-auth' import { TokensCache } from './tokens-cache' type TokenInfo = { @@ -26,12 +26,7 @@ export type BypassLogin = { bypass: boolean pluginName: string authName?: string - user: { - username: string - email: string - displayName: string - role: UserRole - } + user: ExternalUser } async function getAccessToken (bearerToken: string) { @@ -219,16 +214,11 @@ export { // --------------------------------------------------------------------------- -async function createUserFromExternal (pluginAuth: string, options: { - username: string - email: string - role: UserRole - displayName: string -}) { - const username = await findAvailableLocalActorName(options.username) +async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) { + const username = await findAvailableLocalActorName(userOptions.username) const userToCreate = buildUser({ - ...pick(options, [ 'email', 'role' ]), + ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]), username, emailVerified: null, @@ -238,7 +228,7 @@ async function createUserFromExternal (pluginAuth: string, options: { const { user } = await createUserAccountAndChannelAndPlaylist({ userToCreate, - userDisplayName: options.displayName + userDisplayName: userOptions.displayName }) return user 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 c65b8d3a8..cdbaf11ac 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 @@ -33,7 +33,10 @@ async function register ({ username: 'kefka', email: 'kefka@example.com', role: 0, - displayName: 'Kefka Palazzo' + displayName: 'Kefka Palazzo', + adminFlags: 1, + videoQuota: 42000, + videoQuotaDaily: 42100 }) }, hookTokenValidity: (options) => { diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts index 437777e90..ee78ae5aa 100644 --- a/server/tests/plugins/external-auth.ts +++ b/server/tests/plugins/external-auth.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { wait } from '@shared/core-utils' -import { HttpStatusCode, UserRole } from '@shared/models' +import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models' import { cleanupTests, createSingleServer, @@ -156,6 +156,9 @@ describe('Test external auth plugins', function () { expect(body.account.displayName).to.equal('cyan') expect(body.email).to.equal('cyan@example.com') expect(body.role.id).to.equal(UserRole.USER) + expect(body.adminFlags).to.equal(UserAdminFlag.NONE) + expect(body.videoQuota).to.equal(5242880) + expect(body.videoQuotaDaily).to.equal(-1) } }) @@ -178,6 +181,9 @@ describe('Test external auth plugins', function () { expect(body.account.displayName).to.equal('Kefka Palazzo') expect(body.email).to.equal('kefka@example.com') expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) + expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(42100) } }) diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts index 79c18c406..a17fc4b0f 100644 --- a/server/types/plugins/register-server-auth.model.ts +++ b/server/types/plugins/register-server-auth.model.ts @@ -1,5 +1,5 @@ import express from 'express' -import { UserRole } from '@shared/models' +import { UserAdminFlag, UserRole } from '@shared/models' import { MOAuthToken, MUser } from '../models' export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions @@ -9,6 +9,11 @@ export interface RegisterServerAuthenticatedResult { email: string role?: UserRole displayName?: string + + adminFlags?: UserAdminFlag + + videoQuota?: number + videoQuotaDaily?: number } export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { From 60b880acdfa85eab5c9ec09ba1283f82ae58ec85 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 30 Dec 2022 10:12:20 +0100 Subject: [PATCH 3/4] 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 + } }) }) From 2570fd9c1c879d1a543fb0dff1e7cfb036234d11 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Wed, 4 Jan 2023 11:41:03 +0100 Subject: [PATCH 4/4] Redirect to default login url on 401 Can be an external URL --- client/src/app/+login/login.component.ts | 5 +-- client/src/app/core/auth/auth.service.ts | 41 ++++++++++++---------- client/src/app/menu/menu.component.ts | 8 ++--- client/src/root-helpers/plugins-manager.ts | 11 +++--- shared/core-utils/plugins/hooks.ts | 8 ++++- 5 files changed, 42 insertions(+), 31 deletions(-) diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts index c1705807f..5f6aa842e 100644 --- a/client/src/app/+login/login.component.ts +++ b/client/src/app/+login/login.component.ts @@ -1,3 +1,4 @@ +import { environment } from 'src/environments/environment' import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' @@ -7,7 +8,7 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' -import { PluginsManager } from '@root-helpers/plugins-manager' +import { getExternalAuthHref } from '@shared/core-utils' import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' @Component({ @@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni } getAuthHref (auth: RegisteredExternalAuthConfig) { - return PluginsManager.getExternalAuthHref(auth) + return getExternalAuthHref(environment.apiUrl, auth) } login () { diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index 4de28e51e..ed7eabb76 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular import { Injectable } from '@angular/core' import { Router } from '@angular/router' import { Notifier } from '@app/core/notification/notifier.service' -import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' +import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index' import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' import { environment } from '../../../environments/environment' import { RestExtractor } from '../rest/rest-extractor.service' +import { ServerService } from '../server' import { AuthStatus } from './auth-status.model' import { AuthUser } from './auth-user.model' @@ -44,6 +45,7 @@ export class AuthService { private refreshingTokenObservable: Observable constructor ( + private serverService: ServerService, private http: HttpClient, private notifier: Notifier, private hotkeysService: HotkeysService, @@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') this.refreshingTokenObservable = this.http.post(AuthService.BASE_TOKEN_URL, body, { headers }) - .pipe( - map(res => this.handleRefreshToken(res)), - tap(() => { - this.refreshingTokenObservable = null - }), - catchError(err => { - this.refreshingTokenObservable = null + .pipe( + map(res => this.handleRefreshToken(res)), + tap(() => { + this.refreshingTokenObservable = null + }), + catchError(err => { + this.refreshingTokenObservable = null - logger.error(err) - logger.info('Cannot refresh token -> logout...') - this.logout() - this.router.navigate([ '/login' ]) + logger.error(err) + logger.info('Cannot refresh token -> logout...') + this.logout() - return observableThrowError(() => ({ - error: $localize`You need to reconnect.` - })) - }), - share() - ) + const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig()) + if (externalLoginUrl) window.location.href = externalLoginUrl + else this.router.navigate([ '/login' ]) + + return observableThrowError(() => ({ + error: $localize`You need to reconnect.` + })) + }), + share() + ) return this.refreshingTokenObservable } diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts index 63f01df92..568cb98bb 100644 --- a/client/src/app/menu/menu.component.ts +++ b/client/src/app/menu/menu.component.ts @@ -1,6 +1,7 @@ import { HotkeysService } from 'angular2-hotkeys' import * as debug from 'debug' import { switchMap } from 'rxjs/operators' +import { environment } from 'src/environments/environment' import { ViewportScroller } from '@angular/common' import { Component, OnInit, ViewChild } from '@angular/core' import { Router } from '@angular/router' @@ -131,12 +132,7 @@ export class MenuComponent implements OnInit { } getExternalLoginHref () { - if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined - - const externalAuths = this.serverConfig.plugin.registeredExternalAuths - if (externalAuths.length !== 1) return undefined - - return PluginsManager.getExternalAuthHref(externalAuths[0]) + return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig) } isRegistrationAllowed () { diff --git a/client/src/root-helpers/plugins-manager.ts b/client/src/root-helpers/plugins-manager.ts index 6c64e2b01..e5b06a94c 100644 --- a/client/src/root-helpers/plugins-manager.ts +++ b/client/src/root-helpers/plugins-manager.ts @@ -3,7 +3,7 @@ import * as debug from 'debug' import { firstValueFrom, ReplaySubject } from 'rxjs' import { first, shareReplay } from 'rxjs/operators' import { RegisterClientHelpers } from 'src/types/register-client-option.model' -import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' +import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' import { ClientHookName, clientHookObject, @@ -16,7 +16,6 @@ import { RegisterClientRouteOptions, RegisterClientSettingsScriptOptions, RegisterClientVideoFieldOptions, - RegisteredExternalAuthConfig, ServerConfigPlugin } from '@shared/models' import { environment } from '../environments/environment' @@ -94,9 +93,13 @@ class PluginsManager { return isTheme ? '/themes' : '/plugins' } - static getExternalAuthHref (auth: RegisteredExternalAuthConfig) { - return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` + static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) { + if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined + const externalAuths = serverConfig.plugin.registeredExternalAuths + if (externalAuths.length !== 1) return undefined + + return getExternalAuthHref(apiUrl, externalAuths[0]) } loadPluginsList (config: HTMLServerConfig) { diff --git a/shared/core-utils/plugins/hooks.ts b/shared/core-utils/plugins/hooks.ts index 3784969b5..96bcc945e 100644 --- a/shared/core-utils/plugins/hooks.ts +++ b/shared/core-utils/plugins/hooks.ts @@ -1,3 +1,4 @@ +import { RegisteredExternalAuthConfig } from '@shared/models' import { HookType } from '../../models/plugins/hook-type.enum' import { isCatchable, isPromise } from '../common/promises' @@ -49,7 +50,12 @@ async function internalRunHook (options: { return result } +function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) { + return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` +} + export { getHookType, - internalRunHook + internalRunHook, + getExternalAuthHref }