aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorChocobozzz <me@florianbigard.com>2022-10-10 11:19:58 +0200
committerChocobozzz <me@florianbigard.com>2022-10-10 11:19:58 +0200
commit63fa260a81a8930c157b73c897fe8696a8cc90d4 (patch)
tree705ebfae42f9c59b2a1ac97779e4037102dfed1c
parent9b99d32804e99462c6f22df3ec3db9ec5bf8a18c (diff)
parent1ea868a9456439108fbd87255537093ed8bd456f (diff)
downloadPeerTube-63fa260a81a8930c157b73c897fe8696a8cc90d4.tar.gz
PeerTube-63fa260a81a8930c157b73c897fe8696a8cc90d4.tar.zst
PeerTube-63fa260a81a8930c157b73c897fe8696a8cc90d4.zip
Merge branch 'feature/otp' into develop
-rw-r--r--client/src/app/+about/about-instance/contact-admin-modal.component.ts4
-rw-r--r--client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts8
-rw-r--r--client/src/app/+admin/follows/following-list/follow-modal.component.ts4
-rw-r--r--client/src/app/+admin/overview/users/user-edit/user-create.component.ts4
-rw-r--r--client/src/app/+admin/overview/users/user-edit/user-edit.component.html9
-rw-r--r--client/src/app/+admin/overview/users/user-edit/user-edit.component.scss16
-rw-r--r--client/src/app/+admin/overview/users/user-edit/user-edit.ts12
-rw-r--r--client/src/app/+admin/overview/users/user-edit/user-password.component.ts4
-rw-r--r--client/src/app/+admin/overview/users/user-edit/user-update.component.ts25
-rw-r--r--client/src/app/+admin/overview/users/user-list/user-list.component.scss2
-rw-r--r--client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts4
-rw-r--r--client/src/app/+login/login.component.html46
-rw-r--r--client/src/app/+login/login.component.scss4
-rw-r--r--client/src/app/+login/login.component.ts41
-rw-r--r--client/src/app/+manage/video-channel-edit/video-channel-create.component.ts4
-rw-r--r--client/src/app/+manage/video-channel-edit/video-channel-update.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-routing.module.ts11
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts8
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts8
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts4
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.html10
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-settings.component.scss2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts2
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html12
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts49
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html54
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss16
-rw-r--r--client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts105
-rw-r--r--client/src/app/+my-account/my-account.module.ts8
-rw-r--r--client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts2
-rw-r--r--client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts4
-rw-r--r--client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts4
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts4
-rw-r--r--client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts4
-rw-r--r--client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts4
-rw-r--r--client/src/app/+reset-password/reset-password.component.ts4
-rw-r--r--client/src/app/+signup/+register/register.component.ts2
-rw-r--r--client/src/app/+signup/+register/steps/register-step-channel.component.ts4
-rw-r--r--client/src/app/+signup/+register/steps/register-step-terms.component.ts8
-rw-r--r--client/src/app/+signup/+register/steps/register-step-user.component.ts4
-rw-r--r--client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts4
-rw-r--r--client/src/app/+video-studio/edit/video-studio-edit.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts7
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts4
-rw-r--r--client/src/app/+videos/+video-edit/video-update.component.ts4
-rw-r--r--client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts4
-rw-r--r--client/src/app/core/auth/auth.service.ts23
-rw-r--r--client/src/app/core/confirm/confirm.service.ts47
-rw-r--r--client/src/app/core/rest/rest-extractor.service.ts6
-rw-r--r--client/src/app/core/users/user.model.ts4
-rw-r--r--client/src/app/modal/confirm.component.html7
-rw-r--r--client/src/app/modal/confirm.component.ts30
-rw-r--r--client/src/app/shared/form-validators/user-validators.ts9
-rw-r--r--client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts4
-rw-r--r--client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts4
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.service.ts101
-rw-r--r--client/src/app/shared/shared-forms/form-reactive.ts87
-rw-r--r--client/src/app/shared/shared-forms/form-validator.service.ts2
-rw-r--r--client/src/app/shared/shared-forms/index.ts1
-rw-r--r--client/src/app/shared/shared-forms/input-text.component.ts10
-rw-r--r--client/src/app/shared/shared-forms/shared-form.module.ts5
-rw-r--r--client/src/app/shared/shared-main/auth/auth-interceptor.service.ts13
-rw-r--r--client/src/app/shared/shared-moderation/batch-domains-modal.component.ts4
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/account-report.component.ts4
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts4
-rw-r--r--client/src/app/shared/shared-moderation/report-modals/video-report.component.ts4
-rw-r--r--client/src/app/shared/shared-moderation/user-ban-modal.component.ts4
-rw-r--r--client/src/app/shared/shared-moderation/video-block.component.ts4
-rw-r--r--client/src/app/shared/shared-user-settings/user-interface-settings.component.ts4
-rw-r--r--client/src/app/shared/shared-user-settings/user-video-settings.component.ts4
-rw-r--r--client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts4
-rw-r--r--client/src/app/shared/shared-users/index.ts1
-rw-r--r--client/src/app/shared/shared-users/shared-users.module.ts4
-rw-r--r--client/src/app/shared/shared-users/two-factor.service.ts52
-rw-r--r--client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts4
-rw-r--r--client/src/sass/bootstrap.scss52
-rw-r--r--client/src/sass/include/_variables.scss2
-rw-r--r--client/src/sass/ng-select.scss2
-rw-r--r--client/src/sass/player/_player-variables.scss2
-rw-r--r--config/default.yaml5
-rw-r--r--config/dev.yaml3
-rw-r--r--config/production.yaml.example5
-rw-r--r--config/test.yaml3
-rw-r--r--package.json1
-rw-r--r--server.ts7
-rw-r--r--server/controllers/api/users/index.ts2
-rw-r--r--server/controllers/api/users/token.ts7
-rw-r--r--server/controllers/api/users/two-factor.ts95
-rw-r--r--server/helpers/core-utils.ts14
-rw-r--r--server/helpers/otp.ts58
-rw-r--r--server/helpers/peertube-crypto.ts49
-rw-r--r--server/initializers/checker-after-init.ts7
-rw-r--r--server/initializers/checker-before-init.ts1
-rw-r--r--server/initializers/config.ts3
-rw-r--r--server/initializers/constants.ts20
-rw-r--r--server/initializers/migrations/0745-user-otp.ts29
-rw-r--r--server/lib/auth/oauth.ts27
-rw-r--r--server/lib/redis.ts25
-rw-r--r--server/middlewares/validators/shared/index.ts1
-rw-r--r--server/middlewares/validators/shared/users.ts62
-rw-r--r--server/middlewares/validators/two-factor.ts81
-rw-r--r--server/middlewares/validators/users.ts104
-rw-r--r--server/models/user/user.ts9
-rw-r--r--server/tests/api/check-params/index.ts5
-rw-r--r--server/tests/api/check-params/two-factor.ts288
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/two-factor.ts200
-rw-r--r--server/tests/helpers/crypto.ts33
-rw-r--r--server/tests/helpers/index.ts3
-rw-r--r--shared/models/users/index.ts1
-rw-r--r--shared/models/users/two-factor-enable-result.model.ts7
-rw-r--r--shared/models/users/user.model.ts2
-rw-r--r--shared/server-commands/server/server.ts12
-rw-r--r--shared/server-commands/users/index.ts1
-rw-r--r--shared/server-commands/users/login-command.ts73
-rw-r--r--shared/server-commands/users/two-factor-command.ts92
-rw-r--r--shared/server-commands/users/users-command.ts3
-rw-r--r--support/doc/api/openapi.yaml106
-rw-r--r--support/doc/docker.md1
-rw-r--r--support/doc/production.md10
-rw-r--r--support/docker/production/.env3
-rw-r--r--support/docker/production/config/custom-environment-variables.yaml3
-rw-r--r--yarn.lock12
128 files changed, 2165 insertions, 386 deletions
diff --git a/client/src/app/+about/about-instance/contact-admin-modal.component.ts b/client/src/app/+about/about-instance/contact-admin-modal.component.ts
index fab9cfc4b..0e2bf51e8 100644
--- a/client/src/app/+about/about-instance/contact-admin-modal.component.ts
+++ b/client/src/app/+about/about-instance/contact-admin-modal.component.ts
@@ -7,7 +7,7 @@ import {
7 FROM_NAME_VALIDATOR, 7 FROM_NAME_VALIDATOR,
8 SUBJECT_VALIDATOR 8 SUBJECT_VALIDATOR
9} from '@app/shared/form-validators/instance-validators' 9} from '@app/shared/form-validators/instance-validators'
10import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 10import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
11import { InstanceService } from '@app/shared/shared-instance' 11import { InstanceService } from '@app/shared/shared-instance'
12import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 12import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
13import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 13import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -32,7 +32,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
32 private serverConfig: HTMLServerConfig 32 private serverConfig: HTMLServerConfig
33 33
34 constructor ( 34 constructor (
35 protected formValidatorService: FormValidatorService, 35 protected formReactiveService: FormReactiveService,
36 private router: Router, 36 private router: Router,
37 private modalService: NgbModal, 37 private modalService: NgbModal,
38 private instanceService: InstanceService, 38 private instanceService: InstanceService,
diff --git a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
index 545e37857..168f4702c 100644
--- a/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
+++ b/client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
@@ -18,15 +18,15 @@ import {
18 MAX_INSTANCE_LIVES_VALIDATOR, 18 MAX_INSTANCE_LIVES_VALIDATOR,
19 MAX_LIVE_DURATION_VALIDATOR, 19 MAX_LIVE_DURATION_VALIDATOR,
20 MAX_USER_LIVES_VALIDATOR, 20 MAX_USER_LIVES_VALIDATOR,
21 MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
21 SEARCH_INDEX_URL_VALIDATOR, 22 SEARCH_INDEX_URL_VALIDATOR,
22 SERVICES_TWITTER_USERNAME_VALIDATOR, 23 SERVICES_TWITTER_USERNAME_VALIDATOR,
23 SIGNUP_LIMIT_VALIDATOR, 24 SIGNUP_LIMIT_VALIDATOR,
24 SIGNUP_MINIMUM_AGE_VALIDATOR, 25 SIGNUP_MINIMUM_AGE_VALIDATOR,
25 TRANSCODING_THREADS_VALIDATOR, 26 TRANSCODING_THREADS_VALIDATOR
26 MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
27} from '@app/shared/form-validators/custom-config-validators' 27} from '@app/shared/form-validators/custom-config-validators'
28import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators' 28import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
29import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 29import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
30import { CustomPageService } from '@app/shared/shared-main/custom-page' 30import { CustomPageService } from '@app/shared/shared-main/custom-page'
31import { CustomConfig, CustomPage, HTMLServerConfig } from '@shared/models' 31import { CustomConfig, CustomPage, HTMLServerConfig } from '@shared/models'
32import { EditConfigurationService } from './edit-configuration.service' 32import { EditConfigurationService } from './edit-configuration.service'
@@ -52,9 +52,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
52 categoryItems: SelectOptionsItem[] = [] 52 categoryItems: SelectOptionsItem[] = []
53 53
54 constructor ( 54 constructor (
55 protected formReactiveService: FormReactiveService,
55 private router: Router, 56 private router: Router,
56 private route: ActivatedRoute, 57 private route: ActivatedRoute,
57 protected formValidatorService: FormValidatorService,
58 private notifier: Notifier, 58 private notifier: Notifier,
59 private configService: ConfigService, 59 private configService: ConfigService,
60 private customPage: CustomPageService, 60 private customPage: CustomPageService,
diff --git a/client/src/app/+admin/follows/following-list/follow-modal.component.ts b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
index 07cc75d77..8f74e82a6 100644
--- a/client/src/app/+admin/follows/following-list/follow-modal.component.ts
+++ b/client/src/app/+admin/follows/following-list/follow-modal.component.ts
@@ -2,7 +2,7 @@ import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/cor
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { prepareIcu } from '@app/helpers'
4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators' 4import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { InstanceFollowService } from '@app/shared/shared-instance' 6import { InstanceFollowService } from '@app/shared/shared-instance'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -22,7 +22,7 @@ export class FollowModalComponent extends FormReactive implements OnInit {
22 private openedModal: NgbModalRef 22 private openedModal: NgbModalRef
23 23
24 constructor ( 24 constructor (
25 protected formValidatorService: FormValidatorService, 25 protected formReactiveService: FormReactiveService,
26 private modalService: NgbModal, 26 private modalService: NgbModal,
27 private followService: InstanceFollowService, 27 private followService: InstanceFollowService,
28 private notifier: Notifier 28 private notifier: Notifier
diff --git a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts
index 1713e06ce..0627aa887 100644
--- a/client/src/app/+admin/overview/users/user-edit/user-create.component.ts
+++ b/client/src/app/+admin/overview/users/user-edit/user-create.component.ts
@@ -12,7 +12,7 @@ import {
12 USER_VIDEO_QUOTA_DAILY_VALIDATOR, 12 USER_VIDEO_QUOTA_DAILY_VALIDATOR,
13 USER_VIDEO_QUOTA_VALIDATOR 13 USER_VIDEO_QUOTA_VALIDATOR
14} from '@app/shared/form-validators/user-validators' 14} from '@app/shared/form-validators/user-validators'
15import { FormValidatorService } from '@app/shared/shared-forms' 15import { FormReactiveService } from '@app/shared/shared-forms'
16import { UserAdminService } from '@app/shared/shared-users' 16import { UserAdminService } from '@app/shared/shared-users'
17import { UserCreate, UserRole } from '@shared/models' 17import { UserCreate, UserRole } from '@shared/models'
18import { UserEdit } from './user-edit' 18import { UserEdit } from './user-edit'
@@ -27,7 +27,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
27 27
28 constructor ( 28 constructor (
29 protected serverService: ServerService, 29 protected serverService: ServerService,
30 protected formValidatorService: FormValidatorService, 30 protected formReactiveService: FormReactiveService,
31 protected configService: ConfigService, 31 protected configService: ConfigService,
32 protected screenService: ScreenService, 32 protected screenService: ScreenService,
33 protected auth: AuthService, 33 protected auth: AuthService,
diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html
index da5879a36..e51ccf808 100644
--- a/client/src/app/+admin/overview/users/user-edit/user-edit.component.html
+++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.html
@@ -204,7 +204,7 @@
204</div> 204</div>
205 205
206 206
207<div *ngIf="!isCreation() && user && user.pluginAuth === null" class="row mt-4"> <!-- danger zone grid --> 207<div *ngIf="displayDangerZone()" class="row mt-4"> <!-- danger zone grid -->
208 <div class="col-12 col-lg-4 col-xl-3"> 208 <div class="col-12 col-lg-4 col-xl-3">
209 <div class="anchor" id="danger"></div> <!-- danger zone anchor --> 209 <div class="anchor" id="danger"></div> <!-- danger zone anchor -->
210 <div i18n class="account-title account-title-danger">DANGER ZONE</div> 210 <div i18n class="account-title account-title-danger">DANGER ZONE</div>
@@ -213,7 +213,7 @@
213 <div class="col-12 col-lg-8 col-xl-9"> 213 <div class="col-12 col-lg-8 col-xl-9">
214 214
215 <div class="danger-zone"> 215 <div class="danger-zone">
216 <div class="form-group reset-password-email"> 216 <div class="form-group">
217 <label i18n>Send a link to reset the password by email to the user</label> 217 <label i18n>Send a link to reset the password by email to the user</label>
218 <button (click)="resetPassword()" i18n>Ask for new password</button> 218 <button (click)="resetPassword()" i18n>Ask for new password</button>
219 </div> 219 </div>
@@ -222,6 +222,11 @@
222 <label i18n>Manually set the user password</label> 222 <label i18n>Manually set the user password</label>
223 <my-user-password [userId]="user.id"></my-user-password> 223 <my-user-password [userId]="user.id"></my-user-password>
224 </div> 224 </div>
225
226 <div *ngIf="user.twoFactorEnabled" class="form-group">
227 <label i18n>This user has two factor authentication enabled</label>
228 <button (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
229 </div>
225 </div> 230 </div>
226 231
227 </div> 232 </div>
diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss b/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss
index 68fa1215f..698628149 100644
--- a/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss
+++ b/client/src/app/+admin/overview/users/user-edit/user-edit.component.scss
@@ -48,17 +48,13 @@ my-user-real-quota-info {
48} 48}
49 49
50.danger-zone { 50.danger-zone {
51 .reset-password-email { 51 button {
52 margin-bottom: 30px; 52 @include peertube-button;
53 @include danger-button;
54 @include disable-outline;
53 55
54 button { 56 display: block;
55 @include peertube-button; 57 margin-top: 0;
56 @include danger-button;
57 @include disable-outline;
58
59 display: block;
60 margin-top: 0;
61 }
62 } 58 }
63} 59}
64 60
diff --git a/client/src/app/+admin/overview/users/user-edit/user-edit.ts b/client/src/app/+admin/overview/users/user-edit/user-edit.ts
index 6dae4110d..21e9629ab 100644
--- a/client/src/app/+admin/overview/users/user-edit/user-edit.ts
+++ b/client/src/app/+admin/overview/users/user-edit/user-edit.ts
@@ -60,10 +60,22 @@ export abstract class UserEdit extends FormReactive implements OnInit {
60 ] 60 ]
61 } 61 }
62 62
63 displayDangerZone () {
64 if (this.isCreation()) return false
65 if (this.user?.pluginAuth) return false
66 if (this.auth.getUser().id === this.user.id) return false
67
68 return true
69 }
70
63 resetPassword () { 71 resetPassword () {
64 return 72 return
65 } 73 }
66 74
75 disableTwoFactorAuth () {
76 return
77 }
78
67 getUserVideoQuota () { 79 getUserVideoQuota () {
68 return this.form.value['videoQuota'] 80 return this.form.value['videoQuota']
69 } 81 }
diff --git a/client/src/app/+admin/overview/users/user-edit/user-password.component.ts b/client/src/app/+admin/overview/users/user-edit/user-password.component.ts
index 8999d1f00..d6616e077 100644
--- a/client/src/app/+admin/overview/users/user-edit/user-password.component.ts
+++ b/client/src/app/+admin/overview/users/user-edit/user-password.component.ts
@@ -1,7 +1,7 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' 3import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { UserAdminService } from '@app/shared/shared-users' 5import { UserAdminService } from '@app/shared/shared-users'
6import { UserUpdate } from '@shared/models' 6import { UserUpdate } from '@shared/models'
7 7
@@ -18,7 +18,7 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
18 @Input() userId: number 18 @Input() userId: number
19 19
20 constructor ( 20 constructor (
21 protected formValidatorService: FormValidatorService, 21 protected formReactiveService: FormReactiveService,
22 private notifier: Notifier, 22 private notifier: Notifier,
23 private userAdminService: UserAdminService 23 private userAdminService: UserAdminService
24 ) { 24 ) {
diff --git a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts
index bab288a67..71212b19c 100644
--- a/client/src/app/+admin/overview/users/user-edit/user-update.component.ts
+++ b/client/src/app/+admin/overview/users/user-edit/user-update.component.ts
@@ -9,8 +9,8 @@ import {
9 USER_VIDEO_QUOTA_DAILY_VALIDATOR, 9 USER_VIDEO_QUOTA_DAILY_VALIDATOR,
10 USER_VIDEO_QUOTA_VALIDATOR 10 USER_VIDEO_QUOTA_VALIDATOR
11} from '@app/shared/form-validators/user-validators' 11} from '@app/shared/form-validators/user-validators'
12import { FormValidatorService } from '@app/shared/shared-forms' 12import { FormReactiveService } from '@app/shared/shared-forms'
13import { UserAdminService } from '@app/shared/shared-users' 13import { TwoFactorService, UserAdminService } from '@app/shared/shared-users'
14import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models' 14import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models'
15import { UserEdit } from './user-edit' 15import { UserEdit } from './user-edit'
16 16
@@ -25,7 +25,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
25 private paramsSub: Subscription 25 private paramsSub: Subscription
26 26
27 constructor ( 27 constructor (
28 protected formValidatorService: FormValidatorService, 28 protected formReactiveService: FormReactiveService,
29 protected serverService: ServerService, 29 protected serverService: ServerService,
30 protected configService: ConfigService, 30 protected configService: ConfigService,
31 protected screenService: ScreenService, 31 protected screenService: ScreenService,
@@ -34,6 +34,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
34 private router: Router, 34 private router: Router,
35 private notifier: Notifier, 35 private notifier: Notifier,
36 private userService: UserService, 36 private userService: UserService,
37 private twoFactorService: TwoFactorService,
37 private userAdminService: UserAdminService 38 private userAdminService: UserAdminService
38 ) { 39 ) {
39 super() 40 super()
@@ -120,10 +121,22 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
120 this.notifier.success($localize`An email asking for password reset has been sent to ${this.user.username}.`) 121 this.notifier.success($localize`An email asking for password reset has been sent to ${this.user.username}.`)
121 }, 122 },
122 123
123 error: err => { 124 error: err => this.notifier.error(err.message)
124 this.error = err.message 125 })
125 } 126 }
127
128 disableTwoFactorAuth () {
129 this.twoFactorService.disableTwoFactor({ userId: this.user.id })
130 .subscribe({
131 next: () => {
132 this.user.twoFactorEnabled = false
133
134 this.notifier.success($localize`Two factor authentication of ${this.user.username} disabled.`)
135 },
136
137 error: err => this.notifier.error(err.message)
126 }) 138 })
139
127 } 140 }
128 141
129 private onUserFetched (userJson: UserType) { 142 private onUserFetched (userJson: UserType) {
diff --git a/client/src/app/+admin/overview/users/user-list/user-list.component.scss b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
index 3c775cac5..23e0d29ee 100644
--- a/client/src/app/+admin/overview/users/user-list/user-list.component.scss
+++ b/client/src/app/+admin/overview/users/user-list/user-list.component.scss
@@ -1,6 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '~bootstrap/scss/functions' as *; 3@use 'bootstrap/scss/functions' as *;
4 4
5.add-button { 5.add-button {
6 @include create-button; 6 @include create-button;
diff --git a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
index ec02cfcd9..b1a41567e 100644
--- a/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
+++ b/client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
@@ -4,7 +4,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
4import { ActivatedRoute } from '@angular/router' 4import { ActivatedRoute } from '@angular/router'
5import { HooksService, Notifier, PluginService } from '@app/core' 5import { HooksService, Notifier, PluginService } from '@app/core'
6import { BuildFormArgument } from '@app/shared/form-validators' 6import { BuildFormArgument } from '@app/shared/form-validators'
7import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 7import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
8import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models' 8import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models'
9import { PluginApiService } from '../shared/plugin-api.service' 9import { PluginApiService } from '../shared/plugin-api.service'
10 10
@@ -22,7 +22,7 @@ export class PluginShowInstalledComponent extends FormReactive implements OnInit
22 private npmName: string 22 private npmName: string
23 23
24 constructor ( 24 constructor (
25 protected formValidatorService: FormValidatorService, 25 protected formReactiveService: FormReactiveService,
26 private pluginService: PluginService, 26 private pluginService: PluginService,
27 private pluginAPIService: PluginApiService, 27 private pluginAPIService: PluginApiService,
28 private notifier: Notifier, 28 private notifier: Notifier,
diff --git a/client/src/app/+login/login.component.html b/client/src/app/+login/login.component.html
index f3a2476f9..49b443a20 100644
--- a/client/src/app/+login/login.component.html
+++ b/client/src/app/+login/login.component.html
@@ -39,34 +39,48 @@
39 <div class="login-form-and-externals"> 39 <div class="login-form-and-externals">
40 40
41 <form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form"> 41 <form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form">
42 <div class="form-group"> 42 <ng-container *ngIf="!otpStep">
43 <div> 43 <div class="form-group">
44 <label i18n for="username">Username or email address</label> 44 <div>
45 <input 45 <label i18n for="username">Username or email address</label>
46 type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1" 46 <input
47 formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus 47 type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
48 > 48 formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
49 >
50 </div>
51
52 <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
53
54 <div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
55 ⚠️ Most email addresses do not include capital letters.
56 </div>
49 </div> 57 </div>
50 58
51 <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div> 59 <div class="form-group">
60 <label i18n for="password">Password</label>
52 61
53 <div *ngIf="hasUsernameUppercase()" i18n class="form-warning"> 62 <my-input-text
54 ⚠️ Most email addresses do not include capital letters. 63 formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
64 [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
65 ></my-input-text>
55 </div> 66 </div>
56 </div> 67 </ng-container>
68
69 <div *ngIf="otpStep" class="form-group">
70 <p i18n>Enter the two-factor code generated by your phone app:</p>
57 71
58 <div class="form-group"> 72 <label i18n for="otp-token">Two factor authentication token</label>
59 <label i18n for="password">Password</label>
60 73
61 <my-input-text 74 <my-input-text
62 formControlName="password" inputId="password" i18n-placeholder placeholder="Password" 75 #otpTokenInput
63 [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2" 76 [show]="true" formControlName="otp-token" inputId="otp-token"
77 [formError]="formErrors['otp-token']" autocomplete="otp-token"
64 ></my-input-text> 78 ></my-input-text>
65 </div> 79 </div>
66 80
67 <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid"> 81 <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid">
68 82
69 <div class="additional-links"> 83 <div *ngIf="!otpStep" class="additional-links">
70 <a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a> 84 <a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
71 85
72 <ng-container *ngIf="signupAllowed"> 86 <ng-container *ngIf="signupAllowed">
diff --git a/client/src/app/+login/login.component.scss b/client/src/app/+login/login.component.scss
index d31d428f7..17e151fd8 100644
--- a/client/src/app/+login/login.component.scss
+++ b/client/src/app/+login/login.component.scss
@@ -1,8 +1,8 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4@import '~bootstrap/scss/functions'; 4@import 'bootstrap/scss/functions';
5@import '~bootstrap/scss/variables'; 5@import 'bootstrap/scss/variables';
6 6
7label { 7label {
8 display: block; 8 display: block;
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
index 2ed9be16c..c1705807f 100644
--- a/client/src/app/+login/login.component.ts
+++ b/client/src/app/+login/login.component.ts
@@ -1,10 +1,10 @@
1
2import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 1import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
3import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' 3import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
5import { HooksService } from '@app/core/plugins/hooks.service' 4import { HooksService } from '@app/core/plugins/hooks.service'
6import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators' 5import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators'
7import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
7import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { PluginsManager } from '@root-helpers/plugins-manager' 10import { PluginsManager } from '@root-helpers/plugins-manager'
@@ -20,6 +20,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
20 private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url' 20 private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url'
21 21
22 @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef 22 @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
23 @ViewChild('otpTokenInput') otpTokenInput: InputTextComponent
23 24
24 accordion: NgbAccordion 25 accordion: NgbAccordion
25 error: string = null 26 error: string = null
@@ -37,11 +38,13 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
37 codeOfConduct: false 38 codeOfConduct: false
38 } 39 }
39 40
41 otpStep = false
42
40 private openedForgotPasswordModal: NgbModalRef 43 private openedForgotPasswordModal: NgbModalRef
41 private serverConfig: ServerConfig 44 private serverConfig: ServerConfig
42 45
43 constructor ( 46 constructor (
44 protected formValidatorService: FormValidatorService, 47 protected formReactiveService: FormReactiveService,
45 private route: ActivatedRoute, 48 private route: ActivatedRoute,
46 private modalService: NgbModal, 49 private modalService: NgbModal,
47 private authService: AuthService, 50 private authService: AuthService,
@@ -82,7 +85,11 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
82 // Avoid undefined errors when accessing form error properties 85 // Avoid undefined errors when accessing form error properties
83 this.buildForm({ 86 this.buildForm({
84 username: LOGIN_USERNAME_VALIDATOR, 87 username: LOGIN_USERNAME_VALIDATOR,
85 password: LOGIN_PASSWORD_VALIDATOR 88 password: LOGIN_PASSWORD_VALIDATOR,
89 'otp-token': {
90 VALIDATORS: [], // Will be set dynamically
91 MESSAGES: USER_OTP_TOKEN_VALIDATOR.MESSAGES
92 }
86 }) 93 })
87 94
88 this.serverConfig = snapshot.data.serverConfig 95 this.serverConfig = snapshot.data.serverConfig
@@ -118,13 +125,20 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
118 login () { 125 login () {
119 this.error = null 126 this.error = null
120 127
121 const { username, password } = this.form.value 128 const options = {
129 username: this.form.value['username'],
130 password: this.form.value['password'],
131 otpToken: this.form.value['otp-token']
132 }
122 133
123 this.authService.login(username, password) 134 this.authService.login(options)
135 .pipe()
124 .subscribe({ 136 .subscribe({
125 next: () => this.redirectService.redirectToPreviousRoute(), 137 next: () => this.redirectService.redirectToPreviousRoute(),
126 138
127 error: err => this.handleError(err) 139 error: err => {
140 this.handleError(err)
141 }
128 }) 142 })
129 } 143 }
130 144
@@ -162,7 +176,7 @@ The link will expire within 1 hour.`
162 private loadExternalAuthToken (username: string, token: string) { 176 private loadExternalAuthToken (username: string, token: string) {
163 this.isAuthenticatedWithExternalAuth = true 177 this.isAuthenticatedWithExternalAuth = true
164 178
165 this.authService.login(username, null, token) 179 this.authService.login({ username, password: null, token })
166 .subscribe({ 180 .subscribe({
167 next: () => { 181 next: () => {
168 const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY) 182 const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY)
@@ -182,6 +196,17 @@ The link will expire within 1 hour.`
182 } 196 }
183 197
184 private handleError (err: any) { 198 private handleError (err: any) {
199 if (this.authService.isOTPMissingError(err)) {
200 this.otpStep = true
201
202 setTimeout(() => {
203 this.form.get('otp-token').setValidators(USER_OTP_TOKEN_VALIDATOR.VALIDATORS)
204 this.otpTokenInput.focus()
205 })
206
207 return
208 }
209
185 if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.` 210 if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
186 else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.` 211 else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
187 else this.error = err.message 212 else this.error = err.message
diff --git a/client/src/app/+manage/video-channel-edit/video-channel-create.component.ts b/client/src/app/+manage/video-channel-edit/video-channel-create.component.ts
index 8211451a4..372066890 100644
--- a/client/src/app/+manage/video-channel-edit/video-channel-create.component.ts
+++ b/client/src/app/+manage/video-channel-edit/video-channel-create.component.ts
@@ -9,7 +9,7 @@ import {
9 VIDEO_CHANNEL_NAME_VALIDATOR, 9 VIDEO_CHANNEL_NAME_VALIDATOR,
10 VIDEO_CHANNEL_SUPPORT_VALIDATOR 10 VIDEO_CHANNEL_SUPPORT_VALIDATOR
11} from '@app/shared/form-validators/video-channel-validators' 11} from '@app/shared/form-validators/video-channel-validators'
12import { FormValidatorService } from '@app/shared/shared-forms' 12import { FormReactiveService } from '@app/shared/shared-forms'
13import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' 13import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
14import { HttpStatusCode, VideoChannelCreate } from '@shared/models' 14import { HttpStatusCode, VideoChannelCreate } from '@shared/models'
15import { VideoChannelEdit } from './video-channel-edit' 15import { VideoChannelEdit } from './video-channel-edit'
@@ -26,7 +26,7 @@ export class VideoChannelCreateComponent extends VideoChannelEdit implements OnI
26 private banner: FormData 26 private banner: FormData
27 27
28 constructor ( 28 constructor (
29 protected formValidatorService: FormValidatorService, 29 protected formReactiveService: FormReactiveService,
30 private authService: AuthService, 30 private authService: AuthService,
31 private notifier: Notifier, 31 private notifier: Notifier,
32 private router: Router, 32 private router: Router,
diff --git a/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts b/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
index 7e8d6ffe6..32f6d650d 100644
--- a/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
+++ b/client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
@@ -9,7 +9,7 @@ import {
9 VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, 9 VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
10 VIDEO_CHANNEL_SUPPORT_VALIDATOR 10 VIDEO_CHANNEL_SUPPORT_VALIDATOR
11} from '@app/shared/form-validators/video-channel-validators' 11} from '@app/shared/form-validators/video-channel-validators'
12import { FormValidatorService } from '@app/shared/shared-forms' 12import { FormReactiveService } from '@app/shared/shared-forms'
13import { VideoChannel, VideoChannelService } from '@app/shared/shared-main' 13import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
14import { HTMLServerConfig, VideoChannelUpdate } from '@shared/models' 14import { HTMLServerConfig, VideoChannelUpdate } from '@shared/models'
15import { VideoChannelEdit } from './video-channel-edit' 15import { VideoChannelEdit } from './video-channel-edit'
@@ -28,7 +28,7 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
28 private serverConfig: HTMLServerConfig 28 private serverConfig: HTMLServerConfig
29 29
30 constructor ( 30 constructor (
31 protected formValidatorService: FormValidatorService, 31 protected formReactiveService: FormReactiveService,
32 private authService: AuthService, 32 private authService: AuthService,
33 private notifier: Notifier, 33 private notifier: Notifier,
34 private route: ActivatedRoute, 34 private route: ActivatedRoute,
diff --git a/client/src/app/+my-account/my-account-routing.module.ts b/client/src/app/+my-account/my-account-routing.module.ts
index ef39c1a36..b39b1f6b4 100644
--- a/client/src/app/+my-account/my-account-routing.module.ts
+++ b/client/src/app/+my-account/my-account-routing.module.ts
@@ -7,6 +7,7 @@ import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-b
7import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component' 7import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
8import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component' 8import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 9import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
10import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
10import { MyAccountComponent } from './my-account.component' 11import { MyAccountComponent } from './my-account.component'
11 12
12const myAccountRoutes: Routes = [ 13const myAccountRoutes: Routes = [
@@ -31,6 +32,16 @@ const myAccountRoutes: Routes = [
31 }, 32 },
32 33
33 { 34 {
35 path: 'two-factor-auth',
36 component: MyAccountTwoFactorComponent,
37 data: {
38 meta: {
39 title: $localize`Two factor authentication`
40 }
41 }
42 },
43
44 {
34 path: 'video-channels', 45 path: 'video-channels',
35 redirectTo: '/my-library/video-channels', 46 redirectTo: '/my-library/video-channels',
36 pathMatch: 'full' 47 pathMatch: 'full'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
index 9b87daa40..235fbec4a 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
@@ -3,8 +3,8 @@ import { tap } from 'rxjs/operators'
3import { Component, OnInit } from '@angular/core' 3import { Component, OnInit } from '@angular/core'
4import { AuthService, ServerService, UserService } from '@app/core' 4import { AuthService, ServerService, UserService } from '@app/core'
5import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' 5import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { User } from '@shared/models' 7import { HttpStatusCode, User } from '@shared/models'
8 8
9@Component({ 9@Component({
10 selector: 'my-account-change-email', 10 selector: 'my-account-change-email',
@@ -17,7 +17,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
17 user: User = null 17 user: User = null
18 18
19 constructor ( 19 constructor (
20 protected formValidatorService: FormValidatorService, 20 protected formReactiveService: FormReactiveService,
21 private authService: AuthService, 21 private authService: AuthService,
22 private userService: UserService, 22 private userService: UserService,
23 private serverService: ServerService 23 private serverService: ServerService
@@ -57,7 +57,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
57 }, 57 },
58 58
59 error: err => { 59 error: err => {
60 if (err.status === 401) { 60 if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
61 this.error = $localize`You current password is invalid.` 61 this.error = $localize`You current password is invalid.`
62 return 62 return
63 } 63 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
index 47e54dc23..805d50070 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
@@ -6,8 +6,8 @@ import {
6 USER_EXISTING_PASSWORD_VALIDATOR, 6 USER_EXISTING_PASSWORD_VALIDATOR,
7 USER_PASSWORD_VALIDATOR 7 USER_PASSWORD_VALIDATOR
8} from '@app/shared/form-validators/user-validators' 8} from '@app/shared/form-validators/user-validators'
9import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 9import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
10import { User } from '@shared/models' 10import { HttpStatusCode, User } from '@shared/models'
11 11
12@Component({ 12@Component({
13 selector: 'my-account-change-password', 13 selector: 'my-account-change-password',
@@ -19,7 +19,7 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
19 user: User = null 19 user: User = null
20 20
21 constructor ( 21 constructor (
22 protected formValidatorService: FormValidatorService, 22 protected formReactiveService: FormReactiveService,
23 private notifier: Notifier, 23 private notifier: Notifier,
24 private authService: AuthService, 24 private authService: AuthService,
25 private userService: UserService 25 private userService: UserService
@@ -57,7 +57,7 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
57 }, 57 },
58 58
59 error: err => { 59 error: err => {
60 if (err.status === 401) { 60 if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
61 this.error = $localize`You current password is invalid.` 61 this.error = $localize`You current password is invalid.`
62 return 62 return
63 } 63 }
diff --git a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
index 2bae3499e..9619623ee 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
@@ -18,7 +18,7 @@ export class MyAccountDangerZoneComponent {
18 ) { } 18 ) { }
19 19
20 async deleteMe () { 20 async deleteMe () {
21 const res = await this.confirmService.confirmWithInput( 21 const res = await this.confirmService.confirmWithExpectedInput(
22 $localize`Are you sure you want to delete your account?` + 22 $localize`Are you sure you want to delete your account?` +
23 '<br /><br />' + 23 '<br /><br />' +
24 // eslint-disable-next-line max-len 24 // eslint-disable-next-line max-len
diff --git a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
index f395ad73f..8621eb7aa 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
+++ b/client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
@@ -2,7 +2,7 @@ import { Subject } from 'rxjs'
2import { Component, Input, OnInit } from '@angular/core' 2import { Component, Input, OnInit } from '@angular/core'
3import { Notifier, User, UserService } from '@app/core' 3import { Notifier, User, UserService } from '@app/core'
4import { USER_DESCRIPTION_VALIDATOR, USER_DISPLAY_NAME_REQUIRED_VALIDATOR } from '@app/shared/form-validators/user-validators' 4import { USER_DESCRIPTION_VALIDATOR, USER_DISPLAY_NAME_REQUIRED_VALIDATOR } from '@app/shared/form-validators/user-validators'
5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6 6
7@Component({ 7@Component({
8 selector: 'my-account-profile', 8 selector: 'my-account-profile',
@@ -16,7 +16,7 @@ export class MyAccountProfileComponent extends FormReactive implements OnInit {
16 error: string = null 16 error: string = null
17 17
18 constructor ( 18 constructor (
19 protected formValidatorService: FormValidatorService, 19 protected formReactiveService: FormReactiveService,
20 private notifier: Notifier, 20 private notifier: Notifier,
21 private userService: UserService 21 private userService: UserService
22 ) { 22 ) {
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
index 42a8d0856..666205de6 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.html
@@ -62,6 +62,16 @@
62 </div> 62 </div>
63</div> 63</div>
64 64
65<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- two factor auth grid -->
66 <div class="col-12 col-lg-4 col-xl-3">
67 <h2 i18n class="account-title">Two-factor authentication</h2>
68 </div>
69
70 <div class="col-12 col-lg-8 col-xl-9">
71 <my-account-two-factor-button [user]="user" [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
72 </div>
73</div>
74
65<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid --> 75<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
66 <div class="col-12 col-lg-4 col-xl-3"> 76 <div class="col-12 col-lg-4 col-xl-3">
67 <h2 i18n class="account-title">EMAIL</h2> 77 <h2 i18n class="account-title">EMAIL</h2>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
index 8206f4dd8..3d686a146 100644
--- a/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
+++ b/client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
@@ -1,6 +1,6 @@
1@use '_variables' as *; 1@use '_variables' as *;
2@use '_mixins' as *; 2@use '_mixins' as *;
3@use '~bootstrap/scss/functions' as *; 3@use 'bootstrap/scss/functions' as *;
4 4
5.account-title { 5.account-title {
6 @include settings-big-title; 6 @include settings-big-title;
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts
new file mode 100644
index 000000000..cc774bde3
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts
@@ -0,0 +1,2 @@
1export * from './my-account-two-factor-button.component'
2export * from './my-account-two-factor.component'
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html
new file mode 100644
index 000000000..2fcfffbf3
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html
@@ -0,0 +1,12 @@
1<div class="two-factor">
2 <ng-container *ngIf="!twoFactorEnabled">
3 <p i18n>Two factor authentication adds an additional layer of security to your account by requiring a numeric code from another device (most commonly mobile phones) when you log in.</p>
4
5 <my-button [routerLink]="[ '/my-account/two-factor-auth' ]" className="orange-button-link" i18n>Enable two-factor authentication</my-button>
6 </ng-container>
7
8 <ng-container *ngIf="twoFactorEnabled">
9 <my-button className="orange-button" (click)="disableTwoFactor()" i18n>Disable two-factor authentication</my-button>
10 </ng-container>
11
12</div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
new file mode 100644
index 000000000..97ffb6013
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
@@ -0,0 +1,49 @@
1import { Subject } from 'rxjs'
2import { Component, Input, OnInit } from '@angular/core'
3import { AuthService, ConfirmService, Notifier, User } from '@app/core'
4import { TwoFactorService } from '@app/shared/shared-users'
5
6@Component({
7 selector: 'my-account-two-factor-button',
8 templateUrl: './my-account-two-factor-button.component.html'
9})
10export class MyAccountTwoFactorButtonComponent implements OnInit {
11 @Input() user: User = null
12 @Input() userInformationLoaded: Subject<any>
13
14 twoFactorEnabled = false
15
16 constructor (
17 private notifier: Notifier,
18 private twoFactorService: TwoFactorService,
19 private confirmService: ConfirmService,
20 private auth: AuthService
21 ) {
22 }
23
24 ngOnInit () {
25 this.userInformationLoaded.subscribe(() => {
26 this.twoFactorEnabled = this.user.twoFactorEnabled
27 })
28 }
29
30 async disableTwoFactor () {
31 const message = $localize`Are you sure you want to disable two factor authentication of your account?`
32
33 const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
34 if (confirmed === false) return
35
36 this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
37 .subscribe({
38 next: () => {
39 this.twoFactorEnabled = false
40
41 this.auth.refreshUserInformation()
42
43 this.notifier.success($localize`Two factor authentication disabled`)
44 },
45
46 error: err => this.notifier.error(err.message)
47 })
48 }
49}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html
new file mode 100644
index 000000000..16c344e3b
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html
@@ -0,0 +1,54 @@
1<h1>
2 <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
3 <ng-container i18n>Two factor authentication</ng-container>
4</h1>
5
6<div i18n *ngIf="twoFactorAlreadyEnabled === true" class="root already-enabled">
7 Two factor authentication is already enabled.
8</div>
9
10<div class="root" *ngIf="twoFactorAlreadyEnabled === false">
11 <ng-container *ngIf="step === 'request'">
12 <form role="form" (ngSubmit)="requestTwoFactor()" [formGroup]="formPassword">
13
14 <label i18n for="current-password">Your password</label>
15 <div class="form-group-description" i18n>Confirm your password to enable two factor authentication</div>
16
17 <my-input-text
18 formControlName="current-password" inputId="current-password" i18n-placeholder placeholder="Current password"
19 [formError]="formErrorsPassword['current-password']" autocomplete="current-password"
20 ></my-input-text>
21
22 <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formPassword.valid">
23 </form>
24 </ng-container>
25
26 <ng-container *ngIf="step === 'confirm'">
27
28 <p i18n>
29 Scan this QR code into a TOTP app on your phone. This app will generate tokens that you will have to enter when logging in.
30 </p>
31
32 <qrcode [qrdata]="twoFactorURI" [width]="256" level="Q"></qrcode>
33
34 <div i18n>
35 If you can't scan the QR code and need to enter it manually, here is the plain-text secret:
36 </div>
37
38 <div class="secret-plain-text">{{ twoFactorSecret }}</div>
39
40 <form class="mt-3" role="form" (ngSubmit)="confirmTwoFactor()" [formGroup]="formOTP">
41
42 <label i18n for="otp-token">Two-factor code</label>
43 <div class="form-group-description" i18n>Enter the code generated by your authenticator app to confirm</div>
44
45 <my-input-text
46 [show]="true" formControlName="otp-token" inputId="otp-token"
47 [formError]="formErrorsOTP['otp-token']" autocomplete="otp-token"
48 ></my-input-text>
49
50 <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formOTP.valid">
51 </form>
52 </ng-container>
53
54</div>
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss
new file mode 100644
index 000000000..cee016bb8
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss
@@ -0,0 +1,16 @@
1@use '_variables' as *;
2@use '_mixins' as *;
3
4.root {
5 max-width: 600px;
6}
7
8.secret-plain-text {
9 font-family: monospace;
10 font-size: 0.9rem;
11}
12
13qrcode {
14 display: inline-block;
15 margin: auto;
16}
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts
new file mode 100644
index 000000000..259090d64
--- /dev/null
+++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts
@@ -0,0 +1,105 @@
1import { Component, OnInit } from '@angular/core'
2import { FormGroup } from '@angular/forms'
3import { Router } from '@angular/router'
4import { AuthService, Notifier, User } from '@app/core'
5import { USER_EXISTING_PASSWORD_VALIDATOR, USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
6import { FormReactiveService } from '@app/shared/shared-forms'
7import { TwoFactorService } from '@app/shared/shared-users'
8
9@Component({
10 selector: 'my-account-two-factor',
11 templateUrl: './my-account-two-factor.component.html',
12 styleUrls: [ './my-account-two-factor.component.scss' ]
13})
14export class MyAccountTwoFactorComponent implements OnInit {
15 twoFactorAlreadyEnabled: boolean
16
17 step: 'request' | 'confirm' | 'confirmed' = 'request'
18
19 twoFactorSecret: string
20 twoFactorURI: string
21
22 inPasswordStep = true
23
24 formPassword: FormGroup
25 formErrorsPassword: any
26
27 formOTP: FormGroup
28 formErrorsOTP: any
29
30 private user: User
31 private requestToken: string
32
33 constructor (
34 private notifier: Notifier,
35 private twoFactorService: TwoFactorService,
36 private formReactiveService: FormReactiveService,
37 private auth: AuthService,
38 private router: Router
39 ) {
40 }
41
42 ngOnInit () {
43 this.buildPasswordForm()
44 this.buildOTPForm()
45
46 this.auth.userInformationLoaded.subscribe(() => {
47 this.user = this.auth.getUser()
48
49 this.twoFactorAlreadyEnabled = this.user.twoFactorEnabled
50 })
51 }
52
53 requestTwoFactor () {
54 this.twoFactorService.requestTwoFactor({
55 userId: this.user.id,
56 currentPassword: this.formPassword.value['current-password']
57 }).subscribe({
58 next: ({ otpRequest }) => {
59 this.requestToken = otpRequest.requestToken
60 this.twoFactorURI = otpRequest.uri
61 this.twoFactorSecret = otpRequest.secret.replace(/(.{4})/g, '$1 ').trim()
62
63 this.step = 'confirm'
64 },
65
66 error: err => this.notifier.error(err.message)
67 })
68 }
69
70 confirmTwoFactor () {
71 this.twoFactorService.confirmTwoFactorRequest({
72 userId: this.user.id,
73 requestToken: this.requestToken,
74 otpToken: this.formOTP.value['otp-token']
75 }).subscribe({
76 next: () => {
77 this.notifier.success($localize`Two factor authentication has been enabled.`)
78
79 this.auth.refreshUserInformation()
80
81 this.router.navigateByUrl('/my-account/settings')
82 },
83
84 error: err => this.notifier.error(err.message)
85 })
86 }
87
88 private buildPasswordForm () {
89 const { form, formErrors } = this.formReactiveService.buildForm({
90 'current-password': USER_EXISTING_PASSWORD_VALIDATOR
91 })
92
93 this.formPassword = form
94 this.formErrorsPassword = formErrors
95 }
96
97 private buildOTPForm () {
98 const { form, formErrors } = this.formReactiveService.buildForm({
99 'otp-token': USER_OTP_TOKEN_VALIDATOR
100 })
101
102 this.formOTP = form
103 this.formErrorsOTP = formErrors
104 }
105}
diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts
index 4081e4f01..84b057647 100644
--- a/client/src/app/+my-account/my-account.module.ts
+++ b/client/src/app/+my-account/my-account.module.ts
@@ -1,3 +1,4 @@
1import { QRCodeModule } from 'angularx-qrcode'
1import { AutoCompleteModule } from 'primeng/autocomplete' 2import { AutoCompleteModule } from 'primeng/autocomplete'
2import { TableModule } from 'primeng/table' 3import { TableModule } from 'primeng/table'
3import { DragDropModule } from '@angular/cdk/drag-drop' 4import { DragDropModule } from '@angular/cdk/drag-drop'
@@ -10,6 +11,7 @@ import { SharedMainModule } from '@app/shared/shared-main'
10import { SharedModerationModule } from '@app/shared/shared-moderation' 11import { SharedModerationModule } from '@app/shared/shared-moderation'
11import { SharedShareModal } from '@app/shared/shared-share-modal' 12import { SharedShareModal } from '@app/shared/shared-share-modal'
12import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings' 13import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
14import { SharedUsersModule } from '@app/shared/shared-users'
13import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module' 15import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
14import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component' 16import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
15import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component' 17import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
@@ -23,12 +25,14 @@ import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-d
23import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences' 25import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
24import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component' 26import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
25import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component' 27import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
28import { MyAccountTwoFactorButtonComponent, MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
26import { MyAccountComponent } from './my-account.component' 29import { MyAccountComponent } from './my-account.component'
27 30
28@NgModule({ 31@NgModule({
29 imports: [ 32 imports: [
30 MyAccountRoutingModule, 33 MyAccountRoutingModule,
31 34
35 QRCodeModule,
32 AutoCompleteModule, 36 AutoCompleteModule,
33 TableModule, 37 TableModule,
34 DragDropModule, 38 DragDropModule,
@@ -37,6 +41,7 @@ import { MyAccountComponent } from './my-account.component'
37 SharedFormModule, 41 SharedFormModule,
38 SharedModerationModule, 42 SharedModerationModule,
39 SharedUserInterfaceSettingsModule, 43 SharedUserInterfaceSettingsModule,
44 SharedUsersModule,
40 SharedGlobalIconModule, 45 SharedGlobalIconModule,
41 SharedAbuseListModule, 46 SharedAbuseListModule,
42 SharedShareModal, 47 SharedShareModal,
@@ -52,6 +57,9 @@ import { MyAccountComponent } from './my-account.component'
52 MyAccountChangeEmailComponent, 57 MyAccountChangeEmailComponent,
53 MyAccountApplicationsComponent, 58 MyAccountApplicationsComponent,
54 59
60 MyAccountTwoFactorButtonComponent,
61 MyAccountTwoFactorComponent,
62
55 MyAccountDangerZoneComponent, 63 MyAccountDangerZoneComponent,
56 MyAccountBlocklistComponent, 64 MyAccountBlocklistComponent,
57 MyAccountAbusesListComponent, 65 MyAccountAbusesListComponent,
diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
index 205ad7a89..ece59c2ff 100644
--- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
+++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
@@ -40,7 +40,7 @@ export class MyVideoChannelsComponent {
40 } 40 }
41 41
42 async deleteVideoChannel (videoChannel: VideoChannel) { 42 async deleteVideoChannel (videoChannel: VideoChannel) {
43 const res = await this.confirmService.confirmWithInput( 43 const res = await this.confirmService.confirmWithExpectedInput(
44 $localize`Do you really want to delete ${videoChannel.displayName}? 44 $localize`Do you really want to delete ${videoChannel.displayName}?
45It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another 45It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
46channel with the same name (${videoChannel.name})!`, 46channel with the same name (${videoChannel.name})!`,
diff --git a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
index 8ead237c7..ca7eb680b 100644
--- a/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
+++ b/client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
@@ -3,7 +3,7 @@ import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '
3import { AuthService, Notifier } from '@app/core' 3import { AuthService, Notifier } from '@app/core'
4import { listUserChannelsForSelect } from '@app/helpers' 4import { listUserChannelsForSelect } from '@app/helpers'
5import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators' 5import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { VideoOwnershipService } from '@app/shared/shared-main' 7import { VideoOwnershipService } from '@app/shared/shared-main'
8import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 8import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
9import { VideoChangeOwnership } from '@shared/models' 9import { VideoChangeOwnership } from '@shared/models'
@@ -24,7 +24,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
24 error: string = null 24 error: string = null
25 25
26 constructor ( 26 constructor (
27 protected formValidatorService: FormValidatorService, 27 protected formReactiveService: FormReactiveService,
28 private videoOwnershipService: VideoOwnershipService, 28 private videoOwnershipService: VideoOwnershipService,
29 private notifier: Notifier, 29 private notifier: Notifier,
30 private authService: AuthService, 30 private authService: AuthService,
diff --git a/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts
index 9ceb6dfd1..a14ab5b92 100644
--- a/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts
+++ b/client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts
@@ -5,7 +5,7 @@ import { Router } from '@angular/router'
5import { AuthService, Notifier } from '@app/core' 5import { AuthService, Notifier } from '@app/core'
6import { listUserChannelsForSelect } from '@app/helpers' 6import { listUserChannelsForSelect } from '@app/helpers'
7import { VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR } from '@app/shared/form-validators/video-channel-validators' 7import { VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
8import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 8import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
9import { VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main' 9import { VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main'
10import { VideoChannelSyncCreate } from '@shared/models/videos' 10import { VideoChannelSyncCreate } from '@shared/models/videos'
11 11
@@ -20,7 +20,7 @@ export class VideoChannelSyncEditComponent extends FormReactive implements OnIni
20 existingVideosStrategy: string 20 existingVideosStrategy: string
21 21
22 constructor ( 22 constructor (
23 protected formValidatorService: FormValidatorService, 23 protected formReactiveService: FormReactiveService,
24 private authService: AuthService, 24 private authService: AuthService,
25 private router: Router, 25 private router: Router,
26 private notifier: Notifier, 26 private notifier: Notifier,
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
index 9eb3e9888..63f72df3f 100644
--- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
@@ -9,7 +9,7 @@ import {
9 VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR, 9 VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
10 VIDEO_PLAYLIST_PRIVACY_VALIDATOR 10 VIDEO_PLAYLIST_PRIVACY_VALIDATOR
11} from '@app/shared/form-validators/video-playlist-validators' 11} from '@app/shared/form-validators/video-playlist-validators'
12import { FormValidatorService } from '@app/shared/shared-forms' 12import { FormReactiveService } from '@app/shared/shared-forms'
13import { VideoPlaylistService } from '@app/shared/shared-video-playlist' 13import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
14import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model' 14import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
15import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model' 15import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
@@ -23,7 +23,7 @@ export class MyVideoPlaylistCreateComponent extends MyVideoPlaylistEdit implemen
23 error: string 23 error: string
24 24
25 constructor ( 25 constructor (
26 protected formValidatorService: FormValidatorService, 26 protected formReactiveService: FormReactiveService,
27 private authService: AuthService, 27 private authService: AuthService,
28 private notifier: Notifier, 28 private notifier: Notifier,
29 private router: Router, 29 private router: Router,
diff --git a/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts b/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
index ef7ba0018..bbe8a5f80 100644
--- a/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
+++ b/client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
@@ -11,7 +11,7 @@ import {
11 VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR, 11 VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
12 VIDEO_PLAYLIST_PRIVACY_VALIDATOR 12 VIDEO_PLAYLIST_PRIVACY_VALIDATOR
13} from '@app/shared/form-validators/video-playlist-validators' 13} from '@app/shared/form-validators/video-playlist-validators'
14import { FormValidatorService } from '@app/shared/shared-forms' 14import { FormReactiveService } from '@app/shared/shared-forms'
15import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist' 15import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
16import { VideoPlaylistUpdate } from '@shared/models' 16import { VideoPlaylistUpdate } from '@shared/models'
17import { MyVideoPlaylistEdit } from './my-video-playlist-edit' 17import { MyVideoPlaylistEdit } from './my-video-playlist-edit'
@@ -27,7 +27,7 @@ export class MyVideoPlaylistUpdateComponent extends MyVideoPlaylistEdit implemen
27 private paramsSub: Subscription 27 private paramsSub: Subscription
28 28
29 constructor ( 29 constructor (
30 protected formValidatorService: FormValidatorService, 30 protected formReactiveService: FormReactiveService,
31 private authService: AuthService, 31 private authService: AuthService,
32 private notifier: Notifier, 32 private notifier: Notifier,
33 private router: Router, 33 private router: Router,
diff --git a/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts
index 960c9a4f7..72187e893 100644
--- a/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts
+++ b/client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts
@@ -1,7 +1,7 @@
1import { Component, ElementRef, OnInit, ViewChild } from '@angular/core' 1import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { Notifier, UserService } from '@app/core' 2import { Notifier, UserService } from '@app/core'
3import { OWNERSHIP_CHANGE_USERNAME_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators' 3import { OWNERSHIP_CHANGE_USERNAME_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { Video, VideoOwnershipService } from '@app/shared/shared-main' 5import { Video, VideoOwnershipService } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7 7
@@ -20,7 +20,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni
20 private video: Video | undefined = undefined 20 private video: Video | undefined = undefined
21 21
22 constructor ( 22 constructor (
23 protected formValidatorService: FormValidatorService, 23 protected formReactiveService: FormReactiveService,
24 private videoOwnershipService: VideoOwnershipService, 24 private videoOwnershipService: VideoOwnershipService,
25 private notifier: Notifier, 25 private notifier: Notifier,
26 private userService: UserService, 26 private userService: UserService,
diff --git a/client/src/app/+reset-password/reset-password.component.ts b/client/src/app/+reset-password/reset-password.component.ts
index 11c5110fd..44216f978 100644
--- a/client/src/app/+reset-password/reset-password.component.ts
+++ b/client/src/app/+reset-password/reset-password.component.ts
@@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'
3import { Notifier, UserService } from '@app/core' 3import { Notifier, UserService } from '@app/core'
4import { RESET_PASSWORD_CONFIRM_VALIDATOR } from '@app/shared/form-validators/reset-password-validators' 4import { RESET_PASSWORD_CONFIRM_VALIDATOR } from '@app/shared/form-validators/reset-password-validators'
5import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators' 5import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7 7
8@Component({ 8@Component({
9 selector: 'my-login', 9 selector: 'my-login',
@@ -16,7 +16,7 @@ export class ResetPasswordComponent extends FormReactive implements OnInit {
16 private verificationString: string 16 private verificationString: string
17 17
18 constructor ( 18 constructor (
19 protected formValidatorService: FormValidatorService, 19 protected formReactiveService: FormReactiveService,
20 private userService: UserService, 20 private userService: UserService,
21 private notifier: Notifier, 21 private notifier: Notifier,
22 private router: Router, 22 private router: Router,
diff --git a/client/src/app/+signup/+register/register.component.ts b/client/src/app/+signup/+register/register.component.ts
index 4ab327b1b..958770ebf 100644
--- a/client/src/app/+signup/+register/register.component.ts
+++ b/client/src/app/+signup/+register/register.component.ts
@@ -158,7 +158,7 @@ export class RegisterComponent implements OnInit {
158 } 158 }
159 159
160 // Auto login 160 // Auto login
161 this.authService.login(body.username, body.password) 161 this.authService.login({ username: body.username, password: body.password })
162 .subscribe({ 162 .subscribe({
163 next: () => { 163 next: () => {
164 this.signupSuccess = true 164 this.signupSuccess = true
diff --git a/client/src/app/+signup/+register/steps/register-step-channel.component.ts b/client/src/app/+signup/+register/steps/register-step-channel.component.ts
index c10b568ba..df92c5145 100644
--- a/client/src/app/+signup/+register/steps/register-step-channel.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-channel.component.ts
@@ -3,7 +3,7 @@ import { pairwise } from 'rxjs/operators'
3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 3import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
4import { FormGroup } from '@angular/forms' 4import { FormGroup } from '@angular/forms'
5import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators' 5import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { UserSignupService } from '@app/shared/shared-users' 7import { UserSignupService } from '@app/shared/shared-users'
8 8
9@Component({ 9@Component({
@@ -19,7 +19,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
19 @Output() formBuilt = new EventEmitter<FormGroup>() 19 @Output() formBuilt = new EventEmitter<FormGroup>()
20 20
21 constructor ( 21 constructor (
22 protected formValidatorService: FormValidatorService, 22 protected formReactiveService: FormReactiveService,
23 private userSignupService: UserSignupService 23 private userSignupService: UserSignupService
24 ) { 24 ) {
25 super() 25 super()
diff --git a/client/src/app/+signup/+register/steps/register-step-terms.component.ts b/client/src/app/+signup/+register/steps/register-step-terms.component.ts
index 87d16696e..2df963b30 100644
--- a/client/src/app/+signup/+register/steps/register-step-terms.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-terms.component.ts
@@ -1,9 +1,7 @@
1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
2import { FormGroup } from '@angular/forms' 2import { FormGroup } from '@angular/forms'
3import { 3import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
4 USER_TERMS_VALIDATOR 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5} from '@app/shared/form-validators/user-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
7 5
8@Component({ 6@Component({
9 selector: 'my-register-step-terms', 7 selector: 'my-register-step-terms',
@@ -19,7 +17,7 @@ export class RegisterStepTermsComponent extends FormReactive implements OnInit {
19 @Output() codeOfConductClick = new EventEmitter<void>() 17 @Output() codeOfConductClick = new EventEmitter<void>()
20 18
21 constructor ( 19 constructor (
22 protected formValidatorService: FormValidatorService 20 protected formReactiveService: FormReactiveService
23 ) { 21 ) {
24 super() 22 super()
25 } 23 }
diff --git a/client/src/app/+signup/+register/steps/register-step-user.component.ts b/client/src/app/+signup/+register/steps/register-step-user.component.ts
index b89e38a28..822f8f5c5 100644
--- a/client/src/app/+signup/+register/steps/register-step-user.component.ts
+++ b/client/src/app/+signup/+register/steps/register-step-user.component.ts
@@ -8,7 +8,7 @@ import {
8 USER_PASSWORD_VALIDATOR, 8 USER_PASSWORD_VALIDATOR,
9 USER_USERNAME_VALIDATOR 9 USER_USERNAME_VALIDATOR
10} from '@app/shared/form-validators/user-validators' 10} from '@app/shared/form-validators/user-validators'
11import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 11import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
12import { UserSignupService } from '@app/shared/shared-users' 12import { UserSignupService } from '@app/shared/shared-users'
13 13
14@Component({ 14@Component({
@@ -23,7 +23,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
23 @Output() formBuilt = new EventEmitter<FormGroup>() 23 @Output() formBuilt = new EventEmitter<FormGroup>()
24 24
25 constructor ( 25 constructor (
26 protected formValidatorService: FormValidatorService, 26 protected formReactiveService: FormReactiveService,
27 private userSignupService: UserSignupService 27 private userSignupService: UserSignupService
28 ) { 28 ) {
29 super() 29 super()
diff --git a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
index a0ed66a3a..06905f678 100644
--- a/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
+++ b/client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { Notifier, RedirectService, ServerService } from '@app/core' 2import { Notifier, RedirectService, ServerService } from '@app/core'
3import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators' 3import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { UserSignupService } from '@app/shared/shared-users' 5import { UserSignupService } from '@app/shared/shared-users'
6 6
7@Component({ 7@Component({
@@ -14,7 +14,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
14 requiresEmailVerification = false 14 requiresEmailVerification = false
15 15
16 constructor ( 16 constructor (
17 protected formValidatorService: FormValidatorService, 17 protected formReactiveService: FormReactiveService,
18 private userSignupService: UserSignupService, 18 private userSignupService: UserSignupService,
19 private serverService: ServerService, 19 private serverService: ServerService,
20 private notifier: Notifier, 20 private notifier: Notifier,
diff --git a/client/src/app/+video-studio/edit/video-studio-edit.component.ts b/client/src/app/+video-studio/edit/video-studio-edit.component.ts
index bf91c237a..dad083bf9 100644
--- a/client/src/app/+video-studio/edit/video-studio-edit.component.ts
+++ b/client/src/app/+video-studio/edit/video-studio-edit.component.ts
@@ -1,7 +1,7 @@
1import { Component, OnInit } from '@angular/core' 1import { Component, OnInit } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 2import { ActivatedRoute, Router } from '@angular/router'
3import { ConfirmService, Notifier, ServerService } from '@app/core' 3import { ConfirmService, Notifier, ServerService } from '@app/core'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { VideoDetails } from '@app/shared/shared-main' 5import { VideoDetails } from '@app/shared/shared-main'
6import { LoadingBarService } from '@ngx-loading-bar/core' 6import { LoadingBarService } from '@ngx-loading-bar/core'
7import { logger } from '@root-helpers/logger' 7import { logger } from '@root-helpers/logger'
@@ -20,7 +20,7 @@ export class VideoStudioEditComponent extends FormReactive implements OnInit {
20 video: VideoDetails 20 video: VideoDetails
21 21
22 constructor ( 22 constructor (
23 protected formValidatorService: FormValidatorService, 23 protected formReactiveService: FormReactiveService,
24 private serverService: ServerService, 24 private serverService: ServerService,
25 private notifier: Notifier, 25 private notifier: Notifier,
26 private router: Router, 26 private router: Router,
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
index 95d83b131..4ab2d42db 100644
--- a/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
@@ -1,7 +1,7 @@
1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { ServerService } from '@app/core' 2import { ServerService } from '@app/core'
3import { VIDEO_CAPTION_FILE_VALIDATOR, VIDEO_CAPTION_LANGUAGE_VALIDATOR } from '@app/shared/form-validators/video-captions-validators' 3import { VIDEO_CAPTION_FILE_VALIDATOR, VIDEO_CAPTION_LANGUAGE_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { VideoCaptionEdit } from '@app/shared/shared-main' 5import { VideoCaptionEdit } from '@app/shared/shared-main'
6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
7import { HTMLServerConfig, VideoConstant } from '@shared/models' 7import { HTMLServerConfig, VideoConstant } from '@shared/models'
@@ -26,7 +26,7 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni
26 private closingModal = false 26 private closingModal = false
27 27
28 constructor ( 28 constructor (
29 protected formValidatorService: FormValidatorService, 29 protected formReactiveService: FormReactiveService,
30 private modalService: NgbModal, 30 private modalService: NgbModal,
31 private serverService: ServerService 31 private serverService: ServerService
32 ) { 32 ) {
diff --git a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts
index f33353d36..2cb470a24 100644
--- a/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts
+++ b/client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts
@@ -1,8 +1,8 @@
1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators' 2import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main' 4import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main'
5import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' 5import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
6import { HTMLServerConfig, VideoConstant } from '@shared/models' 6import { HTMLServerConfig, VideoConstant } from '@shared/models'
7import { ServerService } from '../../../../core' 7import { ServerService } from '../../../../core'
8 8
@@ -29,8 +29,7 @@ export class VideoCaptionEditModalContentComponent extends FormReactive implemen
29 29
30 constructor ( 30 constructor (
31 protected openedModal: NgbActiveModal, 31 protected openedModal: NgbActiveModal,
32 protected formValidatorService: FormValidatorService, 32 protected formReactiveService: FormReactiveService,
33 private modalService: NgbModal,
34 private videoCaptionService: VideoCaptionService, 33 private videoCaptionService: VideoCaptionService,
35 private serverService: ServerService 34 private serverService: ServerService
36 ) { 35 ) {
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
index 344b99ea2..4f2276e8c 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
@@ -3,7 +3,7 @@ import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular
3import { Router } from '@angular/router' 3import { Router } from '@angular/router'
4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactiveService } from '@app/shared/shared-forms'
7import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 7import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
8import { LiveVideoService } from '@app/shared/shared-video-live' 8import { LiveVideoService } from '@app/shared/shared-video-live'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
@@ -39,7 +39,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
39 error: string 39 error: string
40 40
41 constructor ( 41 constructor (
42 protected formValidatorService: FormValidatorService, 42 protected formReactiveService: FormReactiveService,
43 protected loadingBar: LoadingBarService, 43 protected loadingBar: LoadingBarService,
44 protected notifier: Notifier, 44 protected notifier: Notifier,
45 protected authService: AuthService, 45 protected authService: AuthService,
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
index 7b9531d27..4a1408a4a 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
@@ -3,7 +3,7 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, OnInit, Output, Vie
3import { Router } from '@angular/router' 3import { Router } from '@angular/router'
4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 4import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
5import { scrollToTop } from '@app/helpers' 5import { scrollToTop } from '@app/helpers'
6import { FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactiveService } from '@app/shared/shared-forms'
7import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' 7import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
8import { LoadingBarService } from '@ngx-loading-bar/core' 8import { LoadingBarService } from '@ngx-loading-bar/core'
9import { logger } from '@root-helpers/logger' 9import { logger } from '@root-helpers/logger'
@@ -35,7 +35,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
35 error: string 35 error: string
36 36
37 constructor ( 37 constructor (
38 protected formValidatorService: FormValidatorService, 38 protected formReactiveService: FormReactiveService,
39 protected loadingBar: LoadingBarService, 39 protected loadingBar: LoadingBarService,
40 protected notifier: Notifier, 40 protected notifier: Notifier,
41 protected authService: AuthService, 41 protected authService: AuthService,
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
index 422f0c643..502f3818e 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
@@ -4,7 +4,7 @@ import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular
4import { Router } from '@angular/router' 4import { Router } from '@angular/router'
5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core' 5import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
6import { scrollToTop } from '@app/helpers' 6import { scrollToTop } from '@app/helpers'
7import { FormValidatorService } from '@app/shared/shared-forms' 7import { FormReactiveService } from '@app/shared/shared-forms'
8import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main' 8import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
9import { LoadingBarService } from '@ngx-loading-bar/core' 9import { LoadingBarService } from '@ngx-loading-bar/core'
10import { logger } from '@root-helpers/logger' 10import { logger } from '@root-helpers/logger'
@@ -34,7 +34,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
34 error: string 34 error: string
35 35
36 constructor ( 36 constructor (
37 protected formValidatorService: FormValidatorService, 37 protected formReactiveService: FormReactiveService,
38 protected loadingBar: LoadingBarService, 38 protected loadingBar: LoadingBarService,
39 protected notifier: Notifier, 39 protected notifier: Notifier,
40 protected authService: AuthService, 40 protected authService: AuthService,
diff --git a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
index 19fba2a83..b0d846664 100644
--- a/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
+++ b/client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
@@ -5,7 +5,7 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit,
5import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
6import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core' 6import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
7import { genericUploadErrorHandler, scrollToTop } from '@app/helpers' 7import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
8import { FormValidatorService } from '@app/shared/shared-forms' 8import { FormReactiveService } from '@app/shared/shared-forms'
9import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main' 9import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
10import { LoadingBarService } from '@ngx-loading-bar/core' 10import { LoadingBarService } from '@ngx-loading-bar/core'
11import { logger } from '@root-helpers/logger' 11import { logger } from '@root-helpers/logger'
@@ -60,7 +60,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
60 private uploadServiceSubscription: Subscription 60 private uploadServiceSubscription: Subscription
61 61
62 constructor ( 62 constructor (
63 protected formValidatorService: FormValidatorService, 63 protected formReactiveService: FormReactiveService,
64 protected loadingBar: LoadingBarService, 64 protected loadingBar: LoadingBarService,
65 protected notifier: Notifier, 65 protected notifier: Notifier,
66 protected authService: AuthService, 66 protected authService: AuthService,
diff --git a/client/src/app/+videos/+video-edit/video-update.component.ts b/client/src/app/+videos/+video-edit/video-update.component.ts
index ed17dff06..212971447 100644
--- a/client/src/app/+videos/+video-edit/video-update.component.ts
+++ b/client/src/app/+videos/+video-edit/video-update.component.ts
@@ -4,7 +4,7 @@ import { SelectChannelItem } from 'src/types/select-options-item.model'
4import { Component, HostListener, OnInit } from '@angular/core' 4import { Component, HostListener, OnInit } from '@angular/core'
5import { ActivatedRoute, Router } from '@angular/router' 5import { ActivatedRoute, Router } from '@angular/router'
6import { Notifier } from '@app/core' 6import { Notifier } from '@app/core'
7import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 7import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
8import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main' 8import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
9import { LiveVideoService } from '@app/shared/shared-video-live' 9import { LiveVideoService } from '@app/shared/shared-video-live'
10import { LoadingBarService } from '@ngx-loading-bar/core' 10import { LoadingBarService } from '@ngx-loading-bar/core'
@@ -33,7 +33,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
33 private updateDone = false 33 private updateDone = false
34 34
35 constructor ( 35 constructor (
36 protected formValidatorService: FormValidatorService, 36 protected formReactiveService: FormReactiveService,
37 private route: ActivatedRoute, 37 private route: ActivatedRoute,
38 private router: Router, 38 private router: Router,
39 private notifier: Notifier, 39 private notifier: Notifier,
diff --git a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
index 9f4a68736..9a9bfe710 100644
--- a/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
+++ b/client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
@@ -16,7 +16,7 @@ import {
16import { Router } from '@angular/router' 16import { Router } from '@angular/router'
17import { Notifier, User } from '@app/core' 17import { Notifier, User } from '@app/core'
18import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators' 18import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators'
19import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 19import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
20import { Video } from '@app/shared/shared-main' 20import { Video } from '@app/shared/shared-main'
21import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment' 21import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
22import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 22import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -48,7 +48,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
48 private emojiMarkupList: { emoji: string, name: string }[] 48 private emojiMarkupList: { emoji: string, name: string }[]
49 49
50 constructor ( 50 constructor (
51 protected formValidatorService: FormValidatorService, 51 protected formReactiveService: FormReactiveService,
52 private notifier: Notifier, 52 private notifier: Notifier,
53 private videoCommentService: VideoCommentService, 53 private videoCommentService: VideoCommentService,
54 private modalService: NgbModal, 54 private modalService: NgbModal,
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index ca46866f5..7f4fae4aa 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -1,7 +1,7 @@
1import { Hotkey, HotkeysService } from 'angular2-hotkeys' 1import { Hotkey, HotkeysService } from 'angular2-hotkeys'
2import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs' 2import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
3import { catchError, map, mergeMap, share, tap } from 'rxjs/operators' 3import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
4import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http' 4import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { Notifier } from '@app/core/notification/notifier.service' 7import { Notifier } from '@app/core/notification/notifier.service'
@@ -141,7 +141,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
141 return !!this.getAccessToken() 141 return !!this.getAccessToken()
142 } 142 }
143 143
144 login (username: string, password: string, token?: string) { 144 login (options: {
145 username: string
146 password: string
147 otpToken?: string
148 token?: string
149 }) {
150 const { username, password, token, otpToken } = options
151
145 // Form url encoded 152 // Form url encoded
146 const body = { 153 const body = {
147 client_id: this.clientId, 154 client_id: this.clientId,
@@ -155,7 +162,9 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
155 162
156 if (token) Object.assign(body, { externalAuthToken: token }) 163 if (token) Object.assign(body, { externalAuthToken: token })
157 164
158 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 165 let headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
166 if (otpToken) headers = headers.set('x-peertube-otp', otpToken)
167
159 return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers }) 168 return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers })
160 .pipe( 169 .pipe(
161 map(res => Object.assign(res, { username })), 170 map(res => Object.assign(res, { username })),
@@ -245,6 +254,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
245 }) 254 })
246 } 255 }
247 256
257 isOTPMissingError (err: HttpErrorResponse) {
258 if (err.status !== HttpStatusCode.UNAUTHORIZED_401) return false
259
260 if (err.headers.get('x-peertube-otp') !== 'required; app') return false
261
262 return true
263 }
264
248 private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> { 265 private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
249 // User is not loaded yet, set manually auth header 266 // User is not loaded yet, set manually auth header
250 const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) 267 const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts
index 338b8762c..89a25f0a5 100644
--- a/client/src/app/core/confirm/confirm.service.ts
+++ b/client/src/app/core/confirm/confirm.service.ts
@@ -1,28 +1,53 @@
1import { firstValueFrom, Subject } from 'rxjs' 1import { firstValueFrom, map, Observable, Subject } from 'rxjs'
2import { Injectable } from '@angular/core' 2import { Injectable } from '@angular/core'
3 3
4type ConfirmOptions = { 4type ConfirmOptions = {
5 title: string 5 title: string
6 message: string 6 message: string
7 inputLabel?: string 7} & (
8 expectedInputValue?: string 8 {
9 confirmButtonText?: string 9 type: 'confirm'
10} 10 confirmButtonText?: string
11 } |
12 {
13 type: 'confirm-password'
14 confirmButtonText?: string
15 } |
16 {
17 type: 'confirm-expected-input'
18 inputLabel?: string
19 expectedInputValue?: string
20 confirmButtonText?: string
21 }
22)
11 23
12@Injectable() 24@Injectable()
13export class ConfirmService { 25export class ConfirmService {
14 showConfirm = new Subject<ConfirmOptions>() 26 showConfirm = new Subject<ConfirmOptions>()
15 confirmResponse = new Subject<boolean>() 27 confirmResponse = new Subject<{ confirmed: boolean, value?: string }>()
16 28
17 confirm (message: string, title = '', confirmButtonText?: string) { 29 confirm (message: string, title = '', confirmButtonText?: string) {
18 this.showConfirm.next({ title, message, confirmButtonText }) 30 this.showConfirm.next({ type: 'confirm', title, message, confirmButtonText })
31
32 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
33 }
19 34
20 return firstValueFrom(this.confirmResponse.asObservable()) 35 confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
36 this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
37
38 const obs = this.confirmResponse.asObservable()
39 .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
40
41 return firstValueFrom(obs)
21 } 42 }
22 43
23 confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) { 44 confirmWithExpectedInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
24 this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText }) 45 this.showConfirm.next({ type: 'confirm-expected-input', title, message, inputLabel, expectedInputValue, confirmButtonText })
46
47 return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
48 }
25 49
26 return firstValueFrom(this.confirmResponse.asObservable()) 50 private extractConfirmed (obs: Observable<{ confirmed: boolean }>) {
51 return obs.pipe(map(({ confirmed }) => confirmed))
27 } 52 }
28} 53}
diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts
index 7eec2eca6..57dd9ae26 100644
--- a/client/src/app/core/rest/rest-extractor.service.ts
+++ b/client/src/app/core/rest/rest-extractor.service.ts
@@ -4,6 +4,7 @@ import { Router } from '@angular/router'
4import { DateFormat, dateToHuman } from '@app/helpers' 4import { DateFormat, dateToHuman } from '@app/helpers'
5import { logger } from '@root-helpers/logger' 5import { logger } from '@root-helpers/logger'
6import { HttpStatusCode, ResultList } from '@shared/models' 6import { HttpStatusCode, ResultList } from '@shared/models'
7import { HttpHeaderResponse } from '@angular/common/http'
7 8
8@Injectable() 9@Injectable()
9export class RestExtractor { 10export class RestExtractor {
@@ -54,10 +55,11 @@ export class RestExtractor {
54 handleError (err: any) { 55 handleError (err: any) {
55 const errorMessage = this.buildErrorMessage(err) 56 const errorMessage = this.buildErrorMessage(err)
56 57
57 const errorObj: { message: string, status: string, body: string } = { 58 const errorObj: { message: string, status: string, body: string, headers: HttpHeaderResponse } = {
58 message: errorMessage, 59 message: errorMessage,
59 status: undefined, 60 status: undefined,
60 body: undefined 61 body: undefined,
62 headers: err.headers
61 } 63 }
62 64
63 if (err.status) { 65 if (err.status) {
diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts
index 6ba30e4b8..8385a4012 100644
--- a/client/src/app/core/users/user.model.ts
+++ b/client/src/app/core/users/user.model.ts
@@ -66,6 +66,8 @@ export class User implements UserServerModel {
66 66
67 lastLoginDate: Date | null 67 lastLoginDate: Date | null
68 68
69 twoFactorEnabled: boolean
70
69 createdAt: Date 71 createdAt: Date
70 72
71 constructor (hash: Partial<UserServerModel>) { 73 constructor (hash: Partial<UserServerModel>) {
@@ -108,6 +110,8 @@ export class User implements UserServerModel {
108 110
109 this.notificationSettings = hash.notificationSettings 111 this.notificationSettings = hash.notificationSettings
110 112
113 this.twoFactorEnabled = hash.twoFactorEnabled
114
111 this.createdAt = hash.createdAt 115 this.createdAt = hash.createdAt
112 116
113 this.pluginAuth = hash.pluginAuth 117 this.pluginAuth = hash.pluginAuth
diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html
index c59c25770..f364165c4 100644
--- a/client/src/app/modal/confirm.component.html
+++ b/client/src/app/modal/confirm.component.html
@@ -9,9 +9,12 @@
9 <div class="modal-body" > 9 <div class="modal-body" >
10 <div [innerHtml]="message"></div> 10 <div [innerHtml]="message"></div>
11 11
12 <div *ngIf="inputLabel && expectedInputValue" class="form-group mt-3"> 12 <div *ngIf="inputLabel" class="form-group mt-3">
13 <label for="confirmInput">{{ inputLabel }}</label> 13 <label for="confirmInput">{{ inputLabel }}</label>
14 <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" /> 14
15 <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
16
17 <my-input-text inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
15 </div> 18 </div>
16 </div> 19 </div>
17 20
diff --git a/client/src/app/modal/confirm.component.ts b/client/src/app/modal/confirm.component.ts
index ec4e1d60f..3bb8b9b21 100644
--- a/client/src/app/modal/confirm.component.ts
+++ b/client/src/app/modal/confirm.component.ts
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
21 inputValue = '' 21 inputValue = ''
22 confirmButtonText = '' 22 confirmButtonText = ''
23 23
24 isPasswordInput = false
25
24 private openedModal: NgbModalRef 26 private openedModal: NgbModalRef
25 27
26 constructor ( 28 constructor (
@@ -31,11 +33,27 @@ export class ConfirmComponent implements OnInit {
31 33
32 ngOnInit () { 34 ngOnInit () {
33 this.confirmService.showConfirm.subscribe( 35 this.confirmService.showConfirm.subscribe(
34 ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => { 36 payload => {
37 // Reinit fields
38 this.title = ''
39 this.message = ''
40 this.expectedInputValue = ''
41 this.inputLabel = ''
42 this.inputValue = ''
43 this.confirmButtonText = ''
44 this.isPasswordInput = false
45
46 const { type, title, message, confirmButtonText } = payload
47
35 this.title = title 48 this.title = title
36 49
37 this.inputLabel = inputLabel 50 if (type === 'confirm-expected-input') {
38 this.expectedInputValue = expectedInputValue 51 this.inputLabel = payload.inputLabel
52 this.expectedInputValue = payload.expectedInputValue
53 } else if (type === 'confirm-password') {
54 this.inputLabel = $localize`Confirm your password`
55 this.isPasswordInput = true
56 }
39 57
40 this.confirmButtonText = confirmButtonText || $localize`Confirm` 58 this.confirmButtonText = confirmButtonText || $localize`Confirm`
41 59
@@ -66,11 +84,13 @@ export class ConfirmComponent implements OnInit {
66 this.openedModal = this.modalService.open(this.confirmModal, { centered: true }) 84 this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
67 85
68 this.openedModal.result 86 this.openedModal.result
69 .then(() => this.confirmService.confirmResponse.next(true)) 87 .then(() => {
88 this.confirmService.confirmResponse.next({ confirmed: true, value: this.inputValue })
89 })
70 .catch((reason: string) => { 90 .catch((reason: string) => {
71 // If the reason was that the user used the back button, we don't care about the confirm dialog result 91 // If the reason was that the user used the back button, we don't care about the confirm dialog result
72 if (!reason || reason !== POP_STATE_MODAL_DISMISS) { 92 if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
73 this.confirmService.confirmResponse.next(false) 93 this.confirmService.confirmResponse.next({ confirmed: false, value: this.inputValue })
74 } 94 }
75 }) 95 })
76 } 96 }
diff --git a/client/src/app/shared/form-validators/user-validators.ts b/client/src/app/shared/form-validators/user-validators.ts
index 3262853d8..b93de75ea 100644
--- a/client/src/app/shared/form-validators/user-validators.ts
+++ b/client/src/app/shared/form-validators/user-validators.ts
@@ -61,6 +61,15 @@ export const USER_EXISTING_PASSWORD_VALIDATOR: BuildFormValidator = {
61 } 61 }
62} 62}
63 63
64export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = {
65 VALIDATORS: [
66 Validators.required
67 ],
68 MESSAGES: {
69 required: $localize`OTP token is required.`
70 }
71}
72
64export const USER_PASSWORD_VALIDATOR = { 73export const USER_PASSWORD_VALIDATOR = {
65 VALIDATORS: [ 74 VALIDATORS: [
66 Validators.required, 75 Validators.required,
diff --git a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
index d24a5d58d..12d503f56 100644
--- a/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
+++ b/client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { AuthService, HtmlRendererService, Notifier } from '@app/core' 2import { AuthService, HtmlRendererService, Notifier } from '@app/core'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 4import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 5import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
6import { logger } from '@root-helpers/logger' 6import { logger } from '@root-helpers/logger'
@@ -29,7 +29,7 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
29 private abuse: UserAbuse 29 private abuse: UserAbuse
30 30
31 constructor ( 31 constructor (
32 protected formValidatorService: FormValidatorService, 32 protected formReactiveService: FormReactiveService,
33 private modalService: NgbModal, 33 private modalService: NgbModal,
34 private htmlRenderer: HtmlRendererService, 34 private htmlRenderer: HtmlRendererService,
35 private auth: AuthService, 35 private auth: AuthService,
diff --git a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
index 2600da8da..4ad807d25 100644
--- a/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
+++ b/client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
@@ -1,6 +1,6 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { AbuseService } from '@app/shared/shared-moderation' 4import { AbuseService } from '@app/shared/shared-moderation'
5import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 5import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 6import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -20,7 +20,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
20 private openedModal: NgbModalRef 20 private openedModal: NgbModalRef
21 21
22 constructor ( 22 constructor (
23 protected formValidatorService: FormValidatorService, 23 protected formReactiveService: FormReactiveService,
24 private modalService: NgbModal, 24 private modalService: NgbModal,
25 private notifier: Notifier, 25 private notifier: Notifier,
26 private abuseService: AbuseService 26 private abuseService: AbuseService
diff --git a/client/src/app/shared/shared-forms/form-reactive.service.ts b/client/src/app/shared/shared-forms/form-reactive.service.ts
new file mode 100644
index 000000000..f1b7e0ef2
--- /dev/null
+++ b/client/src/app/shared/shared-forms/form-reactive.service.ts
@@ -0,0 +1,101 @@
1import { Injectable } from '@angular/core'
2import { AbstractControl, FormGroup } from '@angular/forms'
3import { wait } from '@root-helpers/utils'
4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
5import { FormValidatorService } from './form-validator.service'
6
7export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
8export type FormReactiveValidationMessages = {
9 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
10}
11
12@Injectable()
13export class FormReactiveService {
14
15 constructor (private formValidatorService: FormValidatorService) {
16
17 }
18
19 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
20 const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
21
22 form.statusChanges.subscribe(async () => {
23 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
24 await this.waitPendingCheck(form)
25
26 this.onStatusChanged({ form, formErrors, validationMessages })
27 })
28
29 return { form, formErrors, validationMessages }
30 }
31
32 async waitPendingCheck (form: FormGroup) {
33 if (form.status !== 'PENDING') return
34
35 // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
36 // return firstValueFrom(form.statusChanges.pipe(filter(status => status !== 'PENDING')))
37 // So we have to fallback to active wait :/
38
39 do {
40 await wait(10)
41 } while (form.status === 'PENDING')
42 }
43
44 markAllAsDirty (controlsArg: { [ key: string ]: AbstractControl }) {
45 const controls = controlsArg
46
47 for (const key of Object.keys(controls)) {
48 const control = controls[key]
49
50 if (control instanceof FormGroup) {
51 this.markAllAsDirty(control.controls)
52 continue
53 }
54
55 control.markAsDirty()
56 }
57 }
58
59 forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) {
60 this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false })
61 }
62
63 private onStatusChanged (options: {
64 form: FormGroup
65 formErrors: FormReactiveErrors
66 validationMessages: FormReactiveValidationMessages
67 onlyDirty?: boolean // default true
68 }) {
69 const { form, formErrors, validationMessages, onlyDirty = true } = options
70
71 for (const field of Object.keys(formErrors)) {
72 if (formErrors[field] && typeof formErrors[field] === 'object') {
73 this.onStatusChanged({
74 form: form.controls[field] as FormGroup,
75 formErrors: formErrors[field] as FormReactiveErrors,
76 validationMessages: validationMessages[field] as FormReactiveValidationMessages,
77 onlyDirty
78 })
79
80 continue
81 }
82
83 // clear previous error message (if any)
84 formErrors[field] = ''
85 const control = form.get(field)
86
87 if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
88
89 const staticMessages = validationMessages[field]
90 for (const key of Object.keys(control.errors)) {
91 const formErrorValue = control.errors[key]
92
93 // Try to find error message in static validation messages first
94 // Then check if the validator returns a string that is the error
95 if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
96 else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
97 else throw new Error('Form error value of ' + field + ' is invalid')
98 }
99 }
100 }
101}
diff --git a/client/src/app/shared/shared-forms/form-reactive.ts b/client/src/app/shared/shared-forms/form-reactive.ts
index a19ffdd82..d1e7be802 100644
--- a/client/src/app/shared/shared-forms/form-reactive.ts
+++ b/client/src/app/shared/shared-forms/form-reactive.ts
@@ -1,16 +1,9 @@
1 1import { FormGroup } from '@angular/forms'
2import { AbstractControl, FormGroup } from '@angular/forms'
3import { wait } from '@root-helpers/utils'
4import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' 2import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
5import { FormValidatorService } from './form-validator.service' 3import { FormReactiveService, FormReactiveValidationMessages } from './form-reactive.service'
6
7export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
8export type FormReactiveValidationMessages = {
9 [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
10}
11 4
12export abstract class FormReactive { 5export abstract class FormReactive {
13 protected abstract formValidatorService: FormValidatorService 6 protected abstract formReactiveService: FormReactiveService
14 protected formChanged = false 7 protected formChanged = false
15 8
16 form: FormGroup 9 form: FormGroup
@@ -18,86 +11,22 @@ export abstract class FormReactive {
18 validationMessages: FormReactiveValidationMessages 11 validationMessages: FormReactiveValidationMessages
19 12
20 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) { 13 buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
21 const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues) 14 const { formErrors, validationMessages, form } = this.formReactiveService.buildForm(obj, defaultValues)
22 15
23 this.form = form 16 this.form = form
24 this.formErrors = formErrors 17 this.formErrors = formErrors
25 this.validationMessages = validationMessages 18 this.validationMessages = validationMessages
26
27 this.form.statusChanges.subscribe(async () => {
28 // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
29 await this.waitPendingCheck()
30
31 this.onStatusChanged(this.form, this.formErrors, this.validationMessages)
32 })
33 } 19 }
34 20
35 protected async waitPendingCheck () { 21 protected async waitPendingCheck () {
36 if (this.form.status !== 'PENDING') return 22 return this.formReactiveService.waitPendingCheck(this.form)
37
38 // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
39 // return firstValueFrom(this.form.statusChanges.pipe(filter(status => status !== 'PENDING')))
40 // So we have to fallback to active wait :/
41
42 do {
43 await wait(10)
44 } while (this.form.status === 'PENDING')
45 } 23 }
46 24
47 protected markAllAsDirty (controlsArg?: { [ key: string ]: AbstractControl }) { 25 protected markAllAsDirty () {
48 const controls = controlsArg || this.form.controls 26 return this.formReactiveService.markAllAsDirty(this.form.controls)
49
50 for (const key of Object.keys(controls)) {
51 const control = controls[key]
52
53 if (control instanceof FormGroup) {
54 this.markAllAsDirty(control.controls)
55 continue
56 }
57
58 control.markAsDirty()
59 }
60 } 27 }
61 28
62 protected forceCheck () { 29 protected forceCheck () {
63 this.onStatusChanged(this.form, this.formErrors, this.validationMessages, false) 30 return this.formReactiveService.forceCheck(this.form, this.formErrors, this.validationMessages)
64 }
65
66 private onStatusChanged (
67 form: FormGroup,
68 formErrors: FormReactiveErrors,
69 validationMessages: FormReactiveValidationMessages,
70 onlyDirty = true
71 ) {
72 for (const field of Object.keys(formErrors)) {
73 if (formErrors[field] && typeof formErrors[field] === 'object') {
74 this.onStatusChanged(
75 form.controls[field] as FormGroup,
76 formErrors[field] as FormReactiveErrors,
77 validationMessages[field] as FormReactiveValidationMessages,
78 onlyDirty
79 )
80 continue
81 }
82
83 // clear previous error message (if any)
84 formErrors[field] = ''
85 const control = form.get(field)
86
87 if (control.dirty) this.formChanged = true
88
89 if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
90
91 const staticMessages = validationMessages[field]
92 for (const key of Object.keys(control.errors)) {
93 const formErrorValue = control.errors[key]
94
95 // Try to find error message in static validation messages first
96 // Then check if the validator returns a string that is the error
97 if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
98 else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
99 else throw new Error('Form error value of ' + field + ' is invalid')
100 }
101 }
102 } 31 }
103} 32}
diff --git a/client/src/app/shared/shared-forms/form-validator.service.ts b/client/src/app/shared/shared-forms/form-validator.service.ts
index f67d5bb33..897008242 100644
--- a/client/src/app/shared/shared-forms/form-validator.service.ts
+++ b/client/src/app/shared/shared-forms/form-validator.service.ts
@@ -1,7 +1,7 @@
1import { Injectable } from '@angular/core' 1import { Injectable } from '@angular/core'
2import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms' 2import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
3import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model' 3import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
4import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive' 4import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
5 5
6@Injectable() 6@Injectable()
7export class FormValidatorService { 7export class FormValidatorService {
diff --git a/client/src/app/shared/shared-forms/index.ts b/client/src/app/shared/shared-forms/index.ts
index 495785e7b..bff9862f2 100644
--- a/client/src/app/shared/shared-forms/index.ts
+++ b/client/src/app/shared/shared-forms/index.ts
@@ -1,4 +1,5 @@
1export * from './advanced-input-filter.component' 1export * from './advanced-input-filter.component'
2export * from './form-reactive.service'
2export * from './form-reactive' 3export * from './form-reactive'
3export * from './form-validator.service' 4export * from './form-validator.service'
4export * from './form-validator.service' 5export * from './form-validator.service'
diff --git a/client/src/app/shared/shared-forms/input-text.component.ts b/client/src/app/shared/shared-forms/input-text.component.ts
index d667ed663..aa4a1cba8 100644
--- a/client/src/app/shared/shared-forms/input-text.component.ts
+++ b/client/src/app/shared/shared-forms/input-text.component.ts
@@ -1,4 +1,4 @@
1import { Component, forwardRef, Input } from '@angular/core' 1import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' 2import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4 4
@@ -15,6 +15,8 @@ import { Notifier } from '@app/core'
15 ] 15 ]
16}) 16})
17export class InputTextComponent implements ControlValueAccessor { 17export class InputTextComponent implements ControlValueAccessor {
18 @ViewChild('input') inputElement: ElementRef
19
18 @Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined 20 @Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined
19 @Input() value = '' 21 @Input() value = ''
20 @Input() autocomplete = 'off' 22 @Input() autocomplete = 'off'
@@ -65,4 +67,10 @@ export class InputTextComponent implements ControlValueAccessor {
65 update () { 67 update () {
66 this.propagateChange(this.value) 68 this.propagateChange(this.value)
67 } 69 }
70
71 focus () {
72 const el: HTMLElement = this.inputElement.nativeElement
73
74 el.focus({ preventScroll: true })
75 }
68} 76}
diff --git a/client/src/app/shared/shared-forms/shared-form.module.ts b/client/src/app/shared/shared-forms/shared-form.module.ts
index 81f076db6..628affb56 100644
--- a/client/src/app/shared/shared-forms/shared-form.module.ts
+++ b/client/src/app/shared/shared-forms/shared-form.module.ts
@@ -1,4 +1,3 @@
1
2import { InputMaskModule } from 'primeng/inputmask' 1import { InputMaskModule } from 'primeng/inputmask'
3import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
4import { FormsModule, ReactiveFormsModule } from '@angular/forms' 3import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons'
7import { SharedMainModule } from '../shared-main/shared-main.module' 6import { SharedMainModule } from '../shared-main/shared-main.module'
8import { AdvancedInputFilterComponent } from './advanced-input-filter.component' 7import { AdvancedInputFilterComponent } from './advanced-input-filter.component'
9import { DynamicFormFieldComponent } from './dynamic-form-field.component' 8import { DynamicFormFieldComponent } from './dynamic-form-field.component'
9import { FormReactiveService } from './form-reactive.service'
10import { FormValidatorService } from './form-validator.service' 10import { FormValidatorService } from './form-validator.service'
11import { InputSwitchComponent } from './input-switch.component' 11import { InputSwitchComponent } from './input-switch.component'
12import { InputTextComponent } from './input-text.component' 12import { InputTextComponent } from './input-text.component'
@@ -96,7 +96,8 @@ import { TimestampInputComponent } from './timestamp-input.component'
96 ], 96 ],
97 97
98 providers: [ 98 providers: [
99 FormValidatorService 99 FormValidatorService,
100 FormReactiveService
100 ] 101 ]
101}) 102})
102export class SharedFormModule { } 103export class SharedFormModule { }
diff --git a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
index e4b74f3ad..93b3a93d6 100644
--- a/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
+++ b/client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
@@ -27,13 +27,16 @@ export class AuthInterceptor implements HttpInterceptor {
27 .pipe( 27 .pipe(
28 catchError((err: HttpErrorResponse) => { 28 catchError((err: HttpErrorResponse) => {
29 const error = err.error as PeerTubeProblemDocument 29 const error = err.error as PeerTubeProblemDocument
30 const isOTPMissingError = this.authService.isOTPMissingError(err)
30 31
31 if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) { 32 if (!isOTPMissingError) {
32 return this.handleTokenExpired(req, next) 33 if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
33 } 34 return this.handleTokenExpired(req, next)
35 }
34 36
35 if (err.status === HttpStatusCode.UNAUTHORIZED_401) { 37 if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
36 return this.handleNotAuthenticated(err) 38 return this.handleNotAuthenticated(err)
39 }
37 } 40 }
38 41
39 return observableThrowError(() => err) 42 return observableThrowError(() => err)
diff --git a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
index 20be728f6..ec2fea528 100644
--- a/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
@@ -1,5 +1,5 @@
1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
2import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 2import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
3import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 3import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
4import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 4import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
5import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators' 5import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
@@ -18,7 +18,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
18 private openedModal: NgbModalRef 18 private openedModal: NgbModalRef
19 19
20 constructor ( 20 constructor (
21 protected formValidatorService: FormValidatorService, 21 protected formReactiveService: FormReactiveService,
22 private modalService: NgbModal 22 private modalService: NgbModal
23 ) { 23 ) {
24 super() 24 super()
diff --git a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
index 78c9b3382..d587a9709 100644
--- a/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
@@ -2,7 +2,7 @@ import { mapValues, pickBy } from 'lodash-es'
2import { Component, OnInit, ViewChild } from '@angular/core' 2import { Component, OnInit, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators' 4import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { Account } from '@app/shared/shared-main' 6import { Account } from '@app/shared/shared-main'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -26,7 +26,7 @@ export class AccountReportComponent extends FormReactive implements OnInit {
26 private openedModal: NgbModalRef 26 private openedModal: NgbModalRef
27 27
28 constructor ( 28 constructor (
29 protected formValidatorService: FormValidatorService, 29 protected formReactiveService: FormReactiveService,
30 private modalService: NgbModal, 30 private modalService: NgbModal,
31 private abuseService: AbuseService, 31 private abuseService: AbuseService,
32 private notifier: Notifier 32 private notifier: Notifier
diff --git a/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
index 7c0907ce4..e35d70c8f 100644
--- a/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
@@ -2,7 +2,7 @@ import { mapValues, pickBy } from 'lodash-es'
2import { Component, Input, OnInit, ViewChild } from '@angular/core' 2import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators' 4import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { VideoComment } from '@app/shared/shared-video-comment' 6import { VideoComment } from '@app/shared/shared-video-comment'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -27,7 +27,7 @@ export class CommentReportComponent extends FormReactive implements OnInit {
27 private openedModal: NgbModalRef 27 private openedModal: NgbModalRef
28 28
29 constructor ( 29 constructor (
30 protected formValidatorService: FormValidatorService, 30 protected formReactiveService: FormReactiveService,
31 private modalService: NgbModal, 31 private modalService: NgbModal,
32 private abuseService: AbuseService, 32 private abuseService: AbuseService,
33 private notifier: Notifier 33 private notifier: Notifier
diff --git a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
index 38dd92910..16be8e0a1 100644
--- a/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
+++ b/client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
@@ -3,7 +3,7 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core'
3import { DomSanitizer } from '@angular/platform-browser' 3import { DomSanitizer } from '@angular/platform-browser'
4import { Notifier } from '@app/core' 4import { Notifier } from '@app/core'
5import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators' 5import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 7import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 8import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
9import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse' 9import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
@@ -27,7 +27,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
27 private openedModal: NgbModalRef 27 private openedModal: NgbModalRef
28 28
29 constructor ( 29 constructor (
30 protected formValidatorService: FormValidatorService, 30 protected formReactiveService: FormReactiveService,
31 private modalService: NgbModal, 31 private modalService: NgbModal,
32 private abuseService: AbuseService, 32 private abuseService: AbuseService,
33 private notifier: Notifier, 33 private notifier: Notifier,
diff --git a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
index 617408f2a..27dcf043a 100644
--- a/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
+++ b/client/src/app/shared/shared-moderation/user-ban-modal.component.ts
@@ -2,7 +2,7 @@ import { forkJoin } from 'rxjs'
2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 2import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
3import { Notifier } from '@app/core' 3import { Notifier } from '@app/core'
4import { prepareIcu } from '@app/helpers' 4import { prepareIcu } from '@app/helpers'
5import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 5import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
8import { User } from '@shared/models' 8import { User } from '@shared/models'
@@ -25,7 +25,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
25 modalMessage = '' 25 modalMessage = ''
26 26
27 constructor ( 27 constructor (
28 protected formValidatorService: FormValidatorService, 28 protected formReactiveService: FormReactiveService,
29 private modalService: NgbModal, 29 private modalService: NgbModal,
30 private notifier: Notifier, 30 private notifier: Notifier,
31 private userAdminService: UserAdminService, 31 private userAdminService: UserAdminService,
diff --git a/client/src/app/shared/shared-moderation/video-block.component.ts b/client/src/app/shared/shared-moderation/video-block.component.ts
index f8b22a3f6..3ff53443a 100644
--- a/client/src/app/shared/shared-moderation/video-block.component.ts
+++ b/client/src/app/shared/shared-moderation/video-block.component.ts
@@ -1,7 +1,7 @@
1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core' 1import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { prepareIcu } from '@app/helpers' 3import { prepareIcu } from '@app/helpers'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { Video } from '@app/shared/shared-main' 5import { Video } from '@app/shared/shared-main'
6import { NgbModal } from '@ng-bootstrap/ng-bootstrap' 6import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref' 7import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -25,7 +25,7 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
25 private openedModal: NgbModalRef 25 private openedModal: NgbModalRef
26 26
27 constructor ( 27 constructor (
28 protected formValidatorService: FormValidatorService, 28 protected formReactiveService: FormReactiveService,
29 private modalService: NgbModal, 29 private modalService: NgbModal,
30 private videoBlocklistService: VideoBlockService, 30 private videoBlocklistService: VideoBlockService,
31 private notifier: Notifier 31 private notifier: Notifier
diff --git a/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts b/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
index 13e2e5424..c2c30d38b 100644
--- a/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
+++ b/client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
@@ -1,7 +1,7 @@
1import { Subject, Subscription } from 'rxjs' 1import { Subject, Subscription } from 'rxjs'
2import { Component, Input, OnDestroy, OnInit } from '@angular/core' 2import { Component, Input, OnDestroy, OnInit } from '@angular/core'
3import { AuthService, Notifier, ServerService, ThemeService, UserService } from '@app/core' 3import { AuthService, Notifier, ServerService, ThemeService, UserService } from '@app/core'
4import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 4import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
5import { HTMLServerConfig, User, UserUpdateMe } from '@shared/models' 5import { HTMLServerConfig, User, UserUpdateMe } from '@shared/models'
6import { SelectOptionsItem } from 'src/types' 6import { SelectOptionsItem } from 'src/types'
7 7
@@ -22,7 +22,7 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
22 private serverConfig: HTMLServerConfig 22 private serverConfig: HTMLServerConfig
23 23
24 constructor ( 24 constructor (
25 protected formValidatorService: FormValidatorService, 25 protected formReactiveService: FormReactiveService,
26 private authService: AuthService, 26 private authService: AuthService,
27 private notifier: Notifier, 27 private notifier: Notifier,
28 private userService: UserService, 28 private userService: UserService,
diff --git a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts
index 7d6b69469..af0870f12 100644
--- a/client/src/app/shared/shared-user-settings/user-video-settings.component.ts
+++ b/client/src/app/shared/shared-user-settings/user-video-settings.component.ts
@@ -3,7 +3,7 @@ import { Subject, Subscription } from 'rxjs'
3import { first } from 'rxjs/operators' 3import { first } from 'rxjs/operators'
4import { Component, Input, OnDestroy, OnInit } from '@angular/core' 4import { Component, Input, OnDestroy, OnInit } from '@angular/core'
5import { AuthService, Notifier, ServerService, User, UserService } from '@app/core' 5import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { UserUpdateMe } from '@shared/models' 7import { UserUpdateMe } from '@shared/models'
8import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type' 8import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
9 9
@@ -22,7 +22,7 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
22 formValuesWatcher: Subscription 22 formValuesWatcher: Subscription
23 23
24 constructor ( 24 constructor (
25 protected formValidatorService: FormValidatorService, 25 protected formReactiveService: FormReactiveService,
26 private authService: AuthService, 26 private authService: AuthService,
27 private notifier: Notifier, 27 private notifier: Notifier,
28 private userService: UserService, 28 private userService: UserService,
diff --git a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
index 7bcfdd8aa..61bcd5345 100644
--- a/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
+++ b/client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
@@ -1,6 +1,6 @@
1import { Component, Input, OnInit } from '@angular/core' 1import { Component, Input, OnInit } from '@angular/core'
2import { Notifier } from '@app/core' 2import { Notifier } from '@app/core'
3import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 3import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
4import { logger } from '@root-helpers/logger' 4import { logger } from '@root-helpers/logger'
5import { USER_HANDLE_VALIDATOR } from '../form-validators/user-validators' 5import { USER_HANDLE_VALIDATOR } from '../form-validators/user-validators'
6 6
@@ -15,7 +15,7 @@ export class RemoteSubscribeComponent extends FormReactive implements OnInit {
15 @Input() showHelp = false 15 @Input() showHelp = false
16 16
17 constructor ( 17 constructor (
18 protected formValidatorService: FormValidatorService, 18 protected formReactiveService: FormReactiveService,
19 private notifier: Notifier 19 private notifier: Notifier
20 ) { 20 ) {
21 super() 21 super()
diff --git a/client/src/app/shared/shared-users/index.ts b/client/src/app/shared/shared-users/index.ts
index 8f90f2515..20e60486d 100644
--- a/client/src/app/shared/shared-users/index.ts
+++ b/client/src/app/shared/shared-users/index.ts
@@ -1,4 +1,5 @@
1export * from './user-admin.service' 1export * from './user-admin.service'
2export * from './user-signup.service' 2export * from './user-signup.service'
3export * from './two-factor.service'
3 4
4export * from './shared-users.module' 5export * from './shared-users.module'
diff --git a/client/src/app/shared/shared-users/shared-users.module.ts b/client/src/app/shared/shared-users/shared-users.module.ts
index 2a1dadf20..5a1675dc9 100644
--- a/client/src/app/shared/shared-users/shared-users.module.ts
+++ b/client/src/app/shared/shared-users/shared-users.module.ts
@@ -1,6 +1,7 @@
1 1
2import { NgModule } from '@angular/core' 2import { NgModule } from '@angular/core'
3import { SharedMainModule } from '../shared-main/shared-main.module' 3import { SharedMainModule } from '../shared-main/shared-main.module'
4import { TwoFactorService } from './two-factor.service'
4import { UserAdminService } from './user-admin.service' 5import { UserAdminService } from './user-admin.service'
5import { UserSignupService } from './user-signup.service' 6import { UserSignupService } from './user-signup.service'
6 7
@@ -15,7 +16,8 @@ import { UserSignupService } from './user-signup.service'
15 16
16 providers: [ 17 providers: [
17 UserSignupService, 18 UserSignupService,
18 UserAdminService 19 UserAdminService,
20 TwoFactorService
19 ] 21 ]
20}) 22})
21export class SharedUsersModule { } 23export class SharedUsersModule { }
diff --git a/client/src/app/shared/shared-users/two-factor.service.ts b/client/src/app/shared/shared-users/two-factor.service.ts
new file mode 100644
index 000000000..9ff916f15
--- /dev/null
+++ b/client/src/app/shared/shared-users/two-factor.service.ts
@@ -0,0 +1,52 @@
1import { catchError } from 'rxjs/operators'
2import { HttpClient } from '@angular/common/http'
3import { Injectable } from '@angular/core'
4import { RestExtractor, UserService } from '@app/core'
5import { TwoFactorEnableResult } from '@shared/models'
6
7@Injectable()
8export class TwoFactorService {
9 constructor (
10 private authHttp: HttpClient,
11 private restExtractor: RestExtractor
12 ) { }
13
14 // ---------------------------------------------------------------------------
15
16 requestTwoFactor (options: {
17 userId: number
18 currentPassword: string
19 }) {
20 const { userId, currentPassword } = options
21
22 const url = UserService.BASE_USERS_URL + userId + '/two-factor/request'
23
24 return this.authHttp.post<TwoFactorEnableResult>(url, { currentPassword })
25 .pipe(catchError(err => this.restExtractor.handleError(err)))
26 }
27
28 confirmTwoFactorRequest (options: {
29 userId: number
30 requestToken: string
31 otpToken: string
32 }) {
33 const { userId, requestToken, otpToken } = options
34
35 const url = UserService.BASE_USERS_URL + userId + '/two-factor/confirm-request'
36
37 return this.authHttp.post(url, { requestToken, otpToken })
38 .pipe(catchError(err => this.restExtractor.handleError(err)))
39 }
40
41 disableTwoFactor (options: {
42 userId: number
43 currentPassword?: string
44 }) {
45 const { userId, currentPassword } = options
46
47 const url = UserService.BASE_USERS_URL + userId + '/two-factor/disable'
48
49 return this.authHttp.post(url, { currentPassword })
50 .pipe(catchError(err => this.restExtractor.handleError(err)))
51 }
52}
diff --git a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
index e019fdd26..f81de7c6b 100644
--- a/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
+++ b/client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
@@ -3,7 +3,7 @@ import { Subject, Subscription } from 'rxjs'
3import { debounceTime, filter } from 'rxjs/operators' 3import { debounceTime, filter } from 'rxjs/operators'
4import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core' 4import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
5import { AuthService, DisableForReuseHook, Notifier } from '@app/core' 5import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
6import { FormReactive, FormValidatorService } from '@app/shared/shared-forms' 6import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
7import { secondsToTime } from '@shared/core-utils' 7import { secondsToTime } from '@shared/core-utils'
8import { 8import {
9 Video, 9 Video,
@@ -59,7 +59,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
59 private pendingAddId: number 59 private pendingAddId: number
60 60
61 constructor ( 61 constructor (
62 protected formValidatorService: FormValidatorService, 62 protected formReactiveService: FormReactiveService,
63 private authService: AuthService, 63 private authService: AuthService,
64 private notifier: Notifier, 64 private notifier: Notifier,
65 private videoPlaylistService: VideoPlaylistService, 65 private videoPlaylistService: VideoPlaylistService,
diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss
index 9328a27a2..a5d06de98 100644
--- a/client/src/sass/bootstrap.scss
+++ b/client/src/sass/bootstrap.scss
@@ -3,32 +3,32 @@
3 3
4@import './_bootstrap-variables'; 4@import './_bootstrap-variables';
5 5
6@import '~bootstrap/scss/functions'; 6@import 'bootstrap/scss/functions';
7@import '~bootstrap/scss/variables'; 7@import 'bootstrap/scss/variables';
8@import '~bootstrap/scss/maps'; 8@import 'bootstrap/scss/maps';
9@import '~bootstrap/scss/mixins'; 9@import 'bootstrap/scss/mixins';
10@import '~bootstrap/scss/utilities'; 10@import 'bootstrap/scss/utilities';
11 11
12@import '~bootstrap/scss/root'; 12@import 'bootstrap/scss/root';
13@import '~bootstrap/scss/reboot'; 13@import 'bootstrap/scss/reboot';
14@import '~bootstrap/scss/type'; 14@import 'bootstrap/scss/type';
15@import '~bootstrap/scss/grid'; 15@import 'bootstrap/scss/grid';
16@import '~bootstrap/scss/forms'; 16@import 'bootstrap/scss/forms';
17@import '~bootstrap/scss/buttons'; 17@import 'bootstrap/scss/buttons';
18@import '~bootstrap/scss/dropdown'; 18@import 'bootstrap/scss/dropdown';
19@import '~bootstrap/scss/button-group'; 19@import 'bootstrap/scss/button-group';
20@import '~bootstrap/scss/nav'; 20@import 'bootstrap/scss/nav';
21@import '~bootstrap/scss/card'; 21@import 'bootstrap/scss/card';
22@import '~bootstrap/scss/accordion'; 22@import 'bootstrap/scss/accordion';
23@import '~bootstrap/scss/alert'; 23@import 'bootstrap/scss/alert';
24@import '~bootstrap/scss/close'; 24@import 'bootstrap/scss/close';
25@import '~bootstrap/scss/modal'; 25@import 'bootstrap/scss/modal';
26@import '~bootstrap/scss/tooltip'; 26@import 'bootstrap/scss/tooltip';
27@import '~bootstrap/scss/popover'; 27@import 'bootstrap/scss/popover';
28@import '~bootstrap/scss/spinners'; 28@import 'bootstrap/scss/spinners';
29 29
30@import '~bootstrap/scss/helpers'; 30@import 'bootstrap/scss/helpers';
31@import '~bootstrap/scss/utilities/api'; 31@import 'bootstrap/scss/utilities/api';
32 32
33.accordion { 33.accordion {
34 --bs-accordion-color: #{pvar(--mainForegroundColor)}; 34 --bs-accordion-color: #{pvar(--mainForegroundColor)};
diff --git a/client/src/sass/include/_variables.scss b/client/src/sass/include/_variables.scss
index c02359f28..02fa7f1f0 100644
--- a/client/src/sass/include/_variables.scss
+++ b/client/src/sass/include/_variables.scss
@@ -1,6 +1,6 @@
1@use 'sass:math'; 1@use 'sass:math';
2@use 'sass:color'; 2@use 'sass:color';
3@use '~bootstrap/scss/functions' as *; 3@use 'bootstrap/scss/functions' as *;
4 4
5$small-view: 800px; 5$small-view: 800px;
6$mobile-view: 500px; 6$mobile-view: 500px;
diff --git a/client/src/sass/ng-select.scss b/client/src/sass/ng-select.scss
index 78e3a6de3..e231e4fed 100644
--- a/client/src/sass/ng-select.scss
+++ b/client/src/sass/ng-select.scss
@@ -15,7 +15,7 @@ $ng-select-height: 30px;
15$ng-select-value-padding-left: 15px; 15$ng-select-value-padding-left: 15px;
16$ng-select-value-font-size: $form-input-font-size; 16$ng-select-value-font-size: $form-input-font-size;
17 17
18@import '~@ng-select/ng-select/scss/default.theme'; 18@import '@ng-select/ng-select/scss/default.theme';
19 19
20.ng-select { 20.ng-select {
21 font-size: $ng-select-value-font-size; 21 font-size: $ng-select-value-font-size;
diff --git a/client/src/sass/player/_player-variables.scss b/client/src/sass/player/_player-variables.scss
index 47b8adda4..d5f24dd91 100644
--- a/client/src/sass/player/_player-variables.scss
+++ b/client/src/sass/player/_player-variables.scss
@@ -1,4 +1,4 @@
1@use '~bootstrap/scss/functions' as *; 1@use 'bootstrap/scss/functions' as *;
2 2
3$primary-foreground-color: #fff; 3$primary-foreground-color: #fff;
4$primary-foreground-opacity: 0.9; 4$primary-foreground-opacity: 0.9;
diff --git a/config/default.yaml b/config/default.yaml
index 2d8aaf1ea..f94ec6209 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -10,6 +10,11 @@ webserver:
10 hostname: 'localhost' 10 hostname: 'localhost'
11 port: 9000 11 port: 9000
12 12
13# Secrets you need to generate the first time you run PeerTube
14secrets:
15 # Generate one using `openssl rand -hex 32`
16 peertube: ''
17
13rates_limit: 18rates_limit:
14 api: 19 api:
15 # 50 attempts in 10 seconds 20 # 50 attempts in 10 seconds
diff --git a/config/dev.yaml b/config/dev.yaml
index ca93874d2..ef93afc19 100644
--- a/config/dev.yaml
+++ b/config/dev.yaml
@@ -5,6 +5,9 @@ listen:
5webserver: 5webserver:
6 https: false 6 https: false
7 7
8secrets:
9 peertube: 'my super dev secret'
10
8database: 11database:
9 hostname: 'localhost' 12 hostname: 'localhost'
10 port: 5432 13 port: 5432
diff --git a/config/production.yaml.example b/config/production.yaml.example
index 46d574e42..e37ff9b8a 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -8,6 +8,11 @@ webserver:
8 hostname: 'example.com' 8 hostname: 'example.com'
9 port: 443 9 port: 443
10 10
11# Secrets you need to generate the first time you run PeerTube
12secret:
13 # Generate one using `openssl rand -hex 32`
14 peertube: ''
15
11rates_limit: 16rates_limit:
12 api: 17 api:
13 # 50 attempts in 10 seconds 18 # 50 attempts in 10 seconds
diff --git a/config/test.yaml b/config/test.yaml
index a87642bd8..48cf0c0f6 100644
--- a/config/test.yaml
+++ b/config/test.yaml
@@ -5,6 +5,9 @@ listen:
5webserver: 5webserver:
6 https: false 6 https: false
7 7
8secrets:
9 peertube: 'my super secret'
10
8rates_limit: 11rates_limit:
9 signup: 12 signup:
10 window: 10 minutes 13 window: 10 minutes
diff --git a/package.json b/package.json
index dd913896d..6dcf26253 100644
--- a/package.json
+++ b/package.json
@@ -147,6 +147,7 @@
147 "node-media-server": "^2.1.4", 147 "node-media-server": "^2.1.4",
148 "nodemailer": "^6.0.0", 148 "nodemailer": "^6.0.0",
149 "opentelemetry-instrumentation-sequelize": "^0.29.0", 149 "opentelemetry-instrumentation-sequelize": "^0.29.0",
150 "otpauth": "^8.0.3",
150 "p-queue": "^6", 151 "p-queue": "^6",
151 "parse-torrent": "^9.1.0", 152 "parse-torrent": "^9.1.0",
152 "password-generator": "^2.0.2", 153 "password-generator": "^2.0.2",
diff --git a/server.ts b/server.ts
index 2085c67d9..417387a4f 100644
--- a/server.ts
+++ b/server.ts
@@ -45,7 +45,12 @@ try {
45 45
46import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init' 46import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
47 47
48checkConfig() 48try {
49 checkConfig()
50} catch (err) {
51 logger.error('Config error.', { err })
52 process.exit(-1)
53}
49 54
50// Trust our proxy (IP forwarding...) 55// Trust our proxy (IP forwarding...)
51app.set('trust proxy', CONFIG.TRUST_PROXY) 56app.set('trust proxy', CONFIG.TRUST_PROXY)
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index 07b9ae395..a8677a1d3 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -51,6 +51,7 @@ import { myVideosHistoryRouter } from './my-history'
51import { myNotificationsRouter } from './my-notifications' 51import { myNotificationsRouter } from './my-notifications'
52import { mySubscriptionsRouter } from './my-subscriptions' 52import { mySubscriptionsRouter } from './my-subscriptions'
53import { myVideoPlaylistsRouter } from './my-video-playlists' 53import { myVideoPlaylistsRouter } from './my-video-playlists'
54import { twoFactorRouter } from './two-factor'
54 55
55const auditLogger = auditLoggerFactory('users') 56const auditLogger = auditLoggerFactory('users')
56 57
@@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({
66}) 67})
67 68
68const usersRouter = express.Router() 69const usersRouter = express.Router()
70usersRouter.use('/', twoFactorRouter)
69usersRouter.use('/', tokensRouter) 71usersRouter.use('/', tokensRouter)
70usersRouter.use('/', myNotificationsRouter) 72usersRouter.use('/', myNotificationsRouter)
71usersRouter.use('/', mySubscriptionsRouter) 73usersRouter.use('/', mySubscriptionsRouter)
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 012a49791..c6afea67c 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -1,8 +1,9 @@
1import express from 'express' 1import express from 'express'
2import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
3import { CONFIG } from '@server/initializers/config' 3import { CONFIG } from '@server/initializers/config'
4import { OTP } from '@server/initializers/constants'
4import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' 5import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
5import { handleOAuthToken } from '@server/lib/auth/oauth' 6import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth'
6import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' 7import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
7import { Hooks } from '@server/lib/plugins/hooks' 8import { Hooks } from '@server/lib/plugins/hooks'
8import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares' 9import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
@@ -79,6 +80,10 @@ async function handleToken (req: express.Request, res: express.Response, next: e
79 } catch (err) { 80 } catch (err) {
80 logger.warn('Login error', { err }) 81 logger.warn('Login error', { err })
81 82
83 if (err instanceof MissingTwoFactorError) {
84 res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
85 }
86
82 return res.fail({ 87 return res.fail({
83 status: err.code, 88 status: err.code,
84 message: err.message, 89 message: err.message,
diff --git a/server/controllers/api/users/two-factor.ts b/server/controllers/api/users/two-factor.ts
new file mode 100644
index 000000000..e6ae9e4dd
--- /dev/null
+++ b/server/controllers/api/users/two-factor.ts
@@ -0,0 +1,95 @@
1import express from 'express'
2import { generateOTPSecret, isOTPValid } from '@server/helpers/otp'
3import { encrypt } from '@server/helpers/peertube-crypto'
4import { CONFIG } from '@server/initializers/config'
5import { Redis } from '@server/lib/redis'
6import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares'
7import {
8 confirmTwoFactorValidator,
9 disableTwoFactorValidator,
10 requestOrConfirmTwoFactorValidator
11} from '@server/middlewares/validators/two-factor'
12import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
13
14const twoFactorRouter = express.Router()
15
16twoFactorRouter.post('/:id/two-factor/request',
17 authenticate,
18 asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
19 asyncMiddleware(requestOrConfirmTwoFactorValidator),
20 asyncMiddleware(requestTwoFactor)
21)
22
23twoFactorRouter.post('/:id/two-factor/confirm-request',
24 authenticate,
25 asyncMiddleware(requestOrConfirmTwoFactorValidator),
26 confirmTwoFactorValidator,
27 asyncMiddleware(confirmRequestTwoFactor)
28)
29
30twoFactorRouter.post('/:id/two-factor/disable',
31 authenticate,
32 asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
33 asyncMiddleware(disableTwoFactorValidator),
34 asyncMiddleware(disableTwoFactor)
35)
36
37// ---------------------------------------------------------------------------
38
39export {
40 twoFactorRouter
41}
42
43// ---------------------------------------------------------------------------
44
45async function requestTwoFactor (req: express.Request, res: express.Response) {
46 const user = res.locals.user
47
48 const { secret, uri } = generateOTPSecret(user.email)
49
50 const encryptedSecret = await encrypt(secret, CONFIG.SECRETS.PEERTUBE)
51 const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, encryptedSecret)
52
53 return res.json({
54 otpRequest: {
55 requestToken,
56 secret,
57 uri
58 }
59 } as TwoFactorEnableResult)
60}
61
62async function confirmRequestTwoFactor (req: express.Request, res: express.Response) {
63 const requestToken = req.body.requestToken
64 const otpToken = req.body.otpToken
65 const user = res.locals.user
66
67 const encryptedSecret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
68 if (!encryptedSecret) {
69 return res.fail({
70 message: 'Invalid request token',
71 status: HttpStatusCode.FORBIDDEN_403
72 })
73 }
74
75 if (await isOTPValid({ encryptedSecret, token: otpToken }) !== true) {
76 return res.fail({
77 message: 'Invalid OTP token',
78 status: HttpStatusCode.FORBIDDEN_403
79 })
80 }
81
82 user.otpSecret = encryptedSecret
83 await user.save()
84
85 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
86}
87
88async function disableTwoFactor (req: express.Request, res: express.Response) {
89 const user = res.locals.user
90
91 user.otpSecret = null
92 await user.save()
93
94 return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
95}
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index c762f6a29..73bd994c1 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -6,7 +6,7 @@
6*/ 6*/
7 7
8import { exec, ExecOptions } from 'child_process' 8import { exec, ExecOptions } from 'child_process'
9import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions } from 'crypto' 9import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions, scrypt } from 'crypto'
10import { truncate } from 'lodash' 10import { truncate } from 'lodash'
11import { pipeline } from 'stream' 11import { pipeline } from 'stream'
12import { URL } from 'url' 12import { URL } from 'url'
@@ -311,7 +311,17 @@ function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A)
311 } 311 }
312} 312}
313 313
314// eslint-disable-next-line max-len
315function promisify3<T, U, V, A> (func: (arg1: T, arg2: U, arg3: V, cb: (err: any, result: A) => void) => void): (arg1: T, arg2: U, arg3: V) => Promise<A> {
316 return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
317 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
318 func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
319 })
320 }
321}
322
314const randomBytesPromise = promisify1<number, Buffer>(randomBytes) 323const randomBytesPromise = promisify1<number, Buffer>(randomBytes)
324const scryptPromise = promisify3<string, string, number, Buffer>(scrypt)
315const execPromise2 = promisify2<string, any, string>(exec) 325const execPromise2 = promisify2<string, any, string>(exec)
316const execPromise = promisify1<string, string>(exec) 326const execPromise = promisify1<string, string>(exec)
317const pipelinePromise = promisify(pipeline) 327const pipelinePromise = promisify(pipeline)
@@ -339,6 +349,8 @@ export {
339 promisify1, 349 promisify1,
340 promisify2, 350 promisify2,
341 351
352 scryptPromise,
353
342 randomBytesPromise, 354 randomBytesPromise,
343 355
344 generateRSAKeyPairPromise, 356 generateRSAKeyPairPromise,
diff --git a/server/helpers/otp.ts b/server/helpers/otp.ts
new file mode 100644
index 000000000..a32cc9621
--- /dev/null
+++ b/server/helpers/otp.ts
@@ -0,0 +1,58 @@
1import { Secret, TOTP } from 'otpauth'
2import { CONFIG } from '@server/initializers/config'
3import { WEBSERVER } from '@server/initializers/constants'
4import { decrypt } from './peertube-crypto'
5
6async function isOTPValid (options: {
7 encryptedSecret: string
8 token: string
9}) {
10 const { token, encryptedSecret } = options
11
12 const secret = await decrypt(encryptedSecret, CONFIG.SECRETS.PEERTUBE)
13
14 const totp = new TOTP({
15 ...baseOTPOptions(),
16
17 secret
18 })
19
20 const delta = totp.validate({
21 token,
22 window: 1
23 })
24
25 if (delta === null) return false
26
27 return true
28}
29
30function generateOTPSecret (email: string) {
31 const totp = new TOTP({
32 ...baseOTPOptions(),
33
34 label: email,
35 secret: new Secret()
36 })
37
38 return {
39 secret: totp.secret.base32,
40 uri: totp.toString()
41 }
42}
43
44export {
45 isOTPValid,
46 generateOTPSecret
47}
48
49// ---------------------------------------------------------------------------
50
51function baseOTPOptions () {
52 return {
53 issuer: WEBSERVER.HOST,
54 algorithm: 'SHA1',
55 digits: 6,
56 period: 30
57 }
58}
diff --git a/server/helpers/peertube-crypto.ts b/server/helpers/peertube-crypto.ts
index 8aca50900..ae7d11800 100644
--- a/server/helpers/peertube-crypto.ts
+++ b/server/helpers/peertube-crypto.ts
@@ -1,11 +1,11 @@
1import { compare, genSalt, hash } from 'bcrypt' 1import { compare, genSalt, hash } from 'bcrypt'
2import { createSign, createVerify } from 'crypto' 2import { createCipheriv, createDecipheriv, createSign, createVerify } from 'crypto'
3import { Request } from 'express' 3import { Request } from 'express'
4import { cloneDeep } from 'lodash' 4import { cloneDeep } from 'lodash'
5import { sha256 } from '@shared/extra-utils' 5import { sha256 } from '@shared/extra-utils'
6import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants' 6import { BCRYPT_SALT_SIZE, ENCRYPTION, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
7import { MActor } from '../types/models' 7import { MActor } from '../types/models'
8import { generateRSAKeyPairPromise, promisify1, promisify2 } from './core-utils' 8import { generateRSAKeyPairPromise, promisify1, promisify2, randomBytesPromise, scryptPromise } from './core-utils'
9import { jsonld } from './custom-jsonld-signature' 9import { jsonld } from './custom-jsonld-signature'
10import { logger } from './logger' 10import { logger } from './logger'
11 11
@@ -21,9 +21,13 @@ function createPrivateAndPublicKeys () {
21 return generateRSAKeyPairPromise(PRIVATE_RSA_KEY_SIZE) 21 return generateRSAKeyPairPromise(PRIVATE_RSA_KEY_SIZE)
22} 22}
23 23
24// ---------------------------------------------------------------------------
24// User password checks 25// User password checks
26// ---------------------------------------------------------------------------
25 27
26function comparePassword (plainPassword: string, hashPassword: string) { 28function comparePassword (plainPassword: string, hashPassword: string) {
29 if (!plainPassword) return Promise.resolve(false)
30
27 return bcryptComparePromise(plainPassword, hashPassword) 31 return bcryptComparePromise(plainPassword, hashPassword)
28} 32}
29 33
@@ -33,7 +37,9 @@ async function cryptPassword (password: string) {
33 return bcryptHashPromise(password, salt) 37 return bcryptHashPromise(password, salt)
34} 38}
35 39
40// ---------------------------------------------------------------------------
36// HTTP Signature 41// HTTP Signature
42// ---------------------------------------------------------------------------
37 43
38function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean { 44function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
39 if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) { 45 if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
@@ -62,7 +68,9 @@ function parseHTTPSignature (req: Request, clockSkew?: number) {
62 return parsed 68 return parsed
63} 69}
64 70
71// ---------------------------------------------------------------------------
65// JSONLD 72// JSONLD
73// ---------------------------------------------------------------------------
66 74
67function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise<boolean> { 75function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise<boolean> {
68 if (signedDocument.signature.type === 'RsaSignature2017') { 76 if (signedDocument.signature.type === 'RsaSignature2017') {
@@ -112,6 +120,8 @@ async function signJsonLDObject <T> (byActor: MActor, data: T) {
112 return Object.assign(data, { signature }) 120 return Object.assign(data, { signature })
113} 121}
114 122
123// ---------------------------------------------------------------------------
124
115function buildDigest (body: any) { 125function buildDigest (body: any) {
116 const rawBody = typeof body === 'string' ? body : JSON.stringify(body) 126 const rawBody = typeof body === 'string' ? body : JSON.stringify(body)
117 127
@@ -119,6 +129,34 @@ function buildDigest (body: any) {
119} 129}
120 130
121// --------------------------------------------------------------------------- 131// ---------------------------------------------------------------------------
132// Encryption
133// ---------------------------------------------------------------------------
134
135async function encrypt (str: string, secret: string) {
136 const iv = await randomBytesPromise(ENCRYPTION.IV)
137
138 const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
139 const cipher = createCipheriv(ENCRYPTION.ALGORITHM, key, iv)
140
141 let encrypted = iv.toString(ENCRYPTION.ENCODING) + ':'
142 encrypted += cipher.update(str, 'utf8', ENCRYPTION.ENCODING)
143 encrypted += cipher.final(ENCRYPTION.ENCODING)
144
145 return encrypted
146}
147
148async function decrypt (encryptedArg: string, secret: string) {
149 const [ ivStr, encryptedStr ] = encryptedArg.split(':')
150
151 const iv = Buffer.from(ivStr, 'hex')
152 const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
153
154 const decipher = createDecipheriv(ENCRYPTION.ALGORITHM, key, iv)
155
156 return decipher.update(encryptedStr, ENCRYPTION.ENCODING, 'utf8') + decipher.final('utf8')
157}
158
159// ---------------------------------------------------------------------------
122 160
123export { 161export {
124 isHTTPSignatureDigestValid, 162 isHTTPSignatureDigestValid,
@@ -129,7 +167,10 @@ export {
129 comparePassword, 167 comparePassword,
130 createPrivateAndPublicKeys, 168 createPrivateAndPublicKeys,
131 cryptPassword, 169 cryptPassword,
132 signJsonLDObject 170 signJsonLDObject,
171
172 encrypt,
173 decrypt
133} 174}
134 175
135// --------------------------------------------------------------------------- 176// ---------------------------------------------------------------------------
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index 42839d1c9..c83fef425 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -42,6 +42,7 @@ function checkConfig () {
42 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.') 42 logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
43 } 43 }
44 44
45 checkSecretsConfig()
45 checkEmailConfig() 46 checkEmailConfig()
46 checkNSFWPolicyConfig() 47 checkNSFWPolicyConfig()
47 checkLocalRedundancyConfig() 48 checkLocalRedundancyConfig()
@@ -103,6 +104,12 @@ export {
103 104
104// --------------------------------------------------------------------------- 105// ---------------------------------------------------------------------------
105 106
107function checkSecretsConfig () {
108 if (!CONFIG.SECRETS.PEERTUBE) {
109 throw new Error('secrets.peertube is missing in config. Generate one using `openssl rand -hex 32`')
110 }
111}
112
106function checkEmailConfig () { 113function checkEmailConfig () {
107 if (!isEmailEnabled()) { 114 if (!isEmailEnabled()) {
108 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { 115 if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 1fd4ba248..c9268b156 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -11,6 +11,7 @@ const config: IConfig = require('config')
11function checkMissedConfig () { 11function checkMissedConfig () {
12 const required = [ 'listen.port', 'listen.hostname', 12 const required = [ 'listen.port', 'listen.hostname',
13 'webserver.https', 'webserver.hostname', 'webserver.port', 13 'webserver.https', 'webserver.hostname', 'webserver.port',
14 'secrets.peertube',
14 'trust_proxy', 15 'trust_proxy',
15 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 16 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
16 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 17 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 287bf6f6d..a5a0d4e46 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -20,6 +20,9 @@ const CONFIG = {
20 PORT: config.get<number>('listen.port'), 20 PORT: config.get<number>('listen.port'),
21 HOSTNAME: config.get<string>('listen.hostname') 21 HOSTNAME: config.get<string>('listen.hostname')
22 }, 22 },
23 SECRETS: {
24 PEERTUBE: config.get<string>('secrets.peertube')
25 },
23 DATABASE: { 26 DATABASE: {
24 DBNAME: config.has('database.name') ? config.get<string>('database.name') : 'peertube' + config.get<string>('database.suffix'), 27 DBNAME: config.has('database.name') ? config.get<string>('database.name') : 'peertube' + config.get<string>('database.suffix'),
25 HOSTNAME: config.get<string>('database.hostname'), 28 HOSTNAME: config.get<string>('database.hostname'),
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 9257ebf93..cab61948a 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -1,5 +1,5 @@
1import { RepeatOptions } from 'bullmq' 1import { RepeatOptions } from 'bullmq'
2import { randomBytes } from 'crypto' 2import { Encoding, randomBytes } from 'crypto'
3import { invert } from 'lodash' 3import { invert } from 'lodash'
4import { join } from 'path' 4import { join } from 'path'
5import { randomInt, root } from '@shared/core-utils' 5import { randomInt, root } from '@shared/core-utils'
@@ -25,7 +25,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
25 25
26// --------------------------------------------------------------------------- 26// ---------------------------------------------------------------------------
27 27
28const LAST_MIGRATION_VERSION = 740 28const LAST_MIGRATION_VERSION = 745
29 29
30// --------------------------------------------------------------------------- 30// ---------------------------------------------------------------------------
31 31
@@ -637,9 +637,18 @@ let PRIVATE_RSA_KEY_SIZE = 2048
637// Password encryption 637// Password encryption
638const BCRYPT_SALT_SIZE = 10 638const BCRYPT_SALT_SIZE = 10
639 639
640const ENCRYPTION = {
641 ALGORITHM: 'aes-256-cbc',
642 IV: 16,
643 SALT: 'peertube',
644 ENCODING: 'hex' as Encoding
645}
646
640const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes 647const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
641const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days 648const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
642 649
650const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
651
643const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes 652const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
644 653
645const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = { 654const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
@@ -805,6 +814,10 @@ const REDUNDANCY = {
805} 814}
806 815
807const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS) 816const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
817const OTP = {
818 HEADER_NAME: 'x-peertube-otp',
819 HEADER_REQUIRED_VALUE: 'required; app'
820}
808 821
809const ASSETS_PATH = { 822const ASSETS_PATH = {
810 DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'), 823 DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
@@ -953,6 +966,7 @@ const VIDEO_FILTERS = {
953export { 966export {
954 WEBSERVER, 967 WEBSERVER,
955 API_VERSION, 968 API_VERSION,
969 ENCRYPTION,
956 VIDEO_LIVE, 970 VIDEO_LIVE,
957 PEERTUBE_VERSION, 971 PEERTUBE_VERSION,
958 LAZY_STATIC_PATHS, 972 LAZY_STATIC_PATHS,
@@ -986,6 +1000,7 @@ export {
986 FOLLOW_STATES, 1000 FOLLOW_STATES,
987 DEFAULT_USER_THEME_NAME, 1001 DEFAULT_USER_THEME_NAME,
988 SERVER_ACTOR_NAME, 1002 SERVER_ACTOR_NAME,
1003 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
989 PLUGIN_GLOBAL_CSS_FILE_NAME, 1004 PLUGIN_GLOBAL_CSS_FILE_NAME,
990 PLUGIN_GLOBAL_CSS_PATH, 1005 PLUGIN_GLOBAL_CSS_PATH,
991 PRIVATE_RSA_KEY_SIZE, 1006 PRIVATE_RSA_KEY_SIZE,
@@ -1041,6 +1056,7 @@ export {
1041 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME, 1056 PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
1042 ASSETS_PATH, 1057 ASSETS_PATH,
1043 FILES_CONTENT_HASH, 1058 FILES_CONTENT_HASH,
1059 OTP,
1044 loadLanguages, 1060 loadLanguages,
1045 buildLanguages, 1061 buildLanguages,
1046 generateContentHash 1062 generateContentHash
diff --git a/server/initializers/migrations/0745-user-otp.ts b/server/initializers/migrations/0745-user-otp.ts
new file mode 100644
index 000000000..157308ea1
--- /dev/null
+++ b/server/initializers/migrations/0745-user-otp.ts
@@ -0,0 +1,29 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 const { transaction } = utils
10
11 const data = {
12 type: Sequelize.STRING,
13 defaultValue: null,
14 allowNull: true
15 }
16 await utils.queryInterface.addColumn('user', 'otpSecret', data, { transaction })
17
18}
19
20async function down (utils: {
21 queryInterface: Sequelize.QueryInterface
22 transaction: Sequelize.Transaction
23}) {
24}
25
26export {
27 up,
28 down
29}
diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts
index fa1887315..35b05ec5a 100644
--- a/server/lib/auth/oauth.ts
+++ b/server/lib/auth/oauth.ts
@@ -9,11 +9,23 @@ import OAuth2Server, {
9 UnsupportedGrantTypeError 9 UnsupportedGrantTypeError
10} from '@node-oauth/oauth2-server' 10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp'
12import { MOAuthClient } from '@server/types/models' 13import { MOAuthClient } from '@server/types/models'
13import { sha1 } from '@shared/extra-utils' 14import { sha1 } from '@shared/extra-utils'
14import { OAUTH_LIFETIME } from '../../initializers/constants' 15import { HttpStatusCode } from '@shared/models'
16import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
15import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 17import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
16 18
19class MissingTwoFactorError extends Error {
20 code = HttpStatusCode.UNAUTHORIZED_401
21 name = 'missing_two_factor'
22}
23
24class InvalidTwoFactorError extends Error {
25 code = HttpStatusCode.BAD_REQUEST_400
26 name = 'invalid_two_factor'
27}
28
17/** 29/**
18 * 30 *
19 * Reimplement some functions of OAuth2Server to inject external auth methods 31 * Reimplement some functions of OAuth2Server to inject external auth methods
@@ -94,6 +106,9 @@ function handleOAuthAuthenticate (
94} 106}
95 107
96export { 108export {
109 MissingTwoFactorError,
110 InvalidTwoFactorError,
111
97 handleOAuthToken, 112 handleOAuthToken,
98 handleOAuthAuthenticate 113 handleOAuthAuthenticate
99} 114}
@@ -118,6 +133,16 @@ async function handlePasswordGrant (options: {
118 const user = await getUser(request.body.username, request.body.password, bypassLogin) 133 const user = await getUser(request.body.username, request.body.password, bypassLogin)
119 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid') 134 if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
120 135
136 if (user.otpSecret) {
137 if (!request.headers[OTP.HEADER_NAME]) {
138 throw new MissingTwoFactorError('Missing two factor header')
139 }
140
141 if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
142 throw new InvalidTwoFactorError('Invalid two factor header')
143 }
144 }
145
121 const token = await buildToken() 146 const token = await buildToken()
122 147
123 return saveToken(token, client, user, { bypassLogin }) 148 return saveToken(token, client, user, { bypassLogin })
diff --git a/server/lib/redis.ts b/server/lib/redis.ts
index 9b3c72300..b7523492a 100644
--- a/server/lib/redis.ts
+++ b/server/lib/redis.ts
@@ -9,6 +9,7 @@ import {
9 CONTACT_FORM_LIFETIME, 9 CONTACT_FORM_LIFETIME,
10 RESUMABLE_UPLOAD_SESSION_LIFETIME, 10 RESUMABLE_UPLOAD_SESSION_LIFETIME,
11 TRACKER_RATE_LIMITS, 11 TRACKER_RATE_LIMITS,
12 TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
12 USER_EMAIL_VERIFY_LIFETIME, 13 USER_EMAIL_VERIFY_LIFETIME,
13 USER_PASSWORD_CREATE_LIFETIME, 14 USER_PASSWORD_CREATE_LIFETIME,
14 USER_PASSWORD_RESET_LIFETIME, 15 USER_PASSWORD_RESET_LIFETIME,
@@ -108,10 +109,24 @@ class Redis {
108 return this.removeValue(this.generateResetPasswordKey(userId)) 109 return this.removeValue(this.generateResetPasswordKey(userId))
109 } 110 }
110 111
111 async getResetPasswordLink (userId: number) { 112 async getResetPasswordVerificationString (userId: number) {
112 return this.getValue(this.generateResetPasswordKey(userId)) 113 return this.getValue(this.generateResetPasswordKey(userId))
113 } 114 }
114 115
116 /* ************ Two factor auth request ************ */
117
118 async setTwoFactorRequest (userId: number, otpSecret: string) {
119 const requestToken = await generateRandomString(32)
120
121 await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME)
122
123 return requestToken
124 }
125
126 async getTwoFactorRequestToken (userId: number, requestToken: string) {
127 return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken))
128 }
129
115 /* ************ Email verification ************ */ 130 /* ************ Email verification ************ */
116 131
117 async setVerifyEmailVerificationString (userId: number) { 132 async setVerifyEmailVerificationString (userId: number) {
@@ -342,6 +357,10 @@ class Redis {
342 return 'reset-password-' + userId 357 return 'reset-password-' + userId
343 } 358 }
344 359
360 private generateTwoFactorRequestKey (userId: number, token: string) {
361 return 'two-factor-request-' + userId + '-' + token
362 }
363
345 private generateVerifyEmailKey (userId: number) { 364 private generateVerifyEmailKey (userId: number) {
346 return 'verify-email-' + userId 365 return 'verify-email-' + userId
347 } 366 }
@@ -391,8 +410,8 @@ class Redis {
391 return JSON.parse(value) 410 return JSON.parse(value)
392 } 411 }
393 412
394 private setObject (key: string, value: { [ id: string ]: number | string }) { 413 private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
395 return this.setValue(key, JSON.stringify(value)) 414 return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
396 } 415 }
397 416
398 private async setValue (key: string, value: string, expirationMilliseconds?: number) { 417 private async setValue (key: string, value: string, expirationMilliseconds?: number) {
diff --git a/server/middlewares/validators/shared/index.ts b/server/middlewares/validators/shared/index.ts
index bbd03b248..de98cd442 100644
--- a/server/middlewares/validators/shared/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -1,5 +1,6 @@
1export * from './abuses' 1export * from './abuses'
2export * from './accounts' 2export * from './accounts'
3export * from './users'
3export * from './utils' 4export * from './utils'
4export * from './video-blacklists' 5export * from './video-blacklists'
5export * from './video-captions' 6export * from './video-captions'
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
new file mode 100644
index 000000000..fbaa7db0e
--- /dev/null
+++ b/server/middlewares/validators/shared/users.ts
@@ -0,0 +1,62 @@
1import express from 'express'
2import { ActorModel } from '@server/models/actor/actor'
3import { UserModel } from '@server/models/user/user'
4import { MUserDefault } from '@server/types/models'
5import { HttpStatusCode } from '@shared/models'
6
7function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
8 const id = parseInt(idArg + '', 10)
9 return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
10}
11
12function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
13 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
14}
15
16async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
17 const user = await UserModel.loadByUsernameOrEmail(username, email)
18
19 if (user) {
20 res.fail({
21 status: HttpStatusCode.CONFLICT_409,
22 message: 'User with this username or email already exists.'
23 })
24 return false
25 }
26
27 const actor = await ActorModel.loadLocalByName(username)
28 if (actor) {
29 res.fail({
30 status: HttpStatusCode.CONFLICT_409,
31 message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
32 })
33 return false
34 }
35
36 return true
37}
38
39async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
40 const user = await finder()
41
42 if (!user) {
43 if (abortResponse === true) {
44 res.fail({
45 status: HttpStatusCode.NOT_FOUND_404,
46 message: 'User not found'
47 })
48 }
49
50 return false
51 }
52
53 res.locals.user = user
54 return true
55}
56
57export {
58 checkUserIdExist,
59 checkUserEmailExist,
60 checkUserNameOrEmailDoesNotAlreadyExist,
61 checkUserExist
62}
diff --git a/server/middlewares/validators/two-factor.ts b/server/middlewares/validators/two-factor.ts
new file mode 100644
index 000000000..106b579b5
--- /dev/null
+++ b/server/middlewares/validators/two-factor.ts
@@ -0,0 +1,81 @@
1import express from 'express'
2import { body, param } from 'express-validator'
3import { HttpStatusCode, UserRight } from '@shared/models'
4import { exists, isIdValid } from '../../helpers/custom-validators/misc'
5import { areValidationErrors, checkUserIdExist } from './shared'
6
7const requestOrConfirmTwoFactorValidator = [
8 param('id').custom(isIdValid),
9
10 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
11 if (areValidationErrors(req, res)) return
12
13 if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
14
15 if (res.locals.user.otpSecret) {
16 return res.fail({
17 status: HttpStatusCode.BAD_REQUEST_400,
18 message: `Two factor is already enabled.`
19 })
20 }
21
22 return next()
23 }
24]
25
26const confirmTwoFactorValidator = [
27 body('requestToken').custom(exists),
28 body('otpToken').custom(exists),
29
30 (req: express.Request, res: express.Response, next: express.NextFunction) => {
31 if (areValidationErrors(req, res)) return
32
33 return next()
34 }
35]
36
37const disableTwoFactorValidator = [
38 param('id').custom(isIdValid),
39
40 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
41 if (areValidationErrors(req, res)) return
42
43 if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
44
45 if (!res.locals.user.otpSecret) {
46 return res.fail({
47 status: HttpStatusCode.BAD_REQUEST_400,
48 message: `Two factor is already disabled.`
49 })
50 }
51
52 return next()
53 }
54]
55
56// ---------------------------------------------------------------------------
57
58export {
59 requestOrConfirmTwoFactorValidator,
60 confirmTwoFactorValidator,
61 disableTwoFactorValidator
62}
63
64// ---------------------------------------------------------------------------
65
66async function checkCanEnableOrDisableTwoFactor (userId: number | string, res: express.Response) {
67 const authUser = res.locals.oauth.token.user
68
69 if (!await checkUserIdExist(userId, res)) return
70
71 if (res.locals.user.id !== authUser.id && authUser.hasRight(UserRight.MANAGE_USERS) !== true) {
72 res.fail({
73 status: HttpStatusCode.FORBIDDEN_403,
74 message: `User ${authUser.username} does not have right to change two factor setting of this user.`
75 })
76
77 return false
78 }
79
80 return true
81}
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index eb693318f..055af3b64 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -1,9 +1,8 @@
1import express from 'express' 1import express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { Hooks } from '@server/lib/plugins/hooks' 3import { Hooks } from '@server/lib/plugins/hooks'
4import { MUserDefault } from '@server/types/models'
5import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models' 4import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models'
6import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 5import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
8import { 7import {
9 isUserAdminFlagsValid, 8 isUserAdminFlagsValid,
@@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils'
30import { Redis } from '../../lib/redis' 29import { Redis } from '../../lib/redis'
31import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup' 30import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
32import { ActorModel } from '../../models/actor/actor' 31import { ActorModel } from '../../models/actor/actor'
33import { UserModel } from '../../models/user/user' 32import {
34import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared' 33 areValidationErrors,
34 checkUserEmailExist,
35 checkUserIdExist,
36 checkUserNameOrEmailDoesNotAlreadyExist,
37 doesVideoChannelIdExist,
38 doesVideoExist,
39 isValidVideoIdParam
40} from './shared'
35 41
36const usersListValidator = [ 42const usersListValidator = [
37 query('blocked') 43 query('blocked')
@@ -435,7 +441,7 @@ const usersResetPasswordValidator = [
435 if (!await checkUserIdExist(req.params.id, res)) return 441 if (!await checkUserIdExist(req.params.id, res)) return
436 442
437 const user = res.locals.user 443 const user = res.locals.user
438 const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) 444 const redisVerificationString = await Redis.Instance.getResetPasswordVerificationString(user.id)
439 445
440 if (redisVerificationString !== req.body.verificationString) { 446 if (redisVerificationString !== req.body.verificationString) {
441 return res.fail({ 447 return res.fail({
@@ -500,6 +506,41 @@ const usersVerifyEmailValidator = [
500 } 506 }
501] 507]
502 508
509const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
510 return [
511 body('currentPassword').optional().custom(exists),
512
513 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
514 if (areValidationErrors(req, res)) return
515
516 const user = res.locals.oauth.token.User
517 const isAdminOrModerator = user.role === UserRole.ADMINISTRATOR || user.role === UserRole.MODERATOR
518 const targetUserId = parseInt(targetUserIdGetter(req) + '')
519
520 // Admin/moderator action on another user, skip the password check
521 if (isAdminOrModerator && targetUserId !== user.id) {
522 return next()
523 }
524
525 if (!req.body.currentPassword) {
526 return res.fail({
527 status: HttpStatusCode.BAD_REQUEST_400,
528 message: 'currentPassword is missing'
529 })
530 }
531
532 if (await user.isPasswordMatch(req.body.currentPassword) !== true) {
533 return res.fail({
534 status: HttpStatusCode.FORBIDDEN_403,
535 message: 'currentPassword is invalid.'
536 })
537 }
538
539 return next()
540 }
541 ]
542}
543
503const userAutocompleteValidator = [ 544const userAutocompleteValidator = [
504 param('search') 545 param('search')
505 .isString() 546 .isString()
@@ -567,6 +608,7 @@ export {
567 usersUpdateValidator, 608 usersUpdateValidator,
568 usersUpdateMeValidator, 609 usersUpdateMeValidator,
569 usersVideoRatingValidator, 610 usersVideoRatingValidator,
611 usersCheckCurrentPasswordFactory,
570 ensureUserRegistrationAllowed, 612 ensureUserRegistrationAllowed,
571 ensureUserRegistrationAllowedForIP, 613 ensureUserRegistrationAllowedForIP,
572 usersGetValidator, 614 usersGetValidator,
@@ -580,55 +622,3 @@ export {
580 ensureCanModerateUser, 622 ensureCanModerateUser,
581 ensureCanManageChannelOrAccount 623 ensureCanManageChannelOrAccount
582} 624}
583
584// ---------------------------------------------------------------------------
585
586function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
587 const id = parseInt(idArg + '', 10)
588 return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
589}
590
591function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
592 return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
593}
594
595async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
596 const user = await UserModel.loadByUsernameOrEmail(username, email)
597
598 if (user) {
599 res.fail({
600 status: HttpStatusCode.CONFLICT_409,
601 message: 'User with this username or email already exists.'
602 })
603 return false
604 }
605
606 const actor = await ActorModel.loadLocalByName(username)
607 if (actor) {
608 res.fail({
609 status: HttpStatusCode.CONFLICT_409,
610 message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
611 })
612 return false
613 }
614
615 return true
616}
617
618async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
619 const user = await finder()
620
621 if (!user) {
622 if (abortResponse === true) {
623 res.fail({
624 status: HttpStatusCode.NOT_FOUND_404,
625 message: 'User not found'
626 })
627 }
628
629 return false
630 }
631
632 res.locals.user = user
633 return true
634}
diff --git a/server/models/user/user.ts b/server/models/user/user.ts
index 1a7c84390..34329580b 100644
--- a/server/models/user/user.ts
+++ b/server/models/user/user.ts
@@ -403,6 +403,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
403 @Column 403 @Column
404 lastLoginDate: Date 404 lastLoginDate: Date
405 405
406 @AllowNull(true)
407 @Default(null)
408 @Column
409 otpSecret: string
410
406 @CreatedAt 411 @CreatedAt
407 createdAt: Date 412 createdAt: Date
408 413
@@ -935,7 +940,9 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
935 940
936 pluginAuth: this.pluginAuth, 941 pluginAuth: this.pluginAuth,
937 942
938 lastLoginDate: this.lastLoginDate 943 lastLoginDate: this.lastLoginDate,
944
945 twoFactorEnabled: !!this.otpSecret
939 } 946 }
940 947
941 if (parameters.withAdminFlags) { 948 if (parameters.withAdminFlags) {
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index cd7a38459..33dc8fb76 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -2,6 +2,7 @@ import './abuses'
2import './accounts' 2import './accounts'
3import './blocklist' 3import './blocklist'
4import './bulk' 4import './bulk'
5import './channel-import-videos'
5import './config' 6import './config'
6import './contact-form' 7import './contact-form'
7import './custom-pages' 8import './custom-pages'
@@ -17,6 +18,7 @@ import './redundancy'
17import './search' 18import './search'
18import './services' 19import './services'
19import './transcoding' 20import './transcoding'
21import './two-factor'
20import './upload-quota' 22import './upload-quota'
21import './user-notifications' 23import './user-notifications'
22import './user-subscriptions' 24import './user-subscriptions'
@@ -24,12 +26,11 @@ import './users-admin'
24import './users' 26import './users'
25import './video-blacklist' 27import './video-blacklist'
26import './video-captions' 28import './video-captions'
29import './video-channel-syncs'
27import './video-channels' 30import './video-channels'
28import './video-comments' 31import './video-comments'
29import './video-files' 32import './video-files'
30import './video-imports' 33import './video-imports'
31import './video-channel-syncs'
32import './channel-import-videos'
33import './video-playlists' 34import './video-playlists'
34import './video-source' 35import './video-source'
35import './video-studio' 36import './video-studio'
diff --git a/server/tests/api/check-params/two-factor.ts b/server/tests/api/check-params/two-factor.ts
new file mode 100644
index 000000000..f8365f1b5
--- /dev/null
+++ b/server/tests/api/check-params/two-factor.ts
@@ -0,0 +1,288 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { HttpStatusCode } from '@shared/models'
4import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
5
6describe('Test two factor API validators', function () {
7 let server: PeerTubeServer
8
9 let rootId: number
10 let rootPassword: string
11 let rootRequestToken: string
12 let rootOTPToken: string
13
14 let userId: number
15 let userToken = ''
16 let userPassword: string
17 let userRequestToken: string
18 let userOTPToken: string
19
20 // ---------------------------------------------------------------
21
22 before(async function () {
23 this.timeout(30000)
24
25 {
26 server = await createSingleServer(1)
27 await setAccessTokensToServers([ server ])
28 }
29
30 {
31 const result = await server.users.generate('user1')
32 userToken = result.token
33 userId = result.userId
34 userPassword = result.password
35 }
36
37 {
38 const { id } = await server.users.getMyInfo()
39 rootId = id
40 rootPassword = server.store.user.password
41 }
42 })
43
44 describe('When requesting two factor', function () {
45
46 it('Should fail with an unknown user id', async function () {
47 await server.twoFactor.request({ userId: 42, currentPassword: rootPassword, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
48 })
49
50 it('Should fail with an invalid user id', async function () {
51 await server.twoFactor.request({
52 userId: 'invalid' as any,
53 currentPassword: rootPassword,
54 expectedStatus: HttpStatusCode.BAD_REQUEST_400
55 })
56 })
57
58 it('Should fail to request another user two factor without the appropriate rights', async function () {
59 await server.twoFactor.request({
60 userId: rootId,
61 token: userToken,
62 currentPassword: userPassword,
63 expectedStatus: HttpStatusCode.FORBIDDEN_403
64 })
65 })
66
67 it('Should succeed to request another user two factor with the appropriate rights', async function () {
68 await server.twoFactor.request({ userId, currentPassword: rootPassword })
69 })
70
71 it('Should fail to request two factor without a password', async function () {
72 await server.twoFactor.request({
73 userId,
74 token: userToken,
75 currentPassword: undefined,
76 expectedStatus: HttpStatusCode.BAD_REQUEST_400
77 })
78 })
79
80 it('Should fail to request two factor with an incorrect password', async function () {
81 await server.twoFactor.request({
82 userId,
83 token: userToken,
84 currentPassword: rootPassword,
85 expectedStatus: HttpStatusCode.FORBIDDEN_403
86 })
87 })
88
89 it('Should succeed to request two factor without a password when targeting a remote user with an admin account', async function () {
90 await server.twoFactor.request({ userId })
91 })
92
93 it('Should fail to request two factor without a password when targeting myself with an admin account', async function () {
94 await server.twoFactor.request({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
95 await server.twoFactor.request({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
96 })
97
98 it('Should succeed to request my two factor auth', async function () {
99 {
100 const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
101 userRequestToken = otpRequest.requestToken
102 userOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
103 }
104
105 {
106 const { otpRequest } = await server.twoFactor.request({ userId: rootId, currentPassword: rootPassword })
107 rootRequestToken = otpRequest.requestToken
108 rootOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
109 }
110 })
111 })
112
113 describe('When confirming two factor request', function () {
114
115 it('Should fail with an unknown user id', async function () {
116 await server.twoFactor.confirmRequest({
117 userId: 42,
118 requestToken: rootRequestToken,
119 otpToken: rootOTPToken,
120 expectedStatus: HttpStatusCode.NOT_FOUND_404
121 })
122 })
123
124 it('Should fail with an invalid user id', async function () {
125 await server.twoFactor.confirmRequest({
126 userId: 'invalid' as any,
127 requestToken: rootRequestToken,
128 otpToken: rootOTPToken,
129 expectedStatus: HttpStatusCode.BAD_REQUEST_400
130 })
131 })
132
133 it('Should fail to confirm another user two factor request without the appropriate rights', async function () {
134 await server.twoFactor.confirmRequest({
135 userId: rootId,
136 token: userToken,
137 requestToken: rootRequestToken,
138 otpToken: rootOTPToken,
139 expectedStatus: HttpStatusCode.FORBIDDEN_403
140 })
141 })
142
143 it('Should fail without request token', async function () {
144 await server.twoFactor.confirmRequest({
145 userId,
146 requestToken: undefined,
147 otpToken: userOTPToken,
148 expectedStatus: HttpStatusCode.BAD_REQUEST_400
149 })
150 })
151
152 it('Should fail with an invalid request token', async function () {
153 await server.twoFactor.confirmRequest({
154 userId,
155 requestToken: 'toto',
156 otpToken: userOTPToken,
157 expectedStatus: HttpStatusCode.FORBIDDEN_403
158 })
159 })
160
161 it('Should fail with request token of another user', async function () {
162 await server.twoFactor.confirmRequest({
163 userId,
164 requestToken: rootRequestToken,
165 otpToken: userOTPToken,
166 expectedStatus: HttpStatusCode.FORBIDDEN_403
167 })
168 })
169
170 it('Should fail without an otp token', async function () {
171 await server.twoFactor.confirmRequest({
172 userId,
173 requestToken: userRequestToken,
174 otpToken: undefined,
175 expectedStatus: HttpStatusCode.BAD_REQUEST_400
176 })
177 })
178
179 it('Should fail with a bad otp token', async function () {
180 await server.twoFactor.confirmRequest({
181 userId,
182 requestToken: userRequestToken,
183 otpToken: '123456',
184 expectedStatus: HttpStatusCode.FORBIDDEN_403
185 })
186 })
187
188 it('Should succeed to confirm another user two factor request with the appropriate rights', async function () {
189 await server.twoFactor.confirmRequest({
190 userId,
191 requestToken: userRequestToken,
192 otpToken: userOTPToken
193 })
194
195 // Reinit
196 await server.twoFactor.disable({ userId, currentPassword: rootPassword })
197 })
198
199 it('Should succeed to confirm my two factor request', async function () {
200 await server.twoFactor.confirmRequest({
201 userId,
202 token: userToken,
203 requestToken: userRequestToken,
204 otpToken: userOTPToken
205 })
206 })
207
208 it('Should fail to confirm again two factor request', async function () {
209 await server.twoFactor.confirmRequest({
210 userId,
211 token: userToken,
212 requestToken: userRequestToken,
213 otpToken: userOTPToken,
214 expectedStatus: HttpStatusCode.BAD_REQUEST_400
215 })
216 })
217 })
218
219 describe('When disabling two factor', function () {
220
221 it('Should fail with an unknown user id', async function () {
222 await server.twoFactor.disable({
223 userId: 42,
224 currentPassword: rootPassword,
225 expectedStatus: HttpStatusCode.NOT_FOUND_404
226 })
227 })
228
229 it('Should fail with an invalid user id', async function () {
230 await server.twoFactor.disable({
231 userId: 'invalid' as any,
232 currentPassword: rootPassword,
233 expectedStatus: HttpStatusCode.BAD_REQUEST_400
234 })
235 })
236
237 it('Should fail to disable another user two factor without the appropriate rights', async function () {
238 await server.twoFactor.disable({
239 userId: rootId,
240 token: userToken,
241 currentPassword: userPassword,
242 expectedStatus: HttpStatusCode.FORBIDDEN_403
243 })
244 })
245
246 it('Should fail to disable two factor with an incorrect password', async function () {
247 await server.twoFactor.disable({
248 userId,
249 token: userToken,
250 currentPassword: rootPassword,
251 expectedStatus: HttpStatusCode.FORBIDDEN_403
252 })
253 })
254
255 it('Should succeed to disable two factor without a password when targeting a remote user with an admin account', async function () {
256 await server.twoFactor.disable({ userId })
257 await server.twoFactor.requestAndConfirm({ userId })
258 })
259
260 it('Should fail to disable two factor without a password when targeting myself with an admin account', async function () {
261 await server.twoFactor.disable({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
262 await server.twoFactor.disable({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
263 })
264
265 it('Should succeed to disable another user two factor with the appropriate rights', async function () {
266 await server.twoFactor.disable({ userId, currentPassword: rootPassword })
267
268 await server.twoFactor.requestAndConfirm({ userId })
269 })
270
271 it('Should succeed to update my two factor auth', async function () {
272 await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
273 })
274
275 it('Should fail to disable again two factor', async function () {
276 await server.twoFactor.disable({
277 userId,
278 token: userToken,
279 currentPassword: userPassword,
280 expectedStatus: HttpStatusCode.BAD_REQUEST_400
281 })
282 })
283 })
284
285 after(async function () {
286 await cleanupTests([ server ])
287 })
288})
diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts
index c65152c6f..643f1a531 100644
--- a/server/tests/api/users/index.ts
+++ b/server/tests/api/users/index.ts
@@ -1,3 +1,4 @@
1import './two-factor'
1import './user-subscriptions' 2import './user-subscriptions'
2import './user-videos' 3import './user-videos'
3import './users' 4import './users'
diff --git a/server/tests/api/users/two-factor.ts b/server/tests/api/users/two-factor.ts
new file mode 100644
index 000000000..0dcab9e17
--- /dev/null
+++ b/server/tests/api/users/two-factor.ts
@@ -0,0 +1,200 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { expectStartWith } from '@server/tests/shared'
5import { HttpStatusCode } from '@shared/models'
6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
7
8async function login (options: {
9 server: PeerTubeServer
10 username: string
11 password: string
12 otpToken?: string
13 expectedStatus?: HttpStatusCode
14}) {
15 const { server, username, password, otpToken, expectedStatus } = options
16
17 const user = { username, password }
18 const { res, body: { access_token: token } } = await server.login.loginAndGetResponse({ user, otpToken, expectedStatus })
19
20 return { res, token }
21}
22
23describe('Test users', function () {
24 let server: PeerTubeServer
25 let otpSecret: string
26 let requestToken: string
27
28 const userUsername = 'user1'
29 let userId: number
30 let userPassword: string
31 let userToken: string
32
33 before(async function () {
34 this.timeout(30000)
35
36 server = await createSingleServer(1)
37
38 await setAccessTokensToServers([ server ])
39 const res = await server.users.generate(userUsername)
40 userId = res.userId
41 userPassword = res.password
42 userToken = res.token
43 })
44
45 it('Should not add the header on login if two factor is not enabled', async function () {
46 const { res, token } = await login({ server, username: userUsername, password: userPassword })
47
48 expect(res.header['x-peertube-otp']).to.not.exist
49
50 await server.users.getMyInfo({ token })
51 })
52
53 it('Should request two factor and get the secret and uri', async function () {
54 const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
55
56 expect(otpRequest.requestToken).to.exist
57
58 expect(otpRequest.secret).to.exist
59 expect(otpRequest.secret).to.have.lengthOf(32)
60
61 expect(otpRequest.uri).to.exist
62 expectStartWith(otpRequest.uri, 'otpauth://')
63 expect(otpRequest.uri).to.include(otpRequest.secret)
64
65 requestToken = otpRequest.requestToken
66 otpSecret = otpRequest.secret
67 })
68
69 it('Should not have two factor confirmed yet', async function () {
70 const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
71 expect(twoFactorEnabled).to.be.false
72 })
73
74 it('Should confirm two factor', async function () {
75 await server.twoFactor.confirmRequest({
76 userId,
77 token: userToken,
78 otpToken: TwoFactorCommand.buildOTP({ secret: otpSecret }).generate(),
79 requestToken
80 })
81 })
82
83 it('Should not add the header on login if two factor is enabled and password is incorrect', async function () {
84 const { res, token } = await login({ server, username: userUsername, password: 'fake', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
85
86 expect(res.header['x-peertube-otp']).to.not.exist
87 expect(token).to.not.exist
88 })
89
90 it('Should add the header on login if two factor is enabled and password is correct', async function () {
91 const { res, token } = await login({
92 server,
93 username: userUsername,
94 password: userPassword,
95 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
96 })
97
98 expect(res.header['x-peertube-otp']).to.exist
99 expect(token).to.not.exist
100
101 await server.users.getMyInfo({ token })
102 })
103
104 it('Should not login with correct password and incorrect otp secret', async function () {
105 const otp = TwoFactorCommand.buildOTP({ secret: 'a'.repeat(32) })
106
107 const { res, token } = await login({
108 server,
109 username: userUsername,
110 password: userPassword,
111 otpToken: otp.generate(),
112 expectedStatus: HttpStatusCode.BAD_REQUEST_400
113 })
114
115 expect(res.header['x-peertube-otp']).to.not.exist
116 expect(token).to.not.exist
117 })
118
119 it('Should not login with correct password and incorrect otp code', async function () {
120 const { res, token } = await login({
121 server,
122 username: userUsername,
123 password: userPassword,
124 otpToken: '123456',
125 expectedStatus: HttpStatusCode.BAD_REQUEST_400
126 })
127
128 expect(res.header['x-peertube-otp']).to.not.exist
129 expect(token).to.not.exist
130 })
131
132 it('Should not login with incorrect password and correct otp code', async function () {
133 const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
134
135 const { res, token } = await login({
136 server,
137 username: userUsername,
138 password: 'fake',
139 otpToken,
140 expectedStatus: HttpStatusCode.BAD_REQUEST_400
141 })
142
143 expect(res.header['x-peertube-otp']).to.not.exist
144 expect(token).to.not.exist
145 })
146
147 it('Should correctly login with correct password and otp code', async function () {
148 const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
149
150 const { res, token } = await login({ server, username: userUsername, password: userPassword, otpToken })
151
152 expect(res.header['x-peertube-otp']).to.not.exist
153 expect(token).to.exist
154
155 await server.users.getMyInfo({ token })
156 })
157
158 it('Should have two factor enabled when getting my info', async function () {
159 const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
160 expect(twoFactorEnabled).to.be.true
161 })
162
163 it('Should disable two factor and be able to login without otp token', async function () {
164 await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
165
166 const { res, token } = await login({ server, username: userUsername, password: userPassword })
167 expect(res.header['x-peertube-otp']).to.not.exist
168
169 await server.users.getMyInfo({ token })
170 })
171
172 it('Should have two factor disabled when getting my info', async function () {
173 const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
174 expect(twoFactorEnabled).to.be.false
175 })
176
177 it('Should enable two factor auth without password from an admin', async function () {
178 const { otpRequest } = await server.twoFactor.request({ userId })
179
180 await server.twoFactor.confirmRequest({
181 userId,
182 otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate(),
183 requestToken: otpRequest.requestToken
184 })
185
186 const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
187 expect(twoFactorEnabled).to.be.true
188 })
189
190 it('Should disable two factor auth without password from an admin', async function () {
191 await server.twoFactor.disable({ userId })
192
193 const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
194 expect(twoFactorEnabled).to.be.false
195 })
196
197 after(async function () {
198 await cleanupTests([ server ])
199 })
200})
diff --git a/server/tests/helpers/crypto.ts b/server/tests/helpers/crypto.ts
new file mode 100644
index 000000000..b508c715b
--- /dev/null
+++ b/server/tests/helpers/crypto.ts
@@ -0,0 +1,33 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { decrypt, encrypt } from '@server/helpers/peertube-crypto'
5
6describe('Encrypt/Descrypt', function () {
7
8 it('Should encrypt and decrypt the string', async function () {
9 const secret = 'my_secret'
10 const str = 'my super string'
11
12 const encrypted = await encrypt(str, secret)
13 const decrypted = await decrypt(encrypted, secret)
14
15 expect(str).to.equal(decrypted)
16 })
17
18 it('Should not decrypt without the same secret', async function () {
19 const str = 'my super string'
20
21 const encrypted = await encrypt(str, 'my_secret')
22
23 let error = false
24
25 try {
26 await decrypt(encrypted, 'my_sicret')
27 } catch (err) {
28 error = true
29 }
30
31 expect(error).to.be.true
32 })
33})
diff --git a/server/tests/helpers/index.ts b/server/tests/helpers/index.ts
index 951208842..42d644c40 100644
--- a/server/tests/helpers/index.ts
+++ b/server/tests/helpers/index.ts
@@ -1,6 +1,7 @@
1import './image' 1import './crypto'
2import './core-utils' 2import './core-utils'
3import './dns' 3import './dns'
4import './dns'
4import './comment-model' 5import './comment-model'
5import './markdown' 6import './markdown'
6import './request' 7import './request'
diff --git a/shared/models/users/index.ts b/shared/models/users/index.ts
index b25978587..32f7a441c 100644
--- a/shared/models/users/index.ts
+++ b/shared/models/users/index.ts
@@ -1,3 +1,4 @@
1export * from './two-factor-enable-result.model'
1export * from './user-create-result.model' 2export * from './user-create-result.model'
2export * from './user-create.model' 3export * from './user-create.model'
3export * from './user-flag.model' 4export * from './user-flag.model'
diff --git a/shared/models/users/two-factor-enable-result.model.ts b/shared/models/users/two-factor-enable-result.model.ts
new file mode 100644
index 000000000..1fc801f0a
--- /dev/null
+++ b/shared/models/users/two-factor-enable-result.model.ts
@@ -0,0 +1,7 @@
1export interface TwoFactorEnableResult {
2 otpRequest: {
3 requestToken: string
4 secret: string
5 uri: string
6 }
7}
diff --git a/shared/models/users/user.model.ts b/shared/models/users/user.model.ts
index 63c5c8a92..7b6494ff8 100644
--- a/shared/models/users/user.model.ts
+++ b/shared/models/users/user.model.ts
@@ -62,6 +62,8 @@ export interface User {
62 pluginAuth: string | null 62 pluginAuth: string | null
63 63
64 lastLoginDate: Date | null 64 lastLoginDate: Date | null
65
66 twoFactorEnabled: boolean
65} 67}
66 68
67export interface MyUserSpecialPlaylist { 69export interface MyUserSpecialPlaylist {
diff --git a/shared/server-commands/server/server.ts b/shared/server-commands/server/server.ts
index a8f8c1d84..7096faf21 100644
--- a/shared/server-commands/server/server.ts
+++ b/shared/server-commands/server/server.ts
@@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation'
13import { OverviewsCommand } from '../overviews' 13import { OverviewsCommand } from '../overviews'
14import { SearchCommand } from '../search' 14import { SearchCommand } from '../search'
15import { SocketIOCommand } from '../socket' 15import { SocketIOCommand } from '../socket'
16import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users' 16import {
17 AccountsCommand,
18 BlocklistCommand,
19 LoginCommand,
20 NotificationsCommand,
21 SubscriptionsCommand,
22 TwoFactorCommand,
23 UsersCommand
24} from '../users'
17import { 25import {
18 BlacklistCommand, 26 BlacklistCommand,
19 CaptionsCommand, 27 CaptionsCommand,
@@ -136,6 +144,7 @@ export class PeerTubeServer {
136 videos?: VideosCommand 144 videos?: VideosCommand
137 videoStats?: VideoStatsCommand 145 videoStats?: VideoStatsCommand
138 views?: ViewsCommand 146 views?: ViewsCommand
147 twoFactor?: TwoFactorCommand
139 148
140 constructor (options: { serverNumber: number } | { url: string }) { 149 constructor (options: { serverNumber: number } | { url: string }) {
141 if ((options as any).url) { 150 if ((options as any).url) {
@@ -417,5 +426,6 @@ export class PeerTubeServer {
417 this.videoStudio = new VideoStudioCommand(this) 426 this.videoStudio = new VideoStudioCommand(this)
418 this.videoStats = new VideoStatsCommand(this) 427 this.videoStats = new VideoStatsCommand(this)
419 this.views = new ViewsCommand(this) 428 this.views = new ViewsCommand(this)
429 this.twoFactor = new TwoFactorCommand(this)
420 } 430 }
421} 431}
diff --git a/shared/server-commands/users/index.ts b/shared/server-commands/users/index.ts
index f6f93b4d2..1afc02dc1 100644
--- a/shared/server-commands/users/index.ts
+++ b/shared/server-commands/users/index.ts
@@ -5,4 +5,5 @@ export * from './login'
5export * from './login-command' 5export * from './login-command'
6export * from './notifications-command' 6export * from './notifications-command'
7export * from './subscriptions-command' 7export * from './subscriptions-command'
8export * from './two-factor-command'
8export * from './users-command' 9export * from './users-command'
diff --git a/shared/server-commands/users/login-command.ts b/shared/server-commands/users/login-command.ts
index 54070e426..f2fc6d1c5 100644
--- a/shared/server-commands/users/login-command.ts
+++ b/shared/server-commands/users/login-command.ts
@@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
2import { unwrapBody } from '../requests' 2import { unwrapBody } from '../requests'
3import { AbstractCommand, OverrideCommandOptions } from '../shared' 3import { AbstractCommand, OverrideCommandOptions } from '../shared'
4 4
5type LoginOptions = OverrideCommandOptions & {
6 client?: { id?: string, secret?: string }
7 user?: { username: string, password?: string }
8 otpToken?: string
9}
10
5export class LoginCommand extends AbstractCommand { 11export class LoginCommand extends AbstractCommand {
6 12
7 login (options: OverrideCommandOptions & { 13 async login (options: LoginOptions = {}) {
8 client?: { id?: string, secret?: string } 14 const res = await this._login(options)
9 user?: { username: string, password?: string }
10 } = {}) {
11 const { client = this.server.store.client, user = this.server.store.user } = options
12 const path = '/api/v1/users/token'
13 15
14 const body = { 16 return this.unwrapLoginBody(res.body)
15 client_id: client.id, 17 }
16 client_secret: client.secret,
17 username: user.username,
18 password: user.password ?? 'password',
19 response_type: 'code',
20 grant_type: 'password',
21 scope: 'upload'
22 }
23 18
24 return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({ 19 async loginAndGetResponse (options: LoginOptions = {}) {
25 ...options, 20 const res = await this._login(options)
26 21
27 path, 22 return {
28 requestType: 'form', 23 res,
29 fields: body, 24 body: this.unwrapLoginBody(res.body)
30 implicitToken: false, 25 }
31 defaultExpectedStatus: HttpStatusCode.OK_200
32 }))
33 } 26 }
34 27
35 getAccessToken (arg1?: { username: string, password?: string }): Promise<string> 28 getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
@@ -129,4 +122,38 @@ export class LoginCommand extends AbstractCommand {
129 defaultExpectedStatus: HttpStatusCode.OK_200 122 defaultExpectedStatus: HttpStatusCode.OK_200
130 }) 123 })
131 } 124 }
125
126 private _login (options: LoginOptions) {
127 const { client = this.server.store.client, user = this.server.store.user, otpToken } = options
128 const path = '/api/v1/users/token'
129
130 const body = {
131 client_id: client.id,
132 client_secret: client.secret,
133 username: user.username,
134 password: user.password ?? 'password',
135 response_type: 'code',
136 grant_type: 'password',
137 scope: 'upload'
138 }
139
140 const headers = otpToken
141 ? { 'x-peertube-otp': otpToken }
142 : {}
143
144 return this.postBodyRequest({
145 ...options,
146
147 path,
148 headers,
149 requestType: 'form',
150 fields: body,
151 implicitToken: false,
152 defaultExpectedStatus: HttpStatusCode.OK_200
153 })
154 }
155
156 private unwrapLoginBody (body: any) {
157 return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument
158 }
132} 159}
diff --git a/shared/server-commands/users/two-factor-command.ts b/shared/server-commands/users/two-factor-command.ts
new file mode 100644
index 000000000..5542acfda
--- /dev/null
+++ b/shared/server-commands/users/two-factor-command.ts
@@ -0,0 +1,92 @@
1import { TOTP } from 'otpauth'
2import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
3import { unwrapBody } from '../requests'
4import { AbstractCommand, OverrideCommandOptions } from '../shared'
5
6export class TwoFactorCommand extends AbstractCommand {
7
8 static buildOTP (options: {
9 secret: string
10 }) {
11 const { secret } = options
12
13 return new TOTP({
14 issuer: 'PeerTube',
15 algorithm: 'SHA1',
16 digits: 6,
17 period: 30,
18 secret
19 })
20 }
21
22 request (options: OverrideCommandOptions & {
23 userId: number
24 currentPassword?: string
25 }) {
26 const { currentPassword, userId } = options
27
28 const path = '/api/v1/users/' + userId + '/two-factor/request'
29
30 return unwrapBody<TwoFactorEnableResult>(this.postBodyRequest({
31 ...options,
32
33 path,
34 fields: { currentPassword },
35 implicitToken: true,
36 defaultExpectedStatus: HttpStatusCode.OK_200
37 }))
38 }
39
40 confirmRequest (options: OverrideCommandOptions & {
41 userId: number
42 requestToken: string
43 otpToken: string
44 }) {
45 const { userId, requestToken, otpToken } = options
46
47 const path = '/api/v1/users/' + userId + '/two-factor/confirm-request'
48
49 return this.postBodyRequest({
50 ...options,
51
52 path,
53 fields: { requestToken, otpToken },
54 implicitToken: true,
55 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
56 })
57 }
58
59 disable (options: OverrideCommandOptions & {
60 userId: number
61 currentPassword?: string
62 }) {
63 const { userId, currentPassword } = options
64 const path = '/api/v1/users/' + userId + '/two-factor/disable'
65
66 return this.postBodyRequest({
67 ...options,
68
69 path,
70 fields: { currentPassword },
71 implicitToken: true,
72 defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
73 })
74 }
75
76 async requestAndConfirm (options: OverrideCommandOptions & {
77 userId: number
78 currentPassword?: string
79 }) {
80 const { userId, currentPassword } = options
81
82 const { otpRequest } = await this.request({ userId, currentPassword })
83
84 await this.confirmRequest({
85 userId,
86 requestToken: otpRequest.requestToken,
87 otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
88 })
89
90 return otpRequest
91 }
92}
diff --git a/shared/server-commands/users/users-command.ts b/shared/server-commands/users/users-command.ts
index e7d021059..811b9685b 100644
--- a/shared/server-commands/users/users-command.ts
+++ b/shared/server-commands/users/users-command.ts
@@ -202,7 +202,8 @@ export class UsersCommand extends AbstractCommand {
202 token, 202 token,
203 userId: user.id, 203 userId: user.id,
204 userChannelId: me.videoChannels[0].id, 204 userChannelId: me.videoChannels[0].id,
205 userChannelName: me.videoChannels[0].name 205 userChannelName: me.videoChannels[0].name,
206 password
206 } 207 }
207 } 208 }
208 209
diff --git a/support/doc/api/openapi.yaml b/support/doc/api/openapi.yaml
index c62310b76..2fb154dbd 100644
--- a/support/doc/api/openapi.yaml
+++ b/support/doc/api/openapi.yaml
@@ -1126,6 +1126,97 @@ paths:
1126 '404': 1126 '404':
1127 description: user not found 1127 description: user not found
1128 1128
1129 /users/{id}/two-factor/request:
1130 post:
1131 summary: Request two factor auth
1132 operationId: requestTwoFactor
1133 description: Request two factor authentication for a user
1134 tags:
1135 - Users
1136 parameters:
1137 - $ref: '#/components/parameters/id'
1138 requestBody:
1139 content:
1140 application/json:
1141 schema:
1142 type: object
1143 properties:
1144 currentPassword:
1145 type: string
1146 description: Password of the currently authenticated user
1147 responses:
1148 '200':
1149 description: successful operation
1150 content:
1151 application/json:
1152 schema:
1153 type: array
1154 items:
1155 $ref: '#/components/schemas/RequestTwoFactorResponse'
1156 '403':
1157 description: invalid password
1158 '404':
1159 description: user not found
1160
1161 /users/{id}/two-factor/confirm-request:
1162 post:
1163 summary: Confirm two factor auth
1164 operationId: confirmTwoFactorRequest
1165 description: Confirm a two factor authentication request
1166 tags:
1167 - Users
1168 parameters:
1169 - $ref: '#/components/parameters/id'
1170 requestBody:
1171 content:
1172 application/json:
1173 schema:
1174 type: object
1175 properties:
1176 requestToken:
1177 type: string
1178 description: Token to identify the two factor request
1179 otpToken:
1180 type: string
1181 description: OTP token generated by the app
1182 required:
1183 - requestToken
1184 - otpToken
1185 responses:
1186 '204':
1187 description: successful operation
1188 '403':
1189 description: invalid request token or OTP token
1190 '404':
1191 description: user not found
1192
1193 /users/{id}/two-factor/disable:
1194 post:
1195 summary: Disable two factor auth
1196 operationId: disableTwoFactor
1197 description: Disable two factor authentication of a user
1198 tags:
1199 - Users
1200 parameters:
1201 - $ref: '#/components/parameters/id'
1202 requestBody:
1203 content:
1204 application/json:
1205 schema:
1206 type: object
1207 properties:
1208 currentPassword:
1209 type: string
1210 description: Password of the currently authenticated user
1211 responses:
1212 '204':
1213 description: successful operation
1214 '403':
1215 description: invalid password
1216 '404':
1217 description: user not found
1218
1219
1129 /users/ask-send-verify-email: 1220 /users/ask-send-verify-email:
1130 post: 1221 post:
1131 summary: Resend user verification link 1222 summary: Resend user verification link
@@ -8146,6 +8237,21 @@ components:
8146 description: User can select live latency mode if enabled by the instance 8237 description: User can select live latency mode if enabled by the instance
8147 $ref: '#/components/schemas/LiveVideoLatencyMode' 8238 $ref: '#/components/schemas/LiveVideoLatencyMode'
8148 8239
8240 RequestTwoFactorResponse:
8241 properties:
8242 otpRequest:
8243 type: object
8244 properties:
8245 requestToken:
8246 type: string
8247 description: The token to send to confirm this request
8248 secret:
8249 type: string
8250 description: The OTP secret
8251 uri:
8252 type: string
8253 description: The OTP URI
8254
8149 VideoStudioCreateTask: 8255 VideoStudioCreateTask:
8150 type: array 8256 type: array
8151 items: 8257 items:
diff --git a/support/doc/docker.md b/support/doc/docker.md
index 97eecc3ad..267863a4d 100644
--- a/support/doc/docker.md
+++ b/support/doc/docker.md
@@ -49,6 +49,7 @@ In the downloaded example [.env](https://github.com/Chocobozzz/PeerTube/blob/mas
49- `<MY POSTGRES PASSWORD>` 49- `<MY POSTGRES PASSWORD>`
50- `<MY DOMAIN>` without 'https://' 50- `<MY DOMAIN>` without 'https://'
51- `<MY EMAIL ADDRESS>` 51- `<MY EMAIL ADDRESS>`
52- `<MY PEERTUBE SECRET>`
52 53
53Other environment variables are used in 54Other environment variables are used in
54[/support/docker/production/config/custom-environment-variables.yaml](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/custom-environment-variables.yaml) and can be 55[/support/docker/production/config/custom-environment-variables.yaml](https://github.com/Chocobozzz/PeerTube/blob/master/support/docker/production/config/custom-environment-variables.yaml) and can be
diff --git a/support/doc/production.md b/support/doc/production.md
index 64ddd9e48..b400ac451 100644
--- a/support/doc/production.md
+++ b/support/doc/production.md
@@ -115,8 +115,14 @@ $ cd /var/www/peertube
115$ sudo -u peertube cp peertube-latest/config/production.yaml.example config/production.yaml 115$ sudo -u peertube cp peertube-latest/config/production.yaml.example config/production.yaml
116``` 116```
117 117
118Then edit the `config/production.yaml` file according to your webserver 118Then edit the `config/production.yaml` file according to your webserver and database configuration. In particular:
119and database configuration (`webserver`, `database`, `redis`, `smtp` and `admin.email` sections in particular). 119 * `webserver`: Reverse proxy public information
120 * `secrets`: Secret strings you must generate manually (PeerTube version >= 5.0)
121 * `database`: PostgreSQL settings
122 * `redis`: Redis settings
123 * `smtp`: If you want to use emails
124 * `admin.email`: To correctly fill `root` user email
125
120Keys defined in `config/production.yaml` will override keys defined in `config/default.yaml`. 126Keys defined in `config/production.yaml` will override keys defined in `config/default.yaml`.
121 127
122**PeerTube does not support webserver host change**. Even though [PeerTube CLI can help you to switch hostname](https://docs.joinpeertube.org/maintain-tools?id=update-hostjs) there's no official support for that since it is a risky operation that might result in unforeseen errors. 128**PeerTube does not support webserver host change**. Even though [PeerTube CLI can help you to switch hostname](https://docs.joinpeertube.org/maintain-tools?id=update-hostjs) there's no official support for that since it is a risky operation that might result in unforeseen errors.
diff --git a/support/docker/production/.env b/support/docker/production/.env
index 4e7b21ab6..b4e356a58 100644
--- a/support/docker/production/.env
+++ b/support/docker/production/.env
@@ -22,6 +22,9 @@ PEERTUBE_WEBSERVER_HOSTNAME=<MY DOMAIN>
22# pass them as a comma separated array: 22# pass them as a comma separated array:
23PEERTUBE_TRUST_PROXY=["127.0.0.1", "loopback", "172.18.0.0/16"] 23PEERTUBE_TRUST_PROXY=["127.0.0.1", "loopback", "172.18.0.0/16"]
24 24
25# Generate one using `openssl rand -hex 32`
26PEERTUBE_SECRET=<MY PEERTUBE SECRET>
27
25# E-mail configuration 28# E-mail configuration
26# If you use a Custom SMTP server 29# If you use a Custom SMTP server
27#PEERTUBE_SMTP_USERNAME= 30#PEERTUBE_SMTP_USERNAME=
diff --git a/support/docker/production/config/custom-environment-variables.yaml b/support/docker/production/config/custom-environment-variables.yaml
index 9c84428b7..1d889fe7d 100644
--- a/support/docker/production/config/custom-environment-variables.yaml
+++ b/support/docker/production/config/custom-environment-variables.yaml
@@ -7,6 +7,9 @@ webserver:
7 __name: "PEERTUBE_WEBSERVER_HTTPS" 7 __name: "PEERTUBE_WEBSERVER_HTTPS"
8 __format: "json" 8 __format: "json"
9 9
10secrets:
11 peertube: "PEERTUBE_SECRET"
12
10trust_proxy: 13trust_proxy:
11 __name: "PEERTUBE_TRUST_PROXY" 14 __name: "PEERTUBE_TRUST_PROXY"
12 __format: "json" 15 __format: "json"
diff --git a/yarn.lock b/yarn.lock
index 60fe262fa..8ccc4fd0d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5945,6 +5945,11 @@ jsprim@^1.2.2:
5945 json-schema "0.4.0" 5945 json-schema "0.4.0"
5946 verror "1.10.0" 5946 verror "1.10.0"
5947 5947
5948jssha@~3.2.0:
5949 version "3.2.0"
5950 resolved "https://registry.yarnpkg.com/jssha/-/jssha-3.2.0.tgz#88ec50b866dd1411deaddbe6b3e3692e4c710f16"
5951 integrity sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==
5952
5948jstransformer@1.0.0: 5953jstransformer@1.0.0:
5949 version "1.0.0" 5954 version "1.0.0"
5950 resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3" 5955 resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
@@ -7007,6 +7012,13 @@ os-tmpdir@~1.0.2:
7007 resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" 7012 resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
7008 integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== 7013 integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
7009 7014
7015otpauth@^8.0.3:
7016 version "8.0.3"
7017 resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-8.0.3.tgz#fdbcb24503e93dd7d930a8651f2dc9f8f7ff9c1b"
7018 integrity sha512-5abBweT/POpMdVuM0Zk/tvlTHw8Kc8606XX/w8QNLRBDib+FVpseAx12Z21/iVIeCrJOgCY1dBuLS057IOdybw==
7019 dependencies:
7020 jssha "~3.2.0"
7021
7010p-cancelable@^2.0.0: 7022p-cancelable@^2.0.0:
7011 version "2.1.1" 7023 version "2.1.1"
7012 resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" 7024 resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"