diff options
31 files changed, 537 insertions, 297 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 @@ | |||
1 | import { environment } from 'src/environments/environment' | ||
1 | import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' | 2 | import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' |
2 | import { ActivatedRoute, Router } from '@angular/router' | 3 | import { ActivatedRoute, Router } from '@angular/router' |
3 | import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' | 4 | 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 | |||
7 | import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' | 8 | import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' |
8 | import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' | 9 | import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' |
9 | import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' | 10 | import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' |
10 | import { PluginsManager } from '@root-helpers/plugins-manager' | 11 | import { getExternalAuthHref } from '@shared/core-utils' |
11 | import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' | 12 | import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' |
12 | 13 | ||
13 | @Component({ | 14 | @Component({ |
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni | |||
119 | } | 120 | } |
120 | 121 | ||
121 | getAuthHref (auth: RegisteredExternalAuthConfig) { | 122 | getAuthHref (auth: RegisteredExternalAuthConfig) { |
122 | return PluginsManager.getExternalAuthHref(auth) | 123 | return getExternalAuthHref(environment.apiUrl, auth) |
123 | } | 124 | } |
124 | 125 | ||
125 | login () { | 126 | 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 | |||
5 | import { Injectable } from '@angular/core' | 5 | import { Injectable } from '@angular/core' |
6 | import { Router } from '@angular/router' | 6 | import { Router } from '@angular/router' |
7 | import { Notifier } from '@app/core/notification/notifier.service' | 7 | import { Notifier } from '@app/core/notification/notifier.service' |
8 | import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' | 8 | import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index' |
9 | import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' | 9 | import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' |
10 | import { environment } from '../../../environments/environment' | 10 | import { environment } from '../../../environments/environment' |
11 | import { RestExtractor } from '../rest/rest-extractor.service' | 11 | import { RestExtractor } from '../rest/rest-extractor.service' |
12 | import { ServerService } from '../server' | ||
12 | import { AuthStatus } from './auth-status.model' | 13 | import { AuthStatus } from './auth-status.model' |
13 | import { AuthUser } from './auth-user.model' | 14 | import { AuthUser } from './auth-user.model' |
14 | 15 | ||
@@ -44,6 +45,7 @@ export class AuthService { | |||
44 | private refreshingTokenObservable: Observable<any> | 45 | private refreshingTokenObservable: Observable<any> |
45 | 46 | ||
46 | constructor ( | 47 | constructor ( |
48 | private serverService: ServerService, | ||
47 | private http: HttpClient, | 49 | private http: HttpClient, |
48 | private notifier: Notifier, | 50 | private notifier: Notifier, |
49 | private hotkeysService: HotkeysService, | 51 | private hotkeysService: HotkeysService, |
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular | |||
213 | const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') | 215 | const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') |
214 | 216 | ||
215 | this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers }) | 217 | this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers }) |
216 | .pipe( | 218 | .pipe( |
217 | map(res => this.handleRefreshToken(res)), | 219 | map(res => this.handleRefreshToken(res)), |
218 | tap(() => { | 220 | tap(() => { |
219 | this.refreshingTokenObservable = null | 221 | this.refreshingTokenObservable = null |
220 | }), | 222 | }), |
221 | catchError(err => { | 223 | catchError(err => { |
222 | this.refreshingTokenObservable = null | 224 | this.refreshingTokenObservable = null |
223 | 225 | ||
224 | logger.error(err) | 226 | logger.error(err) |
225 | logger.info('Cannot refresh token -> logout...') | 227 | logger.info('Cannot refresh token -> logout...') |
226 | this.logout() | 228 | this.logout() |
227 | this.router.navigate([ '/login' ]) | 229 | |
228 | 230 | const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig()) | |
229 | return observableThrowError(() => ({ | 231 | if (externalLoginUrl) window.location.href = externalLoginUrl |
230 | error: $localize`You need to reconnect.` | 232 | else this.router.navigate([ '/login' ]) |
231 | })) | 233 | |
232 | }), | 234 | return observableThrowError(() => ({ |
233 | share() | 235 | error: $localize`You need to reconnect.` |
234 | ) | 236 | })) |
237 | }), | ||
238 | share() | ||
239 | ) | ||
235 | 240 | ||
236 | return this.refreshingTokenObservable | 241 | return this.refreshingTokenObservable |
237 | } | 242 | } |
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 @@ | |||
1 | import { HotkeysService } from 'angular2-hotkeys' | 1 | import { HotkeysService } from 'angular2-hotkeys' |
2 | import * as debug from 'debug' | 2 | import * as debug from 'debug' |
3 | import { switchMap } from 'rxjs/operators' | 3 | import { switchMap } from 'rxjs/operators' |
4 | import { environment } from 'src/environments/environment' | ||
4 | import { ViewportScroller } from '@angular/common' | 5 | import { ViewportScroller } from '@angular/common' |
5 | import { Component, OnInit, ViewChild } from '@angular/core' | 6 | import { Component, OnInit, ViewChild } from '@angular/core' |
6 | import { Router } from '@angular/router' | 7 | import { Router } from '@angular/router' |
@@ -131,12 +132,7 @@ export class MenuComponent implements OnInit { | |||
131 | } | 132 | } |
132 | 133 | ||
133 | getExternalLoginHref () { | 134 | getExternalLoginHref () { |
134 | if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined | 135 | return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig) |
135 | |||
136 | const externalAuths = this.serverConfig.plugin.registeredExternalAuths | ||
137 | if (externalAuths.length !== 1) return undefined | ||
138 | |||
139 | return PluginsManager.getExternalAuthHref(externalAuths[0]) | ||
140 | } | 136 | } |
141 | 137 | ||
142 | isRegistrationAllowed () { | 138 | 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' | |||
3 | import { firstValueFrom, ReplaySubject } from 'rxjs' | 3 | import { firstValueFrom, ReplaySubject } from 'rxjs' |
4 | import { first, shareReplay } from 'rxjs/operators' | 4 | import { first, shareReplay } from 'rxjs/operators' |
5 | import { RegisterClientHelpers } from 'src/types/register-client-option.model' | 5 | import { RegisterClientHelpers } from 'src/types/register-client-option.model' |
6 | import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' | 6 | import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' |
7 | import { | 7 | import { |
8 | ClientHookName, | 8 | ClientHookName, |
9 | clientHookObject, | 9 | clientHookObject, |
@@ -16,7 +16,6 @@ import { | |||
16 | RegisterClientRouteOptions, | 16 | RegisterClientRouteOptions, |
17 | RegisterClientSettingsScriptOptions, | 17 | RegisterClientSettingsScriptOptions, |
18 | RegisterClientVideoFieldOptions, | 18 | RegisterClientVideoFieldOptions, |
19 | RegisteredExternalAuthConfig, | ||
20 | ServerConfigPlugin | 19 | ServerConfigPlugin |
21 | } from '@shared/models' | 20 | } from '@shared/models' |
22 | import { environment } from '../environments/environment' | 21 | import { environment } from '../environments/environment' |
@@ -94,9 +93,13 @@ class PluginsManager { | |||
94 | return isTheme ? '/themes' : '/plugins' | 93 | return isTheme ? '/themes' : '/plugins' |
95 | } | 94 | } |
96 | 95 | ||
97 | static getExternalAuthHref (auth: RegisteredExternalAuthConfig) { | 96 | static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) { |
98 | return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` | 97 | if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined |
99 | 98 | ||
99 | const externalAuths = serverConfig.plugin.registeredExternalAuths | ||
100 | if (externalAuths.length !== 1) return undefined | ||
101 | |||
102 | return getExternalAuthHref(apiUrl, externalAuths[0]) | ||
100 | } | 103 | } |
101 | 104 | ||
102 | loadPluginsList (config: HTMLServerConfig) { | 105 | loadPluginsList (config: HTMLServerConfig) { |
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: | |||
37 | window: 10 minutes | 37 | window: 10 minutes |
38 | max: 10 | 38 | max: 10 |
39 | 39 | ||
40 | oauth2: | ||
41 | token_lifetime: | ||
42 | access_token: '1 day' | ||
43 | refresh_token: '2 weeks' | ||
44 | |||
40 | # Proxies to trust to get real client IP | 45 | # Proxies to trust to get real client IP |
41 | # If you run PeerTube just behind a local proxy (nginx), keep 'loopback' | 46 | # If you run PeerTube just behind a local proxy (nginx), keep 'loopback' |
42 | # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) | 47 | # 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: | |||
35 | window: 10 minutes | 35 | window: 10 minutes |
36 | max: 10 | 36 | max: 10 |
37 | 37 | ||
38 | oauth2: | ||
39 | token_lifetime: | ||
40 | access_token: '1 day' | ||
41 | refresh_token: '2 weeks' | ||
42 | |||
38 | # Proxies to trust to get real client IP | 43 | # Proxies to trust to get real client IP |
39 | # If you run PeerTube just behind a local proxy (nginx), keep 'loopback' | 44 | # If you run PeerTube just behind a local proxy (nginx), keep 'loopback' |
40 | # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) | 45 | # If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) |
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) { | |||
8 | return exists(value) && VIDEO_LANGUAGES[value] !== undefined | 8 | return exists(value) && VIDEO_LANGUAGES[value] !== undefined |
9 | } | 9 | } |
10 | 10 | ||
11 | const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) | 11 | // MacOS sends application/octet-stream |
12 | .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream | 12 | const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ] |
13 | .map(m => `(${m})`) | 13 | .map(m => `(${m})`) |
14 | .join('|') | 14 | .join('|') |
15 | |||
15 | function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { | 16 | function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { |
16 | return isFileValid({ | 17 | return isFileValid({ |
17 | files, | 18 | 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) { | |||
22 | return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined | 22 | return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined |
23 | } | 23 | } |
24 | 24 | ||
25 | const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) | 25 | // MacOS sends application/octet-stream |
26 | .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream | 26 | const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ] |
27 | .map(m => `(${m})`) | 27 | .map(m => `(${m})`) |
28 | .join('|') | 28 | .join('|') |
29 | |||
29 | function isVideoImportTorrentFile (files: UploadFilesForCheck) { | 30 | function isVideoImportTorrentFile (files: UploadFilesForCheck) { |
30 | return isFileValid({ | 31 | return isFileValid({ |
31 | files, | 32 | 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 () { | |||
174 | function checkStorageConfig () { | 174 | function checkStorageConfig () { |
175 | // Check storage directory locations | 175 | // Check storage directory locations |
176 | if (isProdInstance()) { | 176 | if (isProdInstance()) { |
177 | const configStorage = config.get('storage') | 177 | const configStorage = config.get<{ [ name: string ]: string }>('storage') |
178 | |||
178 | for (const key of Object.keys(configStorage)) { | 179 | for (const key of Object.keys(configStorage)) { |
179 | if (configStorage[key].startsWith('storage/')) { | 180 | if (configStorage[key].startsWith('storage/')) { |
180 | logger.warn( | 181 | logger.warn( |
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 () { | |||
13 | 'webserver.https', 'webserver.hostname', 'webserver.port', | 13 | 'webserver.https', 'webserver.hostname', 'webserver.port', |
14 | 'secrets.peertube', | 14 | 'secrets.peertube', |
15 | 'trust_proxy', | 15 | 'trust_proxy', |
16 | 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token', | ||
16 | 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', | 17 | 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', |
17 | 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', | 18 | 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', |
18 | 'email.body.signature', 'email.subject.prefix', | 19 | '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 = { | |||
149 | HOSTNAME: config.get<string>('webserver.hostname'), | 149 | HOSTNAME: config.get<string>('webserver.hostname'), |
150 | PORT: config.get<number>('webserver.port') | 150 | PORT: config.get<number>('webserver.port') |
151 | }, | 151 | }, |
152 | OAUTH2: { | ||
153 | TOKEN_LIFETIME: { | ||
154 | ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')), | ||
155 | REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token')) | ||
156 | } | ||
157 | }, | ||
152 | RATES_LIMIT: { | 158 | RATES_LIMIT: { |
153 | API: { | 159 | API: { |
154 | WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')), | 160 | WINDOW_MS: parseDurationToMs(config.get<string>('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 = { | |||
101 | VIDEO_REDUNDANCIES: [ 'name' ] | 101 | VIDEO_REDUNDANCIES: [ 'name' ] |
102 | } | 102 | } |
103 | 103 | ||
104 | const OAUTH_LIFETIME = { | ||
105 | ACCESS_TOKEN: 3600 * 24, // 1 day, for upload | ||
106 | REFRESH_TOKEN: 1209600 // 2 weeks | ||
107 | } | ||
108 | |||
109 | const ROUTE_CACHE_LIFETIME = { | 104 | const ROUTE_CACHE_LIFETIME = { |
110 | FEEDS: '15 minutes', | 105 | FEEDS: '15 minutes', |
111 | ROBOTS: '2 hours', | 106 | ROBOTS: '2 hours', |
@@ -1033,7 +1028,6 @@ export { | |||
1033 | JOB_ATTEMPTS, | 1028 | JOB_ATTEMPTS, |
1034 | AP_CLEANER, | 1029 | AP_CLEANER, |
1035 | LAST_MIGRATION_VERSION, | 1030 | LAST_MIGRATION_VERSION, |
1036 | OAUTH_LIFETIME, | ||
1037 | CUSTOM_HTML_TAG_COMMENTS, | 1031 | CUSTOM_HTML_TAG_COMMENTS, |
1038 | STATS_TIMESERIE, | 1032 | STATS_TIMESERIE, |
1039 | BROADCAST_CONCURRENCY, | 1033 | BROADCAST_CONCURRENCY, |
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 () { | |||
51 | const tasks: Promise<any>[] = [] | 51 | const tasks: Promise<any>[] = [] |
52 | 52 | ||
53 | // Cache directories | 53 | // Cache directories |
54 | for (const key of Object.keys(cacheDirectories)) { | 54 | for (const dir of cacheDirectories) { |
55 | const dir = cacheDirectories[key] | ||
56 | tasks.push(removeDirectoryOrContent(dir)) | 55 | tasks.push(removeDirectoryOrContent(dir)) |
57 | } | 56 | } |
58 | 57 | ||
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () { | |||
87 | } | 86 | } |
88 | 87 | ||
89 | // Cache directories | 88 | // Cache directories |
90 | for (const key of Object.keys(cacheDirectories)) { | 89 | for (const dir of cacheDirectories) { |
91 | const dir = cacheDirectories[key] | ||
92 | tasks.push(ensureDir(dir)) | 90 | tasks.push(ensureDir(dir)) |
93 | } | 91 | } |
94 | 92 | ||
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts index 053112801..bc5b74257 100644 --- a/server/lib/auth/external-auth.ts +++ b/server/lib/auth/external-auth.ts | |||
@@ -1,26 +1,35 @@ | |||
1 | 1 | ||
2 | import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' | 2 | import { |
3 | isUserAdminFlagsValid, | ||
4 | isUserDisplayNameValid, | ||
5 | isUserRoleValid, | ||
6 | isUserUsernameValid, | ||
7 | isUserVideoQuotaDailyValid, | ||
8 | isUserVideoQuotaValid | ||
9 | } from '@server/helpers/custom-validators/users' | ||
3 | import { logger } from '@server/helpers/logger' | 10 | import { logger } from '@server/helpers/logger' |
4 | import { generateRandomString } from '@server/helpers/utils' | 11 | import { generateRandomString } from '@server/helpers/utils' |
5 | import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' | 12 | import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' |
6 | import { PluginManager } from '@server/lib/plugins/plugin-manager' | 13 | import { PluginManager } from '@server/lib/plugins/plugin-manager' |
7 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' | 14 | import { OAuthTokenModel } from '@server/models/oauth/oauth-token' |
15 | import { MUser } from '@server/types/models' | ||
8 | import { | 16 | import { |
9 | RegisterServerAuthenticatedResult, | 17 | RegisterServerAuthenticatedResult, |
10 | RegisterServerAuthPassOptions, | 18 | RegisterServerAuthPassOptions, |
11 | RegisterServerExternalAuthenticatedResult | 19 | RegisterServerExternalAuthenticatedResult |
12 | } from '@server/types/plugins/register-server-auth.model' | 20 | } from '@server/types/plugins/register-server-auth.model' |
13 | import { UserRole } from '@shared/models' | 21 | import { UserAdminFlag, UserRole } from '@shared/models' |
22 | import { BypassLogin } from './oauth-model' | ||
23 | |||
24 | export type ExternalUser = | ||
25 | Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> & | ||
26 | { displayName: string } | ||
14 | 27 | ||
15 | // Token is the key, expiration date is the value | 28 | // Token is the key, expiration date is the value |
16 | const authBypassTokens = new Map<string, { | 29 | const authBypassTokens = new Map<string, { |
17 | expires: Date | 30 | expires: Date |
18 | user: { | 31 | user: ExternalUser |
19 | username: string | 32 | userUpdater: RegisterServerAuthenticatedResult['userUpdater'] |
20 | email: string | ||
21 | displayName: string | ||
22 | role: UserRole | ||
23 | } | ||
24 | authName: string | 33 | authName: string |
25 | npmName: string | 34 | npmName: string |
26 | }>() | 35 | }>() |
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: { | |||
56 | expires, | 65 | expires, |
57 | user, | 66 | user, |
58 | npmName, | 67 | npmName, |
59 | authName | 68 | authName, |
69 | userUpdater: authResult.userUpdater | ||
60 | }) | 70 | }) |
61 | 71 | ||
62 | // Cleanup expired tokens | 72 | // Cleanup expired tokens |
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) { | |||
78 | return tokenModel?.authName | 88 | return tokenModel?.authName |
79 | } | 89 | } |
80 | 90 | ||
81 | async function getBypassFromPasswordGrant (username: string, password: string) { | 91 | async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> { |
82 | const plugins = PluginManager.Instance.getIdAndPassAuths() | 92 | const plugins = PluginManager.Instance.getIdAndPassAuths() |
83 | const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] | 93 | const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] |
84 | 94 | ||
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) { | |||
133 | bypass: true, | 143 | bypass: true, |
134 | pluginName: pluginAuth.npmName, | 144 | pluginName: pluginAuth.npmName, |
135 | authName: authOptions.authName, | 145 | authName: authOptions.authName, |
136 | user: buildUserResult(loginResult) | 146 | user: buildUserResult(loginResult), |
147 | userUpdater: loginResult.userUpdater | ||
137 | } | 148 | } |
138 | } catch (err) { | 149 | } catch (err) { |
139 | logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) | 150 | logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) |
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) { | |||
143 | return undefined | 154 | return undefined |
144 | } | 155 | } |
145 | 156 | ||
146 | function getBypassFromExternalAuth (username: string, externalAuthToken: string) { | 157 | function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin { |
147 | const obj = authBypassTokens.get(externalAuthToken) | 158 | const obj = authBypassTokens.get(externalAuthToken) |
148 | if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') | 159 | if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') |
149 | 160 | ||
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string) | |||
167 | bypass: true, | 178 | bypass: true, |
168 | pluginName: npmName, | 179 | pluginName: npmName, |
169 | authName, | 180 | authName, |
181 | userUpdater: obj.userUpdater, | ||
170 | user | 182 | user |
171 | } | 183 | } |
172 | } | 184 | } |
173 | 185 | ||
174 | function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { | 186 | function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { |
175 | if (!isUserUsernameValid(result.username)) { | 187 | const returnError = (field: string) => { |
176 | logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) | 188 | logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] }) |
177 | return false | 189 | return false |
178 | } | 190 | } |
179 | 191 | ||
180 | if (!result.email) { | 192 | if (!isUserUsernameValid(result.username)) return returnError('username') |
181 | logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) | 193 | if (!result.email) return returnError('email') |
182 | return false | ||
183 | } | ||
184 | 194 | ||
185 | // role is optional | 195 | // Following fields are optional |
186 | if (result.role && !isUserRoleValid(result.role)) { | 196 | if (result.role && !isUserRoleValid(result.role)) return returnError('role') |
187 | logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) | 197 | if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName') |
188 | return false | 198 | if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags') |
189 | } | 199 | if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota') |
200 | if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily') | ||
190 | 201 | ||
191 | // display name is optional | 202 | if (result.userUpdater && typeof result.userUpdater !== 'function') { |
192 | if (result.displayName && !isUserDisplayNameValid(result.displayName)) { | 203 | logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName) |
193 | logger.error( | ||
194 | 'Auth method %s of plugin %s did not provide a valid display name.', | ||
195 | authName, npmName, { displayName: result.displayName } | ||
196 | ) | ||
197 | return false | 204 | return false |
198 | } | 205 | } |
199 | 206 | ||
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) { | |||
205 | username: pluginResult.username, | 212 | username: pluginResult.username, |
206 | email: pluginResult.email, | 213 | email: pluginResult.email, |
207 | role: pluginResult.role ?? UserRole.USER, | 214 | role: pluginResult.role ?? UserRole.USER, |
208 | displayName: pluginResult.displayName || pluginResult.username | 215 | displayName: pluginResult.displayName || pluginResult.username, |
216 | |||
217 | adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE, | ||
218 | |||
219 | videoQuota: pluginResult.videoQuota, | ||
220 | videoQuotaDaily: pluginResult.videoQuotaDaily | ||
209 | } | 221 | } |
210 | } | 222 | } |
211 | 223 | ||
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts index 322b69e3a..43909284f 100644 --- a/server/lib/auth/oauth-model.ts +++ b/server/lib/auth/oauth-model.ts | |||
@@ -1,11 +1,13 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { AccessDeniedError } from '@node-oauth/oauth2-server' | 2 | import { AccessDeniedError } from '@node-oauth/oauth2-server' |
3 | import { PluginManager } from '@server/lib/plugins/plugin-manager' | 3 | import { PluginManager } from '@server/lib/plugins/plugin-manager' |
4 | import { AccountModel } from '@server/models/account/account' | ||
5 | import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types' | ||
4 | import { MOAuthClient } from '@server/types/models' | 6 | import { MOAuthClient } from '@server/types/models' |
5 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' | 7 | import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' |
6 | import { MUser } from '@server/types/models/user/user' | 8 | import { MUser, MUserDefault } from '@server/types/models/user/user' |
7 | import { pick } from '@shared/core-utils' | 9 | import { pick } from '@shared/core-utils' |
8 | import { UserRole } from '@shared/models/users/user-role' | 10 | import { AttributesOnly } from '@shared/typescript-utils' |
9 | import { logger } from '../../helpers/logger' | 11 | import { logger } from '../../helpers/logger' |
10 | import { CONFIG } from '../../initializers/config' | 12 | import { CONFIG } from '../../initializers/config' |
11 | import { OAuthClientModel } from '../../models/oauth/oauth-client' | 13 | import { OAuthClientModel } from '../../models/oauth/oauth-client' |
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token' | |||
13 | import { UserModel } from '../../models/user/user' | 15 | import { UserModel } from '../../models/user/user' |
14 | import { findAvailableLocalActorName } from '../local-actor' | 16 | import { findAvailableLocalActorName } from '../local-actor' |
15 | import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' | 17 | import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' |
18 | import { ExternalUser } from './external-auth' | ||
16 | import { TokensCache } from './tokens-cache' | 19 | import { TokensCache } from './tokens-cache' |
17 | 20 | ||
18 | type TokenInfo = { | 21 | type TokenInfo = { |
@@ -26,12 +29,8 @@ export type BypassLogin = { | |||
26 | bypass: boolean | 29 | bypass: boolean |
27 | pluginName: string | 30 | pluginName: string |
28 | authName?: string | 31 | authName?: string |
29 | user: { | 32 | user: ExternalUser |
30 | username: string | 33 | userUpdater: RegisterServerAuthenticatedResult['userUpdater'] |
31 | email: string | ||
32 | displayName: string | ||
33 | role: UserRole | ||
34 | } | ||
35 | } | 34 | } |
36 | 35 | ||
37 | async function getAccessToken (bearerToken: string) { | 36 | async function getAccessToken (bearerToken: string) { |
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin | |||
89 | logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) | 88 | logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) |
90 | 89 | ||
91 | let user = await UserModel.loadByEmail(bypassLogin.user.email) | 90 | let user = await UserModel.loadByEmail(bypassLogin.user.email) |
91 | |||
92 | if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) | 92 | if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) |
93 | else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater) | ||
93 | 94 | ||
94 | // Cannot create a user | 95 | // Cannot create a user |
95 | if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') | 96 | if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') |
@@ -219,16 +220,11 @@ export { | |||
219 | 220 | ||
220 | // --------------------------------------------------------------------------- | 221 | // --------------------------------------------------------------------------- |
221 | 222 | ||
222 | async function createUserFromExternal (pluginAuth: string, options: { | 223 | async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) { |
223 | username: string | 224 | const username = await findAvailableLocalActorName(userOptions.username) |
224 | email: string | ||
225 | role: UserRole | ||
226 | displayName: string | ||
227 | }) { | ||
228 | const username = await findAvailableLocalActorName(options.username) | ||
229 | 225 | ||
230 | const userToCreate = buildUser({ | 226 | const userToCreate = buildUser({ |
231 | ...pick(options, [ 'email', 'role' ]), | 227 | ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]), |
232 | 228 | ||
233 | username, | 229 | username, |
234 | emailVerified: null, | 230 | emailVerified: null, |
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: { | |||
238 | 234 | ||
239 | const { user } = await createUserAccountAndChannelAndPlaylist({ | 235 | const { user } = await createUserAccountAndChannelAndPlaylist({ |
240 | userToCreate, | 236 | userToCreate, |
241 | userDisplayName: options.displayName | 237 | userDisplayName: userOptions.displayName |
242 | }) | 238 | }) |
243 | 239 | ||
244 | return user | 240 | return user |
245 | } | 241 | } |
246 | 242 | ||
243 | async function updateUserFromExternal ( | ||
244 | user: MUserDefault, | ||
245 | userOptions: ExternalUser, | ||
246 | userUpdater: RegisterServerAuthenticatedResult['userUpdater'] | ||
247 | ) { | ||
248 | if (!userUpdater) return user | ||
249 | |||
250 | { | ||
251 | type UserAttributeKeys = keyof AttributesOnly<UserModel> | ||
252 | const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = { | ||
253 | role: 'role', | ||
254 | adminFlags: 'adminFlags', | ||
255 | videoQuota: 'videoQuota', | ||
256 | videoQuotaDaily: 'videoQuotaDaily' | ||
257 | } | ||
258 | |||
259 | for (const modelKey of Object.keys(mappingKeys)) { | ||
260 | const pluginOptionKey = mappingKeys[modelKey] | ||
261 | |||
262 | const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] }) | ||
263 | user.set(modelKey, newValue) | ||
264 | } | ||
265 | } | ||
266 | |||
267 | { | ||
268 | type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>> | ||
269 | const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = { | ||
270 | name: 'displayName' | ||
271 | } | ||
272 | |||
273 | for (const modelKey of Object.keys(mappingKeys)) { | ||
274 | const optionKey = mappingKeys[modelKey] | ||
275 | |||
276 | const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] }) | ||
277 | user.Account.set(modelKey, newValue) | ||
278 | } | ||
279 | } | ||
280 | |||
281 | logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions }) | ||
282 | |||
283 | user.Account = await user.Account.save() | ||
284 | |||
285 | return user.save() | ||
286 | } | ||
287 | |||
247 | function checkUserValidityOrThrow (user: MUser) { | 288 | function checkUserValidityOrThrow (user: MUser) { |
248 | if (user.blocked) throw new AccessDeniedError('User is blocked.') | 289 | if (user.blocked) throw new AccessDeniedError('User is blocked.') |
249 | } | 290 | } |
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, { | |||
10 | } from '@node-oauth/oauth2-server' | 10 | } from '@node-oauth/oauth2-server' |
11 | import { randomBytesPromise } from '@server/helpers/core-utils' | 11 | import { randomBytesPromise } from '@server/helpers/core-utils' |
12 | import { isOTPValid } from '@server/helpers/otp' | 12 | import { isOTPValid } from '@server/helpers/otp' |
13 | import { CONFIG } from '@server/initializers/config' | ||
13 | import { MOAuthClient } from '@server/types/models' | 14 | import { MOAuthClient } from '@server/types/models' |
14 | import { sha1 } from '@shared/extra-utils' | 15 | import { sha1 } from '@shared/extra-utils' |
15 | import { HttpStatusCode } from '@shared/models' | 16 | import { HttpStatusCode } from '@shared/models' |
16 | import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' | 17 | import { OTP } from '../../initializers/constants' |
17 | import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' | 18 | import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' |
18 | 19 | ||
19 | class MissingTwoFactorError extends Error { | 20 | class MissingTwoFactorError extends Error { |
@@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error { | |||
32 | * | 33 | * |
33 | */ | 34 | */ |
34 | const oAuthServer = new OAuth2Server({ | 35 | const oAuthServer = new OAuth2Server({ |
35 | accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, | 36 | // Wants seconds |
36 | refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, | 37 | accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000, |
38 | refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000, | ||
37 | 39 | ||
38 | // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications | 40 | // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications |
39 | model: require('./oauth-model') | 41 | model: require('./oauth-model') |
@@ -182,10 +184,10 @@ function generateRandomToken () { | |||
182 | 184 | ||
183 | function getTokenExpiresAt (type: 'access' | 'refresh') { | 185 | function getTokenExpiresAt (type: 'access' | 'refresh') { |
184 | const lifetime = type === 'access' | 186 | const lifetime = type === 'access' |
185 | ? OAUTH_LIFETIME.ACCESS_TOKEN | 187 | ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN |
186 | : OAUTH_LIFETIME.REFRESH_TOKEN | 188 | : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN |
187 | 189 | ||
188 | return new Date(Date.now() + lifetime * 1000) | 190 | return new Date(Date.now() + lifetime) |
189 | } | 191 | } |
190 | 192 | ||
191 | async function buildToken () { | 193 | 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 { | |||
36 | const token = this.userHavingToken.get(userId) | 36 | const token = this.userHavingToken.get(userId) |
37 | 37 | ||
38 | if (token !== undefined) { | 38 | if (token !== undefined) { |
39 | this.accessTokenCache.del(token) | 39 | this.accessTokenCache.delete(token) |
40 | this.userHavingToken.del(userId) | 40 | this.userHavingToken.delete(userId) |
41 | } | 41 | } |
42 | } | 42 | } |
43 | 43 | ||
@@ -45,8 +45,8 @@ export class TokensCache { | |||
45 | const tokenModel = this.accessTokenCache.get(token) | 45 | const tokenModel = this.accessTokenCache.get(token) |
46 | 46 | ||
47 | if (tokenModel !== undefined) { | 47 | if (tokenModel !== undefined) { |
48 | this.userHavingToken.del(tokenModel.userId) | 48 | this.userHavingToken.delete(tokenModel.userId) |
49 | this.accessTokenCache.del(token) | 49 | this.accessTokenCache.delete(token) |
50 | } | 50 | } |
51 | } | 51 | } |
52 | } | 52 | } |
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<Partial<AttributesOnly<VideoFileModel> | |||
439 | if (!element) return videoFile.save({ transaction }) | 439 | if (!element) return videoFile.save({ transaction }) |
440 | 440 | ||
441 | for (const k of Object.keys(videoFile.toJSON())) { | 441 | for (const k of Object.keys(videoFile.toJSON())) { |
442 | element[k] = videoFile[k] | 442 | element.set(k, videoFile[k]) |
443 | } | 443 | } |
444 | 444 | ||
445 | return element.save({ transaction }) | 445 | return element.save({ transaction }) |
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 @@ | |||
1 | import './oauth' | ||
1 | import './two-factor' | 2 | import './two-factor' |
2 | import './user-subscriptions' | 3 | import './user-subscriptions' |
3 | import './user-videos' | 4 | 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 @@ | |||
1 | /* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ | ||
2 | |||
3 | import { expect } from 'chai' | ||
4 | import { wait } from '@shared/core-utils' | ||
5 | import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models' | ||
6 | import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' | ||
7 | |||
8 | describe('Test oauth', function () { | ||
9 | let server: PeerTubeServer | ||
10 | |||
11 | before(async function () { | ||
12 | this.timeout(30000) | ||
13 | |||
14 | server = await createSingleServer(1, { | ||
15 | rates_limit: { | ||
16 | login: { | ||
17 | max: 30 | ||
18 | } | ||
19 | } | ||
20 | }) | ||
21 | |||
22 | await setAccessTokensToServers([ server ]) | ||
23 | }) | ||
24 | |||
25 | describe('OAuth client', function () { | ||
26 | |||
27 | function expectInvalidClient (body: PeerTubeProblemDocument) { | ||
28 | expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) | ||
29 | expect(body.error).to.contain('client is invalid') | ||
30 | expect(body.type.startsWith('https://')).to.be.true | ||
31 | expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) | ||
32 | } | ||
33 | |||
34 | it('Should create a new client') | ||
35 | |||
36 | it('Should return the first client') | ||
37 | |||
38 | it('Should remove the last client') | ||
39 | |||
40 | it('Should not login with an invalid client id', async function () { | ||
41 | const client = { id: 'client', secret: server.store.client.secret } | ||
42 | const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
43 | |||
44 | expectInvalidClient(body) | ||
45 | }) | ||
46 | |||
47 | it('Should not login with an invalid client secret', async function () { | ||
48 | const client = { id: server.store.client.id, secret: 'coucou' } | ||
49 | const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
50 | |||
51 | expectInvalidClient(body) | ||
52 | }) | ||
53 | }) | ||
54 | |||
55 | describe('Login', function () { | ||
56 | |||
57 | function expectInvalidCredentials (body: PeerTubeProblemDocument) { | ||
58 | expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) | ||
59 | expect(body.error).to.contain('credentials are invalid') | ||
60 | expect(body.type.startsWith('https://')).to.be.true | ||
61 | expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) | ||
62 | } | ||
63 | |||
64 | it('Should not login with an invalid username', async function () { | ||
65 | const user = { username: 'captain crochet', password: server.store.user.password } | ||
66 | const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
67 | |||
68 | expectInvalidCredentials(body) | ||
69 | }) | ||
70 | |||
71 | it('Should not login with an invalid password', async function () { | ||
72 | const user = { username: server.store.user.username, password: 'mew_three' } | ||
73 | const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
74 | |||
75 | expectInvalidCredentials(body) | ||
76 | }) | ||
77 | |||
78 | it('Should be able to login', async function () { | ||
79 | await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) | ||
80 | }) | ||
81 | |||
82 | it('Should be able to login with an insensitive username', async function () { | ||
83 | const user = { username: 'RoOt', password: server.store.user.password } | ||
84 | await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) | ||
85 | |||
86 | const user2 = { username: 'rOoT', password: server.store.user.password } | ||
87 | await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) | ||
88 | |||
89 | const user3 = { username: 'ROOt', password: server.store.user.password } | ||
90 | await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) | ||
91 | }) | ||
92 | }) | ||
93 | |||
94 | describe('Logout', function () { | ||
95 | |||
96 | it('Should logout (revoke token)', async function () { | ||
97 | await server.login.logout({ token: server.accessToken }) | ||
98 | }) | ||
99 | |||
100 | it('Should not be able to get the user information', async function () { | ||
101 | await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
102 | }) | ||
103 | |||
104 | it('Should not be able to upload a video', async function () { | ||
105 | await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
106 | }) | ||
107 | |||
108 | it('Should be able to login again', async function () { | ||
109 | const body = await server.login.login() | ||
110 | server.accessToken = body.access_token | ||
111 | server.refreshToken = body.refresh_token | ||
112 | }) | ||
113 | |||
114 | it('Should be able to get my user information again', async function () { | ||
115 | await server.users.getMyInfo() | ||
116 | }) | ||
117 | |||
118 | it('Should have an expired access token', async function () { | ||
119 | this.timeout(60000) | ||
120 | |||
121 | await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) | ||
122 | await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) | ||
123 | |||
124 | await killallServers([ server ]) | ||
125 | await server.run() | ||
126 | |||
127 | await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
128 | }) | ||
129 | |||
130 | it('Should not be able to refresh an access token with an expired refresh token', async function () { | ||
131 | await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
132 | }) | ||
133 | |||
134 | it('Should refresh the token', async function () { | ||
135 | this.timeout(50000) | ||
136 | |||
137 | const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() | ||
138 | await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) | ||
139 | |||
140 | await killallServers([ server ]) | ||
141 | await server.run() | ||
142 | |||
143 | const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) | ||
144 | server.accessToken = res.body.access_token | ||
145 | server.refreshToken = res.body.refresh_token | ||
146 | }) | ||
147 | |||
148 | it('Should be able to get my user information again', async function () { | ||
149 | await server.users.getMyInfo() | ||
150 | }) | ||
151 | }) | ||
152 | |||
153 | describe('Custom token lifetime', function () { | ||
154 | before(async function () { | ||
155 | this.timeout(120_000) | ||
156 | |||
157 | await server.kill() | ||
158 | await server.run({ | ||
159 | oauth2: { | ||
160 | token_lifetime: { | ||
161 | access_token: '2 seconds', | ||
162 | refresh_token: '2 seconds' | ||
163 | } | ||
164 | } | ||
165 | }) | ||
166 | }) | ||
167 | |||
168 | it('Should have a very short access token lifetime', async function () { | ||
169 | this.timeout(50000) | ||
170 | |||
171 | const { access_token: accessToken } = await server.login.login() | ||
172 | await server.users.getMyInfo({ token: accessToken }) | ||
173 | |||
174 | await wait(3000) | ||
175 | await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
176 | }) | ||
177 | |||
178 | it('Should have a very short refresh token lifetime', async function () { | ||
179 | this.timeout(50000) | ||
180 | |||
181 | const { refresh_token: refreshToken } = await server.login.login() | ||
182 | await server.login.refreshToken({ refreshToken }) | ||
183 | |||
184 | await wait(3000) | ||
185 | await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
186 | }) | ||
187 | }) | ||
188 | |||
189 | after(async function () { | ||
190 | await cleanupTests([ server ]) | ||
191 | }) | ||
192 | }) | ||
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 @@ | |||
2 | 2 | ||
3 | import { expect } from 'chai' | 3 | import { expect } from 'chai' |
4 | import { testImage } from '@server/tests/shared' | 4 | import { testImage } from '@server/tests/shared' |
5 | import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' | 5 | import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' |
6 | import { | 6 | import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' |
7 | cleanupTests, | ||
8 | createSingleServer, | ||
9 | killallServers, | ||
10 | makePutBodyRequest, | ||
11 | PeerTubeServer, | ||
12 | setAccessTokensToServers | ||
13 | } from '@shared/server-commands' | ||
14 | 7 | ||
15 | describe('Test users', function () { | 8 | describe('Test users', function () { |
16 | let server: PeerTubeServer | 9 | let server: PeerTubeServer |
@@ -39,166 +32,6 @@ describe('Test users', function () { | |||
39 | await server.plugins.install({ npmName: 'peertube-theme-background-red' }) | 32 | await server.plugins.install({ npmName: 'peertube-theme-background-red' }) |
40 | }) | 33 | }) |
41 | 34 | ||
42 | describe('OAuth client', function () { | ||
43 | it('Should create a new client') | ||
44 | |||
45 | it('Should return the first client') | ||
46 | |||
47 | it('Should remove the last client') | ||
48 | |||
49 | it('Should not login with an invalid client id', async function () { | ||
50 | const client = { id: 'client', secret: server.store.client.secret } | ||
51 | const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
52 | |||
53 | expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) | ||
54 | expect(body.error).to.contain('client is invalid') | ||
55 | expect(body.type.startsWith('https://')).to.be.true | ||
56 | expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) | ||
57 | }) | ||
58 | |||
59 | it('Should not login with an invalid client secret', async function () { | ||
60 | const client = { id: server.store.client.id, secret: 'coucou' } | ||
61 | const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
62 | |||
63 | expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) | ||
64 | expect(body.error).to.contain('client is invalid') | ||
65 | expect(body.type.startsWith('https://')).to.be.true | ||
66 | expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) | ||
67 | }) | ||
68 | }) | ||
69 | |||
70 | describe('Login', function () { | ||
71 | |||
72 | it('Should not login with an invalid username', async function () { | ||
73 | const user = { username: 'captain crochet', password: server.store.user.password } | ||
74 | const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
75 | |||
76 | expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) | ||
77 | expect(body.error).to.contain('credentials are invalid') | ||
78 | expect(body.type.startsWith('https://')).to.be.true | ||
79 | expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) | ||
80 | }) | ||
81 | |||
82 | it('Should not login with an invalid password', async function () { | ||
83 | const user = { username: server.store.user.username, password: 'mew_three' } | ||
84 | const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
85 | |||
86 | expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) | ||
87 | expect(body.error).to.contain('credentials are invalid') | ||
88 | expect(body.type.startsWith('https://')).to.be.true | ||
89 | expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) | ||
90 | }) | ||
91 | |||
92 | it('Should not be able to upload a video', async function () { | ||
93 | token = 'my_super_token' | ||
94 | |||
95 | await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
96 | }) | ||
97 | |||
98 | it('Should not be able to follow', async function () { | ||
99 | token = 'my_super_token' | ||
100 | |||
101 | await server.follows.follow({ | ||
102 | hosts: [ 'http://example.com' ], | ||
103 | token, | ||
104 | expectedStatus: HttpStatusCode.UNAUTHORIZED_401 | ||
105 | }) | ||
106 | }) | ||
107 | |||
108 | it('Should not be able to unfollow') | ||
109 | |||
110 | it('Should be able to login', async function () { | ||
111 | const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) | ||
112 | |||
113 | token = body.access_token | ||
114 | }) | ||
115 | |||
116 | it('Should be able to login with an insensitive username', async function () { | ||
117 | const user = { username: 'RoOt', password: server.store.user.password } | ||
118 | await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) | ||
119 | |||
120 | const user2 = { username: 'rOoT', password: server.store.user.password } | ||
121 | await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) | ||
122 | |||
123 | const user3 = { username: 'ROOt', password: server.store.user.password } | ||
124 | await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) | ||
125 | }) | ||
126 | }) | ||
127 | |||
128 | describe('Logout', function () { | ||
129 | it('Should logout (revoke token)', async function () { | ||
130 | await server.login.logout({ token: server.accessToken }) | ||
131 | }) | ||
132 | |||
133 | it('Should not be able to get the user information', async function () { | ||
134 | await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
135 | }) | ||
136 | |||
137 | it('Should not be able to upload a video', async function () { | ||
138 | await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
139 | }) | ||
140 | |||
141 | it('Should not be able to rate a video', async function () { | ||
142 | const path = '/api/v1/videos/' | ||
143 | const data = { | ||
144 | rating: 'likes' | ||
145 | } | ||
146 | |||
147 | const options = { | ||
148 | url: server.url, | ||
149 | path: path + videoId, | ||
150 | token: 'wrong token', | ||
151 | fields: data, | ||
152 | expectedStatus: HttpStatusCode.UNAUTHORIZED_401 | ||
153 | } | ||
154 | await makePutBodyRequest(options) | ||
155 | }) | ||
156 | |||
157 | it('Should be able to login again', async function () { | ||
158 | const body = await server.login.login() | ||
159 | server.accessToken = body.access_token | ||
160 | server.refreshToken = body.refresh_token | ||
161 | }) | ||
162 | |||
163 | it('Should be able to get my user information again', async function () { | ||
164 | await server.users.getMyInfo() | ||
165 | }) | ||
166 | |||
167 | it('Should have an expired access token', async function () { | ||
168 | this.timeout(60000) | ||
169 | |||
170 | await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) | ||
171 | await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) | ||
172 | |||
173 | await killallServers([ server ]) | ||
174 | await server.run() | ||
175 | |||
176 | await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) | ||
177 | }) | ||
178 | |||
179 | it('Should not be able to refresh an access token with an expired refresh token', async function () { | ||
180 | await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | ||
181 | }) | ||
182 | |||
183 | it('Should refresh the token', async function () { | ||
184 | this.timeout(50000) | ||
185 | |||
186 | const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() | ||
187 | await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) | ||
188 | |||
189 | await killallServers([ server ]) | ||
190 | await server.run() | ||
191 | |||
192 | const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) | ||
193 | server.accessToken = res.body.access_token | ||
194 | server.refreshToken = res.body.refresh_token | ||
195 | }) | ||
196 | |||
197 | it('Should be able to get my user information again', async function () { | ||
198 | await server.users.getMyInfo() | ||
199 | }) | ||
200 | }) | ||
201 | |||
202 | describe('Creating a user', function () { | 35 | describe('Creating a user', function () { |
203 | 36 | ||
204 | it('Should be able to create a new user', async function () { | 37 | it('Should be able to create a new user', async function () { |
@@ -512,6 +345,7 @@ describe('Test users', function () { | |||
512 | }) | 345 | }) |
513 | 346 | ||
514 | describe('Updating another user', function () { | 347 | describe('Updating another user', function () { |
348 | |||
515 | it('Should be able to update another user', async function () { | 349 | it('Should be able to update another user', async function () { |
516 | await server.users.update({ | 350 | await server.users.update({ |
517 | userId, | 351 | userId, |
@@ -562,13 +396,6 @@ describe('Test users', function () { | |||
562 | }) | 396 | }) |
563 | }) | 397 | }) |
564 | 398 | ||
565 | describe('Video blacklists', function () { | ||
566 | |||
567 | it('Should be able to list my video blacklist', async function () { | ||
568 | await server.blacklist.list({ token: userToken }) | ||
569 | }) | ||
570 | }) | ||
571 | |||
572 | describe('Remove a user', function () { | 399 | describe('Remove a user', function () { |
573 | 400 | ||
574 | before(async function () { | 401 | before(async function () { |
@@ -653,8 +480,9 @@ describe('Test users', function () { | |||
653 | }) | 480 | }) |
654 | 481 | ||
655 | describe('User blocking', function () { | 482 | describe('User blocking', function () { |
656 | let user16Id | 483 | let user16Id: number |
657 | let user16AccessToken | 484 | let user16AccessToken: string |
485 | |||
658 | const user16 = { | 486 | const user16 = { |
659 | username: 'user_16', | 487 | username: 'user_16', |
660 | password: 'my super password' | 488 | password: 'my super password' |
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..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 | |||
@@ -33,7 +33,17 @@ async function register ({ | |||
33 | username: 'kefka', | 33 | username: 'kefka', |
34 | email: 'kefka@example.com', | 34 | email: 'kefka@example.com', |
35 | role: 0, | 35 | role: 0, |
36 | displayName: 'Kefka Palazzo' | 36 | displayName: 'Kefka Palazzo', |
37 | adminFlags: 1, | ||
38 | videoQuota: 42000, | ||
39 | videoQuotaDaily: 42100, | ||
40 | |||
41 | // Always use new value except for videoQuotaDaily field | ||
42 | userUpdater: ({ fieldName, currentValue, newValue }) => { | ||
43 | if (fieldName === 'videoQuotaDaily') return currentValue | ||
44 | |||
45 | return newValue | ||
46 | } | ||
37 | }) | 47 | }) |
38 | }, | 48 | }, |
39 | hookTokenValidity: (options) => { | 49 | 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 ({ | |||
33 | if (body.id === 'laguna' && body.password === 'laguna password') { | 33 | if (body.id === 'laguna' && body.password === 'laguna password') { |
34 | return Promise.resolve({ | 34 | return Promise.resolve({ |
35 | username: 'laguna', | 35 | username: 'laguna', |
36 | email: 'laguna@example.com' | 36 | email: 'laguna@example.com', |
37 | displayName: 'Laguna Loire', | ||
38 | adminFlags: 1, | ||
39 | videoQuota: 42000, | ||
40 | videoQuotaDaily: 42100, | ||
41 | |||
42 | // Always use new value except for videoQuotaDaily field | ||
43 | userUpdater: ({ fieldName, currentValue, newValue }) => { | ||
44 | if (fieldName === 'videoQuotaDaily') return currentValue | ||
45 | |||
46 | return newValue | ||
47 | } | ||
37 | }) | 48 | }) |
38 | } | 49 | } |
39 | 50 | ||
diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts index 437777e90..e600f958f 100644 --- a/server/tests/plugins/external-auth.ts +++ b/server/tests/plugins/external-auth.ts | |||
@@ -2,7 +2,7 @@ | |||
2 | 2 | ||
3 | import { expect } from 'chai' | 3 | import { expect } from 'chai' |
4 | import { wait } from '@shared/core-utils' | 4 | import { wait } from '@shared/core-utils' |
5 | import { HttpStatusCode, UserRole } from '@shared/models' | 5 | import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models' |
6 | import { | 6 | import { |
7 | cleanupTests, | 7 | cleanupTests, |
8 | createSingleServer, | 8 | createSingleServer, |
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () { | |||
51 | 51 | ||
52 | let kefkaAccessToken: string | 52 | let kefkaAccessToken: string |
53 | let kefkaRefreshToken: string | 53 | let kefkaRefreshToken: string |
54 | let kefkaId: number | ||
54 | 55 | ||
55 | let externalAuthToken: string | 56 | let externalAuthToken: string |
56 | 57 | ||
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () { | |||
156 | expect(body.account.displayName).to.equal('cyan') | 157 | expect(body.account.displayName).to.equal('cyan') |
157 | expect(body.email).to.equal('cyan@example.com') | 158 | expect(body.email).to.equal('cyan@example.com') |
158 | expect(body.role.id).to.equal(UserRole.USER) | 159 | expect(body.role.id).to.equal(UserRole.USER) |
160 | expect(body.adminFlags).to.equal(UserAdminFlag.NONE) | ||
161 | expect(body.videoQuota).to.equal(5242880) | ||
162 | expect(body.videoQuotaDaily).to.equal(-1) | ||
159 | } | 163 | } |
160 | }) | 164 | }) |
161 | 165 | ||
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () { | |||
178 | expect(body.account.displayName).to.equal('Kefka Palazzo') | 182 | expect(body.account.displayName).to.equal('Kefka Palazzo') |
179 | expect(body.email).to.equal('kefka@example.com') | 183 | expect(body.email).to.equal('kefka@example.com') |
180 | expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) | 184 | expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) |
185 | expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) | ||
186 | expect(body.videoQuota).to.equal(42000) | ||
187 | expect(body.videoQuotaDaily).to.equal(42100) | ||
188 | |||
189 | kefkaId = body.id | ||
181 | } | 190 | } |
182 | }) | 191 | }) |
183 | 192 | ||
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () { | |||
240 | expect(body.role.id).to.equal(UserRole.USER) | 249 | expect(body.role.id).to.equal(UserRole.USER) |
241 | }) | 250 | }) |
242 | 251 | ||
252 | it('Should login Kefka and update the profile', async function () { | ||
253 | { | ||
254 | await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 }) | ||
255 | await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' }) | ||
256 | |||
257 | const body = await server.users.getMyInfo({ token: kefkaAccessToken }) | ||
258 | expect(body.username).to.equal('kefka') | ||
259 | expect(body.account.displayName).to.equal('kefka updated') | ||
260 | expect(body.videoQuota).to.equal(43000) | ||
261 | expect(body.videoQuotaDaily).to.equal(43100) | ||
262 | } | ||
263 | |||
264 | { | ||
265 | const res = await loginExternal({ | ||
266 | server, | ||
267 | npmName: 'test-external-auth-one', | ||
268 | authName: 'external-auth-2', | ||
269 | username: 'kefka' | ||
270 | }) | ||
271 | |||
272 | kefkaAccessToken = res.access_token | ||
273 | kefkaRefreshToken = res.refresh_token | ||
274 | |||
275 | const body = await server.users.getMyInfo({ token: kefkaAccessToken }) | ||
276 | expect(body.username).to.equal('kefka') | ||
277 | expect(body.account.displayName).to.equal('Kefka Palazzo') | ||
278 | expect(body.videoQuota).to.equal(42000) | ||
279 | expect(body.videoQuotaDaily).to.equal(43100) | ||
280 | } | ||
281 | }) | ||
282 | |||
243 | it('Should not update an external auth email', async function () { | 283 | it('Should not update an external auth email', async function () { |
244 | await server.users.updateMe({ | 284 | await server.users.updateMe({ |
245 | token: cyanAccessToken, | 285 | 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 () { | |||
13 | 13 | ||
14 | let lagunaAccessToken: string | 14 | let lagunaAccessToken: string |
15 | let lagunaRefreshToken: string | 15 | let lagunaRefreshToken: string |
16 | let lagunaId: number | ||
16 | 17 | ||
17 | before(async function () { | 18 | before(async function () { |
18 | this.timeout(30000) | 19 | this.timeout(30000) |
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () { | |||
78 | const body = await server.users.getMyInfo({ token: lagunaAccessToken }) | 79 | const body = await server.users.getMyInfo({ token: lagunaAccessToken }) |
79 | 80 | ||
80 | expect(body.username).to.equal('laguna') | 81 | expect(body.username).to.equal('laguna') |
81 | expect(body.account.displayName).to.equal('laguna') | 82 | expect(body.account.displayName).to.equal('Laguna Loire') |
82 | expect(body.role.id).to.equal(UserRole.USER) | 83 | expect(body.role.id).to.equal(UserRole.USER) |
84 | |||
85 | lagunaId = body.id | ||
83 | } | 86 | } |
84 | }) | 87 | }) |
85 | 88 | ||
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () { | |||
132 | expect(body.role.id).to.equal(UserRole.MODERATOR) | 135 | expect(body.role.id).to.equal(UserRole.MODERATOR) |
133 | }) | 136 | }) |
134 | 137 | ||
138 | it('Should login Laguna and update the profile', async function () { | ||
139 | { | ||
140 | await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 }) | ||
141 | await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' }) | ||
142 | |||
143 | const body = await server.users.getMyInfo({ token: lagunaAccessToken }) | ||
144 | expect(body.username).to.equal('laguna') | ||
145 | expect(body.account.displayName).to.equal('laguna updated') | ||
146 | expect(body.videoQuota).to.equal(43000) | ||
147 | expect(body.videoQuotaDaily).to.equal(43100) | ||
148 | } | ||
149 | |||
150 | { | ||
151 | const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } }) | ||
152 | lagunaAccessToken = body.access_token | ||
153 | lagunaRefreshToken = body.refresh_token | ||
154 | } | ||
155 | |||
156 | { | ||
157 | const body = await server.users.getMyInfo({ token: lagunaAccessToken }) | ||
158 | expect(body.username).to.equal('laguna') | ||
159 | expect(body.account.displayName).to.equal('Laguna Loire') | ||
160 | expect(body.videoQuota).to.equal(42000) | ||
161 | expect(body.videoQuotaDaily).to.equal(43100) | ||
162 | } | ||
163 | }) | ||
164 | |||
135 | it('Should reject token of laguna by the plugin hook', async function () { | 165 | it('Should reject token of laguna by the plugin hook', async function () { |
136 | this.timeout(10000) | 166 | this.timeout(10000) |
137 | 167 | ||
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () { | |||
147 | await server.servers.waitUntilLog('valid username') | 177 | await server.servers.waitUntilLog('valid username') |
148 | 178 | ||
149 | await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | 179 | await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) |
150 | await server.servers.waitUntilLog('valid display name') | 180 | await server.servers.waitUntilLog('valid displayName') |
151 | 181 | ||
152 | await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) | 182 | await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) |
153 | await server.servers.waitUntilLog('valid role') | 183 | 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 @@ | |||
1 | |||
2 | import { OutgoingHttpHeaders } from 'http' | 1 | import { OutgoingHttpHeaders } from 'http' |
3 | import { RegisterServerAuthExternalOptions } from '@server/types' | 2 | import { RegisterServerAuthExternalOptions } from '@server/types' |
4 | import { | 3 | 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 @@ | |||
1 | type ObjectKeys<T> = | ||
2 | T extends object | ||
3 | ? `${Exclude<keyof T, symbol>}`[] | ||
4 | : T extends number | ||
5 | ? [] | ||
6 | : T extends any | string | ||
7 | ? string[] | ||
8 | : never | ||
9 | |||
10 | interface ObjectConstructor { | ||
11 | keys<T> (o: T): ObjectKeys<T> | ||
12 | } | ||
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts index 79c18c406..e10968c20 100644 --- a/server/types/plugins/register-server-auth.model.ts +++ b/server/types/plugins/register-server-auth.model.ts | |||
@@ -1,14 +1,33 @@ | |||
1 | import express from 'express' | 1 | import express from 'express' |
2 | import { UserRole } from '@shared/models' | 2 | import { UserAdminFlag, UserRole } from '@shared/models' |
3 | import { MOAuthToken, MUser } from '../models' | 3 | import { MOAuthToken, MUser } from '../models' |
4 | 4 | ||
5 | export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions | 5 | export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions |
6 | 6 | ||
7 | export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily' | ||
8 | |||
7 | export interface RegisterServerAuthenticatedResult { | 9 | export interface RegisterServerAuthenticatedResult { |
10 | // Update the user profile if it already exists | ||
11 | // Default behaviour is no update | ||
12 | // Introduced in PeerTube >= 5.1 | ||
13 | userUpdater?: <T> (options: { | ||
14 | fieldName: AuthenticatedResultUpdaterFieldName | ||
15 | currentValue: T | ||
16 | newValue: T | ||
17 | }) => T | ||
18 | |||
8 | username: string | 19 | username: string |
9 | email: string | 20 | email: string |
10 | role?: UserRole | 21 | role?: UserRole |
11 | displayName?: string | 22 | displayName?: string |
23 | |||
24 | // PeerTube >= 5.1 | ||
25 | adminFlags?: UserAdminFlag | ||
26 | |||
27 | // PeerTube >= 5.1 | ||
28 | videoQuota?: number | ||
29 | // PeerTube >= 5.1 | ||
30 | videoQuotaDaily?: number | ||
12 | } | 31 | } |
13 | 32 | ||
14 | export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { | 33 | export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { |
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 @@ | |||
1 | import { RegisteredExternalAuthConfig } from '@shared/models' | ||
1 | import { HookType } from '../../models/plugins/hook-type.enum' | 2 | import { HookType } from '../../models/plugins/hook-type.enum' |
2 | import { isCatchable, isPromise } from '../common/promises' | 3 | import { isCatchable, isPromise } from '../common/promises' |
3 | 4 | ||
@@ -49,7 +50,12 @@ async function internalRunHook <T> (options: { | |||
49 | return result | 50 | return result |
50 | } | 51 | } |
51 | 52 | ||
53 | function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) { | ||
54 | return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` | ||
55 | } | ||
56 | |||
52 | export { | 57 | export { |
53 | getHookType, | 58 | getHookType, |
54 | internalRunHook | 59 | internalRunHook, |
60 | getExternalAuthHref | ||
55 | } | 61 | } |
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) { | |||
199 | return req.expect((res) => { | 199 | return req.expect((res) => { |
200 | if (options.expectedStatus && res.status !== options.expectedStatus) { | 200 | if (options.expectedStatus && res.status !== options.expectedStatus) { |
201 | throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + | 201 | throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + |
202 | `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` + | 202 | `\nThe server responded: "${res.body?.error ?? res.text}".\n` + |
203 | 'You may take a closer look at the logs. To see how to do so, check out this page: ' + | 203 | 'You may take a closer look at the logs. To see how to do so, check out this page: ' + |
204 | 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs') | 204 | 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs') |
205 | } | 205 | } |
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 (...) { | |||
433 | username: 'user' | 433 | username: 'user' |
434 | email: 'user@example.com' | 434 | email: 'user@example.com' |
435 | role: 2 | 435 | role: 2 |
436 | displayName: 'User display name' | 436 | displayName: 'User display name', |
437 | |||
438 | // Custom admin flags (bypass video auto moderation etc.) | ||
439 | // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts | ||
440 | // PeerTube >= 5.1 | ||
441 | adminFlags: 0, | ||
442 | // Quota in bytes | ||
443 | // PeerTube >= 5.1 | ||
444 | videoQuota: 1024 * 1024 * 1024, // 1GB | ||
445 | // PeerTube >= 5.1 | ||
446 | videoQuotaDaily: -1, // Unlimited | ||
447 | |||
448 | // Update the user profile if it already exists | ||
449 | // Default behaviour is no update | ||
450 | // Introduced in PeerTube >= 5.1 | ||
451 | userUpdater: ({ fieldName, currentValue, newValue }) => { | ||
452 | // Always use new value except for videoQuotaDaily field | ||
453 | if (fieldName === 'videoQuotaDaily') return currentValue | ||
454 | |||
455 | return newValue | ||
456 | } | ||
437 | }) | 457 | }) |
438 | }) | 458 | }) |
439 | 459 | ||