]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/commitdiff
Merge branch 'feature/otp' into develop
authorChocobozzz <me@florianbigard.com>
Mon, 10 Oct 2022 09:19:58 +0000 (11:19 +0200)
committerChocobozzz <me@florianbigard.com>
Mon, 10 Oct 2022 09:19:58 +0000 (11:19 +0200)
128 files changed:
client/src/app/+about/about-instance/contact-admin-modal.component.ts
client/src/app/+admin/config/edit-custom-config/edit-custom-config.component.ts
client/src/app/+admin/follows/following-list/follow-modal.component.ts
client/src/app/+admin/overview/users/user-edit/user-create.component.ts
client/src/app/+admin/overview/users/user-edit/user-edit.component.html
client/src/app/+admin/overview/users/user-edit/user-edit.component.scss
client/src/app/+admin/overview/users/user-edit/user-edit.ts
client/src/app/+admin/overview/users/user-edit/user-password.component.ts
client/src/app/+admin/overview/users/user-edit/user-update.component.ts
client/src/app/+admin/overview/users/user-list/user-list.component.scss
client/src/app/+admin/plugins/plugin-show-installed/plugin-show-installed.component.ts
client/src/app/+login/login.component.html
client/src/app/+login/login.component.scss
client/src/app/+login/login.component.ts
client/src/app/+manage/video-channel-edit/video-channel-create.component.ts
client/src/app/+manage/video-channel-edit/video-channel-update.component.ts
client/src/app/+my-account/my-account-routing.module.ts
client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
client/src/app/+my-account/my-account-settings/my-account-profile/my-account-profile.component.ts
client/src/app/+my-account/my-account-settings/my-account-settings.component.html
client/src/app/+my-account/my-account-settings/my-account-settings.component.scss
client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss [new file with mode: 0644]
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts [new file with mode: 0644]
client/src/app/+my-account/my-account.module.ts
client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
client/src/app/+my-library/my-ownership/my-accept-ownership/my-accept-ownership.component.ts
client/src/app/+my-library/my-video-channel-syncs/video-channel-sync-edit/video-channel-sync-edit.component.ts
client/src/app/+my-library/my-video-playlists/my-video-playlist-create.component.ts
client/src/app/+my-library/my-video-playlists/my-video-playlist-update.component.ts
client/src/app/+my-library/my-videos/modals/video-change-ownership.component.ts
client/src/app/+reset-password/reset-password.component.ts
client/src/app/+signup/+register/register.component.ts
client/src/app/+signup/+register/steps/register-step-channel.component.ts
client/src/app/+signup/+register/steps/register-step-terms.component.ts
client/src/app/+signup/+register/steps/register-step-user.component.ts
client/src/app/+signup/+verify-account/verify-account-ask-send-email/verify-account-ask-send-email.component.ts
client/src/app/+video-studio/edit/video-studio-edit.component.ts
client/src/app/+videos/+video-edit/shared/video-caption-add-modal.component.ts
client/src/app/+videos/+video-edit/shared/video-caption-edit-modal-content/video-caption-edit-modal-content.component.ts
client/src/app/+videos/+video-edit/video-add-components/video-go-live.component.ts
client/src/app/+videos/+video-edit/video-add-components/video-import-torrent.component.ts
client/src/app/+videos/+video-edit/video-add-components/video-import-url.component.ts
client/src/app/+videos/+video-edit/video-add-components/video-upload.component.ts
client/src/app/+videos/+video-edit/video-update.component.ts
client/src/app/+videos/+video-watch/shared/comment/video-comment-add.component.ts
client/src/app/core/auth/auth.service.ts
client/src/app/core/confirm/confirm.service.ts
client/src/app/core/rest/rest-extractor.service.ts
client/src/app/core/users/user.model.ts
client/src/app/modal/confirm.component.html
client/src/app/modal/confirm.component.ts
client/src/app/shared/form-validators/user-validators.ts
client/src/app/shared/shared-abuse-list/abuse-message-modal.component.ts
client/src/app/shared/shared-abuse-list/moderation-comment-modal.component.ts
client/src/app/shared/shared-forms/form-reactive.service.ts [new file with mode: 0644]
client/src/app/shared/shared-forms/form-reactive.ts
client/src/app/shared/shared-forms/form-validator.service.ts
client/src/app/shared/shared-forms/index.ts
client/src/app/shared/shared-forms/input-text.component.ts
client/src/app/shared/shared-forms/shared-form.module.ts
client/src/app/shared/shared-main/auth/auth-interceptor.service.ts
client/src/app/shared/shared-moderation/batch-domains-modal.component.ts
client/src/app/shared/shared-moderation/report-modals/account-report.component.ts
client/src/app/shared/shared-moderation/report-modals/comment-report.component.ts
client/src/app/shared/shared-moderation/report-modals/video-report.component.ts
client/src/app/shared/shared-moderation/user-ban-modal.component.ts
client/src/app/shared/shared-moderation/video-block.component.ts
client/src/app/shared/shared-user-settings/user-interface-settings.component.ts
client/src/app/shared/shared-user-settings/user-video-settings.component.ts
client/src/app/shared/shared-user-subscription/remote-subscribe.component.ts
client/src/app/shared/shared-users/index.ts
client/src/app/shared/shared-users/shared-users.module.ts
client/src/app/shared/shared-users/two-factor.service.ts [new file with mode: 0644]
client/src/app/shared/shared-video-playlist/video-add-to-playlist.component.ts
client/src/sass/bootstrap.scss
client/src/sass/include/_variables.scss
client/src/sass/ng-select.scss
client/src/sass/player/_player-variables.scss
config/default.yaml
config/dev.yaml
config/production.yaml.example
config/test.yaml
package.json
server.ts
server/controllers/api/users/index.ts
server/controllers/api/users/token.ts
server/controllers/api/users/two-factor.ts [new file with mode: 0644]
server/helpers/core-utils.ts
server/helpers/otp.ts [new file with mode: 0644]
server/helpers/peertube-crypto.ts
server/initializers/checker-after-init.ts
server/initializers/checker-before-init.ts
server/initializers/config.ts
server/initializers/constants.ts
server/initializers/migrations/0745-user-otp.ts [new file with mode: 0644]
server/lib/auth/oauth.ts
server/lib/redis.ts
server/middlewares/validators/shared/index.ts
server/middlewares/validators/shared/users.ts [new file with mode: 0644]
server/middlewares/validators/two-factor.ts [new file with mode: 0644]
server/middlewares/validators/users.ts
server/models/user/user.ts
server/tests/api/check-params/index.ts
server/tests/api/check-params/two-factor.ts [new file with mode: 0644]
server/tests/api/users/index.ts
server/tests/api/users/two-factor.ts [new file with mode: 0644]
server/tests/helpers/crypto.ts [new file with mode: 0644]
server/tests/helpers/index.ts
shared/models/users/index.ts
shared/models/users/two-factor-enable-result.model.ts [new file with mode: 0644]
shared/models/users/user.model.ts
shared/server-commands/server/server.ts
shared/server-commands/users/index.ts
shared/server-commands/users/login-command.ts
shared/server-commands/users/two-factor-command.ts [new file with mode: 0644]
shared/server-commands/users/users-command.ts
support/doc/api/openapi.yaml
support/doc/docker.md
support/doc/production.md
support/docker/production/.env
support/docker/production/config/custom-environment-variables.yaml
yarn.lock

index fab9cfc4b079d5fcedd16fbde4d458fae96d4d13..0e2bf51e82218da5e608923f0ce6068192557329 100644 (file)
@@ -7,7 +7,7 @@ import {
   FROM_NAME_VALIDATOR,
   SUBJECT_VALIDATOR
 } from '@app/shared/form-validators/instance-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { InstanceService } from '@app/shared/shared-instance'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -32,7 +32,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
   private serverConfig: HTMLServerConfig
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private router: Router,
     private modalService: NgbModal,
     private instanceService: InstanceService,
index 545e3785769c71acd382ad010243dee888a278c6..168f4702c1fad95581a432dc7dab7fb476d4137b 100644 (file)
@@ -18,15 +18,15 @@ import {
   MAX_INSTANCE_LIVES_VALIDATOR,
   MAX_LIVE_DURATION_VALIDATOR,
   MAX_USER_LIVES_VALIDATOR,
+  MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
   SEARCH_INDEX_URL_VALIDATOR,
   SERVICES_TWITTER_USERNAME_VALIDATOR,
   SIGNUP_LIMIT_VALIDATOR,
   SIGNUP_MINIMUM_AGE_VALIDATOR,
-  TRANSCODING_THREADS_VALIDATOR,
-  MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
+  TRANSCODING_THREADS_VALIDATOR
 } from '@app/shared/form-validators/custom-config-validators'
 import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { CustomPageService } from '@app/shared/shared-main/custom-page'
 import { CustomConfig, CustomPage, HTMLServerConfig } from '@shared/models'
 import { EditConfigurationService } from './edit-configuration.service'
@@ -52,9 +52,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
   categoryItems: SelectOptionsItem[] = []
 
   constructor (
+    protected formReactiveService: FormReactiveService,
     private router: Router,
     private route: ActivatedRoute,
-    protected formValidatorService: FormValidatorService,
     private notifier: Notifier,
     private configService: ConfigService,
     private customPage: CustomPageService,
index 07cc75d779864e4143091047391ebbfb55b8341d..8f74e82a66b05923491883115b831ea51d650760 100644 (file)
@@ -2,7 +2,7 @@ import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/cor
 import { Notifier } from '@app/core'
 import { prepareIcu } from '@app/helpers'
 import { splitAndGetNotEmpty, UNIQUE_HOSTS_OR_HANDLE_VALIDATOR } from '@app/shared/form-validators/host-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { InstanceFollowService } from '@app/shared/shared-instance'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -22,7 +22,7 @@ export class FollowModalComponent extends FormReactive implements OnInit {
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private followService: InstanceFollowService,
     private notifier: Notifier
index 1713e06ce0a7f2a9248c283d35d896f0175d992d..0627aa88745429be1215d89ea7735af9bf34b211 100644 (file)
@@ -12,7 +12,7 @@ import {
   USER_VIDEO_QUOTA_DAILY_VALIDATOR,
   USER_VIDEO_QUOTA_VALIDATOR
 } from '@app/shared/form-validators/user-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { UserAdminService } from '@app/shared/shared-users'
 import { UserCreate, UserRole } from '@shared/models'
 import { UserEdit } from './user-edit'
@@ -27,7 +27,7 @@ export class UserCreateComponent extends UserEdit implements OnInit {
 
   constructor (
     protected serverService: ServerService,
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     protected configService: ConfigService,
     protected screenService: ScreenService,
     protected auth: AuthService,
index da5879a36fb1ae1850d9a2b95d67d0519cb021ce..e51ccf80847075e8292707ac731a8f3b4a4706fa 100644 (file)
 </div>
 
 
-<div *ngIf="!isCreation() && user && user.pluginAuth === null" class="row mt-4"> <!-- danger zone grid -->
+<div *ngIf="displayDangerZone()" class="row mt-4"> <!-- danger zone grid -->
   <div class="col-12 col-lg-4 col-xl-3">
     <div class="anchor" id="danger"></div> <!-- danger zone anchor -->
     <div i18n class="account-title account-title-danger">DANGER ZONE</div>
   <div class="col-12 col-lg-8 col-xl-9">
 
     <div class="danger-zone">
-      <div class="form-group reset-password-email">
+      <div class="form-group">
         <label i18n>Send a link to reset the password by email to the user</label>
         <button (click)="resetPassword()" i18n>Ask for new password</button>
       </div>
         <label i18n>Manually set the user password</label>
         <my-user-password [userId]="user.id"></my-user-password>
       </div>
+
+      <div *ngIf="user.twoFactorEnabled" class="form-group">
+        <label i18n>This user has two factor authentication enabled</label>
+        <button (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
+      </div>
     </div>
 
   </div>
index 68fa1215fccc15cc9f0686c4246bde7bd3ed0669..6986281497f3d64048f9f482482606beb48e76bd 100644 (file)
@@ -48,17 +48,13 @@ my-user-real-quota-info {
 }
 
 .danger-zone {
-  .reset-password-email {
-    margin-bottom: 30px;
+  button {
+    @include peertube-button;
+    @include danger-button;
+    @include disable-outline;
 
-    button {
-      @include peertube-button;
-      @include danger-button;
-      @include disable-outline;
-
-      display: block;
-      margin-top: 0;
-    }
+    display: block;
+    margin-top: 0;
   }
 }
 
index 6dae4110d43ef00baab89c243cdcc896bda887d3..21e9629aba71e45d2524c16a27ec3b5308beae5b 100644 (file)
@@ -60,10 +60,22 @@ export abstract class UserEdit extends FormReactive implements OnInit {
     ]
   }
 
+  displayDangerZone () {
+    if (this.isCreation()) return false
+    if (this.user?.pluginAuth) return false
+    if (this.auth.getUser().id === this.user.id) return false
+
+    return true
+  }
+
   resetPassword () {
     return
   }
 
+  disableTwoFactorAuth () {
+    return
+  }
+
   getUserVideoQuota () {
     return this.form.value['videoQuota']
   }
index 8999d1f0044d4708b876b47b15f2f4d015cf6a47..d6616e07780864b1dea9cba8bbf6a41c580cd3dd 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, Input, OnInit } from '@angular/core'
 import { Notifier } from '@app/core'
 import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { UserAdminService } from '@app/shared/shared-users'
 import { UserUpdate } from '@shared/models'
 
@@ -18,7 +18,7 @@ export class UserPasswordComponent extends FormReactive implements OnInit {
   @Input() userId: number
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private notifier: Notifier,
     private userAdminService: UserAdminService
   ) {
index bab288a6760c309480cb69eb5110c9d5465a4709..71212b19c5c104d9fd279f8844cea542f97de90e 100644 (file)
@@ -9,8 +9,8 @@ import {
   USER_VIDEO_QUOTA_DAILY_VALIDATOR,
   USER_VIDEO_QUOTA_VALIDATOR
 } from '@app/shared/form-validators/user-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
-import { UserAdminService } from '@app/shared/shared-users'
+import { FormReactiveService } from '@app/shared/shared-forms'
+import { TwoFactorService, UserAdminService } from '@app/shared/shared-users'
 import { User as UserType, UserAdminFlag, UserRole, UserUpdate } from '@shared/models'
 import { UserEdit } from './user-edit'
 
@@ -25,7 +25,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
   private paramsSub: Subscription
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     protected serverService: ServerService,
     protected configService: ConfigService,
     protected screenService: ScreenService,
@@ -34,6 +34,7 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
     private router: Router,
     private notifier: Notifier,
     private userService: UserService,
+    private twoFactorService: TwoFactorService,
     private userAdminService: UserAdminService
   ) {
     super()
@@ -120,10 +121,22 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
           this.notifier.success($localize`An email asking for password reset has been sent to ${this.user.username}.`)
         },
 
-        error: err => {
-          this.error = err.message
-        }
+        error: err => this.notifier.error(err.message)
+      })
+  }
+
+  disableTwoFactorAuth () {
+    this.twoFactorService.disableTwoFactor({ userId: this.user.id })
+      .subscribe({
+        next: () => {
+          this.user.twoFactorEnabled = false
+
+          this.notifier.success($localize`Two factor authentication of ${this.user.username} disabled.`)
+        },
+
+        error: err => this.notifier.error(err.message)
       })
+
   }
 
   private onUserFetched (userJson: UserType) {
index 3c775cac5fd37ca033afb14818a33a62c075c756..23e0d29ee17a9af104144981bd6987a55eab8a24 100644 (file)
@@ -1,6 +1,6 @@
 @use '_variables' as *;
 @use '_mixins' as *;
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
 
 .add-button {
   @include create-button;
index ec02cfcd9a344b89afe9e7efa08d7d3266879f16..b1a41567ee4ea221b7f5de05cb2c3ae9698f078f 100644 (file)
@@ -4,7 +4,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
 import { ActivatedRoute } from '@angular/router'
 import { HooksService, Notifier, PluginService } from '@app/core'
 import { BuildFormArgument } from '@app/shared/form-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { PeerTubePlugin, RegisterServerSettingOptions } from '@shared/models'
 import { PluginApiService } from '../shared/plugin-api.service'
 
@@ -22,7 +22,7 @@ export class PluginShowInstalledComponent extends FormReactive implements OnInit
   private npmName: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private pluginService: PluginService,
     private pluginAPIService: PluginApiService,
     private notifier: Notifier,
index f3a2476f9ad5f59f6e523ebe2b536a405153e51c..49b443a20bf07cd0bf9fd37b1f9b296841b91bb0 100644 (file)
       <div class="login-form-and-externals">
 
         <form myPluginSelector pluginSelectorId="login-form" role="form" (ngSubmit)="login()" [formGroup]="form">
-          <div class="form-group">
-            <div>
-              <label i18n for="username">Username or email address</label>
-              <input
-                type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
-                formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
-              >
+          <ng-container *ngIf="!otpStep">
+            <div class="form-group">
+              <div>
+                <label i18n for="username">Username or email address</label>
+                <input
+                  type="text" id="username" i18n-placeholder placeholder="Example: john@example.com" required tabindex="1"
+                  formControlName="username" class="form-control" [ngClass]="{ 'input-error': formErrors['username'] }" myAutofocus
+                >
+              </div>
+
+              <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
+
+              <div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
+                ⚠️ Most email addresses do not include capital letters.
+              </div>
             </div>
 
-            <div *ngIf="formErrors.username" class="form-error">{{ formErrors.username }}</div>
+            <div class="form-group">
+              <label i18n for="password">Password</label>
 
-            <div *ngIf="hasUsernameUppercase()" i18n class="form-warning">
-              ⚠️ Most email addresses do not include capital letters.
+              <my-input-text
+                formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
+                [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
+              ></my-input-text>
             </div>
-          </div>
+          </ng-container>
+
+          <div *ngIf="otpStep" class="form-group">
+            <p i18n>Enter the two-factor code generated by your phone app:</p>
 
-          <div class="form-group">
-            <label i18n for="password">Password</label>
+            <label i18n for="otp-token">Two factor authentication token</label>
 
             <my-input-text
-              formControlName="password" inputId="password" i18n-placeholder placeholder="Password"
-              [formError]="formErrors['password']" autocomplete="current-password" [tabindex]="2"
+              #otpTokenInput
+              [show]="true" formControlName="otp-token" inputId="otp-token"
+              [formError]="formErrors['otp-token']" autocomplete="otp-token"
             ></my-input-text>
           </div>
 
           <input type="submit" class="peertube-button orange-button" i18n-value value="Login" [disabled]="!form.valid">
 
-          <div class="additional-links">
+          <div *ngIf="!otpStep" class="additional-links">
             <a i18n role="button" class="link-orange" (click)="openForgotPasswordModal()" i18n-title title="Click here to reset your password">I forgot my password</a>
 
             <ng-container *ngIf="signupAllowed">
index d31d428f70f0ef0f6cee85c62d06dc510b945e6f..17e151fd88b3519183a6232aff8d04bb8bb76988 100644 (file)
@@ -1,8 +1,8 @@
 @use '_variables' as *;
 @use '_mixins' as *;
 
-@import '~bootstrap/scss/functions';
-@import '~bootstrap/scss/variables';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
 
 label {
   display: block;
index 2ed9be16cf717e1e2b585a3ee41cee99c469fba6..c1705807f0052bbdec43d6a1ccc04089ed8c3c38 100644 (file)
@@ -1,10 +1,10 @@
-
 import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
 import { HooksService } from '@app/core/plugins/hooks.service'
 import { LOGIN_PASSWORD_VALIDATOR, LOGIN_USERNAME_VALIDATOR } from '@app/shared/form-validators/login-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
+import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
 import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
 import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
 import { PluginsManager } from '@root-helpers/plugins-manager'
@@ -20,6 +20,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
   private static SESSION_STORAGE_REDIRECT_URL_KEY = 'login-previous-url'
 
   @ViewChild('forgotPasswordModal', { static: true }) forgotPasswordModal: ElementRef
+  @ViewChild('otpTokenInput') otpTokenInput: InputTextComponent
 
   accordion: NgbAccordion
   error: string = null
@@ -37,11 +38,13 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
     codeOfConduct: false
   }
 
+  otpStep = false
+
   private openedForgotPasswordModal: NgbModalRef
   private serverConfig: ServerConfig
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private route: ActivatedRoute,
     private modalService: NgbModal,
     private authService: AuthService,
@@ -82,7 +85,11 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
     // Avoid undefined errors when accessing form error properties
     this.buildForm({
       username: LOGIN_USERNAME_VALIDATOR,
-      password: LOGIN_PASSWORD_VALIDATOR
+      password: LOGIN_PASSWORD_VALIDATOR,
+      'otp-token': {
+        VALIDATORS: [], // Will be set dynamically
+        MESSAGES: USER_OTP_TOKEN_VALIDATOR.MESSAGES
+      }
     })
 
     this.serverConfig = snapshot.data.serverConfig
@@ -118,13 +125,20 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
   login () {
     this.error = null
 
-    const { username, password } = this.form.value
+    const options = {
+      username: this.form.value['username'],
+      password: this.form.value['password'],
+      otpToken: this.form.value['otp-token']
+    }
 
-    this.authService.login(username, password)
+    this.authService.login(options)
+      .pipe()
       .subscribe({
         next: () => this.redirectService.redirectToPreviousRoute(),
 
-        error: err => this.handleError(err)
+        error: err => {
+          this.handleError(err)
+        }
       })
   }
 
@@ -162,7 +176,7 @@ The link will expire within 1 hour.`
   private loadExternalAuthToken (username: string, token: string) {
     this.isAuthenticatedWithExternalAuth = true
 
-    this.authService.login(username, null, token)
+    this.authService.login({ username, password: null, token })
       .subscribe({
         next: () => {
           const redirectUrl = this.storage.getItem(LoginComponent.SESSION_STORAGE_REDIRECT_URL_KEY)
@@ -182,6 +196,17 @@ The link will expire within 1 hour.`
   }
 
   private handleError (err: any) {
+    if (this.authService.isOTPMissingError(err)) {
+      this.otpStep = true
+
+      setTimeout(() => {
+        this.form.get('otp-token').setValidators(USER_OTP_TOKEN_VALIDATOR.VALIDATORS)
+        this.otpTokenInput.focus()
+      })
+
+      return
+    }
+
     if (err.message.indexOf('credentials are invalid') !== -1) this.error = $localize`Incorrect username or password.`
     else if (err.message.indexOf('blocked') !== -1) this.error = $localize`Your account is blocked.`
     else this.error = err.message
index 8211451a402d3d6ad69ccd6bb47e724c56c5c3bf..3720668900e6a8751cbde736cb937feece931418 100644 (file)
@@ -9,7 +9,7 @@ import {
   VIDEO_CHANNEL_NAME_VALIDATOR,
   VIDEO_CHANNEL_SUPPORT_VALIDATOR
 } from '@app/shared/form-validators/video-channel-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
 import { HttpStatusCode, VideoChannelCreate } from '@shared/models'
 import { VideoChannelEdit } from './video-channel-edit'
@@ -26,7 +26,7 @@ export class VideoChannelCreateComponent extends VideoChannelEdit implements OnI
   private banner: FormData
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private router: Router,
index 7e8d6ffe68eb8c734602d99e4ab86055325239c0..32f6d650d9d24d882b6b792c239cb625cc309669 100644 (file)
@@ -9,7 +9,7 @@ import {
   VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
   VIDEO_CHANNEL_SUPPORT_VALIDATOR
 } from '@app/shared/form-validators/video-channel-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { VideoChannel, VideoChannelService } from '@app/shared/shared-main'
 import { HTMLServerConfig, VideoChannelUpdate } from '@shared/models'
 import { VideoChannelEdit } from './video-channel-edit'
@@ -28,7 +28,7 @@ export class VideoChannelUpdateComponent extends VideoChannelEdit implements OnI
   private serverConfig: HTMLServerConfig
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private route: ActivatedRoute,
index ef39c1a365e5a09d1e70d458f0d6a4fdbeb6ee7d..b39b1f6b4a8ea544460926ec1ed31bf95c1aaf7e 100644 (file)
@@ -7,6 +7,7 @@ import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-b
 import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
 import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
 import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
+import { MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
 import { MyAccountComponent } from './my-account.component'
 
 const myAccountRoutes: Routes = [
@@ -30,6 +31,16 @@ const myAccountRoutes: Routes = [
         }
       },
 
+      {
+        path: 'two-factor-auth',
+        component: MyAccountTwoFactorComponent,
+        data: {
+          meta: {
+            title: $localize`Two factor authentication`
+          }
+        }
+      },
+
       {
         path: 'video-channels',
         redirectTo: '/my-library/video-channels',
index 9b87daa40ef11d33871bc2810cddb1204cbaaf8d..235fbec4a9eed793ac533a1ac3971f3ed8517a78 100644 (file)
@@ -3,8 +3,8 @@ import { tap } from 'rxjs/operators'
 import { Component, OnInit } from '@angular/core'
 import { AuthService, ServerService, UserService } from '@app/core'
 import { USER_EMAIL_VALIDATOR, USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { User } from '@shared/models'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { HttpStatusCode, User } from '@shared/models'
 
 @Component({
   selector: 'my-account-change-email',
@@ -17,7 +17,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
   user: User = null
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private userService: UserService,
     private serverService: ServerService
@@ -57,7 +57,7 @@ export class MyAccountChangeEmailComponent extends FormReactive implements OnIni
         },
 
         error: err => {
-          if (err.status === 401) {
+          if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
             this.error = $localize`You current password is invalid.`
             return
           }
index 47e54dc23110dbb3fc0febbaaaf122cf928b40a6..805d500701d1202f96b832105e2dcaa7f50cf1d6 100644 (file)
@@ -6,8 +6,8 @@ import {
   USER_EXISTING_PASSWORD_VALIDATOR,
   USER_PASSWORD_VALIDATOR
 } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { User } from '@shared/models'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
+import { HttpStatusCode, User } from '@shared/models'
 
 @Component({
   selector: 'my-account-change-password',
@@ -19,7 +19,7 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
   user: User = null
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private notifier: Notifier,
     private authService: AuthService,
     private userService: UserService
@@ -57,7 +57,7 @@ export class MyAccountChangePasswordComponent extends FormReactive implements On
         },
 
         error: err => {
-          if (err.status === 401) {
+          if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
             this.error = $localize`You current password is invalid.`
             return
           }
index 2bae3499e755324c1a33f666ee9c7d95c804d804..9619623ee4cae1d105320475619acefedcb0b565 100644 (file)
@@ -18,7 +18,7 @@ export class MyAccountDangerZoneComponent {
   ) { }
 
   async deleteMe () {
-    const res = await this.confirmService.confirmWithInput(
+    const res = await this.confirmService.confirmWithExpectedInput(
       $localize`Are you sure you want to delete your account?` +
         '<br /><br />' +
         // eslint-disable-next-line max-len
index f395ad73f67afd0a9b0cef0bbd8ce30d6ca855d9..8621eb7aaafaedb2d5a4d400329cf74a8234ed0a 100644 (file)
@@ -2,7 +2,7 @@ import { Subject } from 'rxjs'
 import { Component, Input, OnInit } from '@angular/core'
 import { Notifier, User, UserService } from '@app/core'
 import { USER_DESCRIPTION_VALIDATOR, USER_DISPLAY_NAME_REQUIRED_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 
 @Component({
   selector: 'my-account-profile',
@@ -16,7 +16,7 @@ export class MyAccountProfileComponent extends FormReactive implements OnInit {
   error: string = null
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private notifier: Notifier,
     private userService: UserService
   ) {
index 42a8d0856d08c71f233889a323d509f9947c28d4..666205de67f8553df8f3bfb732f182bf969619b8 100644 (file)
   </div>
 </div>
 
+<div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- two factor auth grid -->
+  <div class="col-12 col-lg-4 col-xl-3">
+    <h2 i18n class="account-title">Two-factor authentication</h2>
+  </div>
+
+  <div class="col-12 col-lg-8 col-xl-9">
+    <my-account-two-factor-button [user]="user"  [userInformationLoaded]="userInformationLoaded"></my-account-two-factor-button>
+  </div>
+</div>
+
 <div class="row mt-5" *ngIf="user.pluginAuth === null"> <!-- email grid -->
   <div class="col-12 col-lg-4 col-xl-3">
     <h2 i18n class="account-title">EMAIL</h2>
index 8206f4dd8fd02daffdde62e7454575a0a7bb14fb..3d686a1467aecbb4b659878f0fb972ea640e7f74 100644 (file)
@@ -1,6 +1,6 @@
 @use '_variables' as *;
 @use '_mixins' as *;
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
 
 .account-title {
   @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 (file)
index 0000000..cc774bd
--- /dev/null
@@ -0,0 +1,2 @@
+export * from './my-account-two-factor-button.component'
+export * 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 (file)
index 0000000..2fcfffb
--- /dev/null
@@ -0,0 +1,12 @@
+<div class="two-factor">
+  <ng-container *ngIf="!twoFactorEnabled">
+    <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>
+
+    <my-button [routerLink]="[ '/my-account/two-factor-auth' ]" className="orange-button-link" i18n>Enable two-factor authentication</my-button>
+  </ng-container>
+
+  <ng-container *ngIf="twoFactorEnabled">
+    <my-button className="orange-button" (click)="disableTwoFactor()" i18n>Disable two-factor authentication</my-button>
+  </ng-container>
+
+</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 (file)
index 0000000..97ffb60
--- /dev/null
@@ -0,0 +1,49 @@
+import { Subject } from 'rxjs'
+import { Component, Input, OnInit } from '@angular/core'
+import { AuthService, ConfirmService, Notifier, User } from '@app/core'
+import { TwoFactorService } from '@app/shared/shared-users'
+
+@Component({
+  selector: 'my-account-two-factor-button',
+  templateUrl: './my-account-two-factor-button.component.html'
+})
+export class MyAccountTwoFactorButtonComponent implements OnInit {
+  @Input() user: User = null
+  @Input() userInformationLoaded: Subject<any>
+
+  twoFactorEnabled = false
+
+  constructor (
+    private notifier: Notifier,
+    private twoFactorService: TwoFactorService,
+    private confirmService: ConfirmService,
+    private auth: AuthService
+  ) {
+  }
+
+  ngOnInit () {
+    this.userInformationLoaded.subscribe(() => {
+      this.twoFactorEnabled = this.user.twoFactorEnabled
+    })
+  }
+
+  async disableTwoFactor () {
+    const message = $localize`Are you sure you want to disable two factor authentication of your account?`
+
+    const { confirmed, password } = await this.confirmService.confirmWithPassword(message, $localize`Disable two factor`)
+    if (confirmed === false) return
+
+    this.twoFactorService.disableTwoFactor({ userId: this.user.id, currentPassword: password })
+      .subscribe({
+        next: () => {
+          this.twoFactorEnabled = false
+
+          this.auth.refreshUserInformation()
+
+          this.notifier.success($localize`Two factor authentication disabled`)
+        },
+
+        error: err => this.notifier.error(err.message)
+      })
+  }
+}
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 (file)
index 0000000..16c344e
--- /dev/null
@@ -0,0 +1,54 @@
+<h1>
+  <my-global-icon iconName="cog" aria-hidden="true"></my-global-icon>
+  <ng-container i18n>Two factor authentication</ng-container>
+</h1>
+
+<div i18n *ngIf="twoFactorAlreadyEnabled === true" class="root already-enabled">
+  Two factor authentication is already enabled.
+</div>
+
+<div class="root" *ngIf="twoFactorAlreadyEnabled === false">
+  <ng-container *ngIf="step === 'request'">
+    <form role="form" (ngSubmit)="requestTwoFactor()" [formGroup]="formPassword">
+
+      <label i18n for="current-password">Your password</label>
+      <div class="form-group-description" i18n>Confirm your password to enable two factor authentication</div>
+
+      <my-input-text
+        formControlName="current-password" inputId="current-password" i18n-placeholder placeholder="Current password"
+        [formError]="formErrorsPassword['current-password']" autocomplete="current-password"
+      ></my-input-text>
+
+      <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formPassword.valid">
+    </form>
+  </ng-container>
+
+  <ng-container *ngIf="step === 'confirm'">
+
+    <p i18n>
+      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.
+    </p>
+
+    <qrcode [qrdata]="twoFactorURI" [width]="256" level="Q"></qrcode>
+
+    <div i18n>
+      If you can't scan the QR code and need to enter it manually, here is the plain-text secret:
+    </div>
+
+    <div class="secret-plain-text">{{ twoFactorSecret }}</div>
+
+    <form class="mt-3" role="form" (ngSubmit)="confirmTwoFactor()" [formGroup]="formOTP">
+
+      <label i18n for="otp-token">Two-factor code</label>
+      <div class="form-group-description" i18n>Enter the code generated by your authenticator app to confirm</div>
+
+      <my-input-text
+        [show]="true" formControlName="otp-token" inputId="otp-token"
+        [formError]="formErrorsOTP['otp-token']" autocomplete="otp-token"
+      ></my-input-text>
+
+      <input class="peertube-button orange-button mt-3" type="submit" i18n-value value="Confirm" [disabled]="!formOTP.valid">
+    </form>
+  </ng-container>
+
+</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 (file)
index 0000000..cee016b
--- /dev/null
@@ -0,0 +1,16 @@
+@use '_variables' as *;
+@use '_mixins' as *;
+
+.root {
+  max-width: 600px;
+}
+
+.secret-plain-text {
+  font-family: monospace;
+  font-size: 0.9rem;
+}
+
+qrcode {
+  display: inline-block;
+  margin: auto;
+}
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 (file)
index 0000000..259090d
--- /dev/null
@@ -0,0 +1,105 @@
+import { Component, OnInit } from '@angular/core'
+import { FormGroup } from '@angular/forms'
+import { Router } from '@angular/router'
+import { AuthService, Notifier, User } from '@app/core'
+import { USER_EXISTING_PASSWORD_VALIDATOR, USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-validators'
+import { FormReactiveService } from '@app/shared/shared-forms'
+import { TwoFactorService } from '@app/shared/shared-users'
+
+@Component({
+  selector: 'my-account-two-factor',
+  templateUrl: './my-account-two-factor.component.html',
+  styleUrls: [ './my-account-two-factor.component.scss' ]
+})
+export class MyAccountTwoFactorComponent implements OnInit {
+  twoFactorAlreadyEnabled: boolean
+
+  step: 'request' | 'confirm' | 'confirmed' = 'request'
+
+  twoFactorSecret: string
+  twoFactorURI: string
+
+  inPasswordStep = true
+
+  formPassword: FormGroup
+  formErrorsPassword: any
+
+  formOTP: FormGroup
+  formErrorsOTP: any
+
+  private user: User
+  private requestToken: string
+
+  constructor (
+    private notifier: Notifier,
+    private twoFactorService: TwoFactorService,
+    private formReactiveService: FormReactiveService,
+    private auth: AuthService,
+    private router: Router
+  ) {
+  }
+
+  ngOnInit () {
+    this.buildPasswordForm()
+    this.buildOTPForm()
+
+    this.auth.userInformationLoaded.subscribe(() => {
+      this.user = this.auth.getUser()
+
+      this.twoFactorAlreadyEnabled = this.user.twoFactorEnabled
+    })
+  }
+
+  requestTwoFactor () {
+    this.twoFactorService.requestTwoFactor({
+      userId: this.user.id,
+      currentPassword: this.formPassword.value['current-password']
+    }).subscribe({
+      next: ({ otpRequest }) => {
+        this.requestToken = otpRequest.requestToken
+        this.twoFactorURI = otpRequest.uri
+        this.twoFactorSecret = otpRequest.secret.replace(/(.{4})/g, '$1 ').trim()
+
+        this.step = 'confirm'
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+  confirmTwoFactor () {
+    this.twoFactorService.confirmTwoFactorRequest({
+      userId: this.user.id,
+      requestToken: this.requestToken,
+      otpToken: this.formOTP.value['otp-token']
+    }).subscribe({
+      next: () => {
+        this.notifier.success($localize`Two factor authentication has been enabled.`)
+
+        this.auth.refreshUserInformation()
+
+        this.router.navigateByUrl('/my-account/settings')
+      },
+
+      error: err => this.notifier.error(err.message)
+    })
+  }
+
+  private buildPasswordForm () {
+    const { form, formErrors } = this.formReactiveService.buildForm({
+      'current-password': USER_EXISTING_PASSWORD_VALIDATOR
+    })
+
+    this.formPassword = form
+    this.formErrorsPassword = formErrors
+  }
+
+  private buildOTPForm () {
+    const { form, formErrors } = this.formReactiveService.buildForm({
+      'otp-token': USER_OTP_TOKEN_VALIDATOR
+    })
+
+    this.formOTP = form
+    this.formErrorsOTP = formErrors
+  }
+}
index 4081e4f01376096c9a98c73703ffb9abe3341317..84b05764709e4c595f872a44e9d1308f445d2974 100644 (file)
@@ -1,3 +1,4 @@
+import { QRCodeModule } from 'angularx-qrcode'
 import { AutoCompleteModule } from 'primeng/autocomplete'
 import { TableModule } from 'primeng/table'
 import { DragDropModule } from '@angular/cdk/drag-drop'
@@ -10,6 +11,7 @@ import { SharedMainModule } from '@app/shared/shared-main'
 import { SharedModerationModule } from '@app/shared/shared-moderation'
 import { SharedShareModal } from '@app/shared/shared-share-modal'
 import { SharedUserInterfaceSettingsModule } from '@app/shared/shared-user-settings'
+import { SharedUsersModule } from '@app/shared/shared-users'
 import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
 import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
 import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
@@ -23,12 +25,14 @@ import { MyAccountDangerZoneComponent } from './my-account-settings/my-account-d
 import { MyAccountNotificationPreferencesComponent } from './my-account-settings/my-account-notification-preferences'
 import { MyAccountProfileComponent } from './my-account-settings/my-account-profile/my-account-profile.component'
 import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
+import { MyAccountTwoFactorButtonComponent, MyAccountTwoFactorComponent } from './my-account-settings/my-account-two-factor'
 import { MyAccountComponent } from './my-account.component'
 
 @NgModule({
   imports: [
     MyAccountRoutingModule,
 
+    QRCodeModule,
     AutoCompleteModule,
     TableModule,
     DragDropModule,
@@ -37,6 +41,7 @@ import { MyAccountComponent } from './my-account.component'
     SharedFormModule,
     SharedModerationModule,
     SharedUserInterfaceSettingsModule,
+    SharedUsersModule,
     SharedGlobalIconModule,
     SharedAbuseListModule,
     SharedShareModal,
@@ -52,6 +57,9 @@ import { MyAccountComponent } from './my-account.component'
     MyAccountChangeEmailComponent,
     MyAccountApplicationsComponent,
 
+    MyAccountTwoFactorButtonComponent,
+    MyAccountTwoFactorComponent,
+
     MyAccountDangerZoneComponent,
     MyAccountBlocklistComponent,
     MyAccountAbusesListComponent,
index 205ad7a89304d9bf51eb751a579da4ad8d14de3b..ece59c2ff2761f828f8abc29795437f584e7bf50 100644 (file)
@@ -40,7 +40,7 @@ export class MyVideoChannelsComponent {
   }
 
   async deleteVideoChannel (videoChannel: VideoChannel) {
-    const res = await this.confirmService.confirmWithInput(
+    const res = await this.confirmService.confirmWithExpectedInput(
       $localize`Do you really want to delete ${videoChannel.displayName}?
 It will delete ${videoChannel.videosCount} videos uploaded in this channel, and you will not be able to create another
 channel with the same name (${videoChannel.name})!`,
index 8ead237c7ab726563e8d9e376208259e0d11372f..ca7eb680b6fe25cfc392b6dca20a042a5f067529 100644 (file)
@@ -3,7 +3,7 @@ import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '
 import { AuthService, Notifier } from '@app/core'
 import { listUserChannelsForSelect } from '@app/helpers'
 import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { VideoOwnershipService } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { VideoChangeOwnership } from '@shared/models'
@@ -24,7 +24,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
   error: string = null
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private videoOwnershipService: VideoOwnershipService,
     private notifier: Notifier,
     private authService: AuthService,
index 9ceb6dfd19932b93f4e24ed585b8e4069050ae0b..a14ab5b9221c975af8c6b5a0d9b7c165a10cece2 100644 (file)
@@ -5,7 +5,7 @@ import { Router } from '@angular/router'
 import { AuthService, Notifier } from '@app/core'
 import { listUserChannelsForSelect } from '@app/helpers'
 import { VIDEO_CHANNEL_EXTERNAL_URL_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { VideoChannelService, VideoChannelSyncService } from '@app/shared/shared-main'
 import { VideoChannelSyncCreate } from '@shared/models/videos'
 
@@ -20,7 +20,7 @@ export class VideoChannelSyncEditComponent extends FormReactive implements OnIni
   existingVideosStrategy: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private router: Router,
     private notifier: Notifier,
index 9eb3e9888b041b7a6b849c7b90e170f7e0230ff2..63f72df3f7a23bb4bda7cf6ac4d8b60472a367db 100644 (file)
@@ -9,7 +9,7 @@ import {
   VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
   VIDEO_PLAYLIST_PRIVACY_VALIDATOR
 } from '@app/shared/form-validators/video-playlist-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { VideoPlaylistCreate } from '@shared/models/videos/playlist/video-playlist-create.model'
 import { VideoPlaylistPrivacy } from '@shared/models/videos/playlist/video-playlist-privacy.model'
@@ -23,7 +23,7 @@ export class MyVideoPlaylistCreateComponent extends MyVideoPlaylistEdit implemen
   error: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private router: Router,
index ef7ba00181211fabec52e08295410cf212c44c48..bbe8a5f803cf3058fe26acb1b67c60d4b5a2a493 100644 (file)
@@ -11,7 +11,7 @@ import {
   VIDEO_PLAYLIST_DISPLAY_NAME_VALIDATOR,
   VIDEO_PLAYLIST_PRIVACY_VALIDATOR
 } from '@app/shared/form-validators/video-playlist-validators'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
 import { VideoPlaylistUpdate } from '@shared/models'
 import { MyVideoPlaylistEdit } from './my-video-playlist-edit'
@@ -27,7 +27,7 @@ export class MyVideoPlaylistUpdateComponent extends MyVideoPlaylistEdit implemen
   private paramsSub: Subscription
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private router: Router,
index 960c9a4f73f0ba31e627187fb76f5319ddf4812d..72187e8930ef542815b1a4195344737a1ea4c57f 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
 import { Notifier, UserService } from '@app/core'
 import { OWNERSHIP_CHANGE_USERNAME_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { Video, VideoOwnershipService } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 
@@ -20,7 +20,7 @@ export class VideoChangeOwnershipComponent extends FormReactive implements OnIni
   private video: Video | undefined = undefined
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private videoOwnershipService: VideoOwnershipService,
     private notifier: Notifier,
     private userService: UserService,
index 11c5110fd73bacb31cbc4fc851faf40fc379f981..44216f978666a72809a2f5f9a0cbdbf82365301f 100644 (file)
@@ -3,7 +3,7 @@ import { ActivatedRoute, Router } from '@angular/router'
 import { Notifier, UserService } from '@app/core'
 import { RESET_PASSWORD_CONFIRM_VALIDATOR } from '@app/shared/form-validators/reset-password-validators'
 import { USER_PASSWORD_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 
 @Component({
   selector: 'my-login',
@@ -16,7 +16,7 @@ export class ResetPasswordComponent extends FormReactive implements OnInit {
   private verificationString: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private userService: UserService,
     private notifier: Notifier,
     private router: Router,
index 4ab327b1bfdffd239c744d9facee701b951eb546..958770ebf32495a64ab0f66fd84646985d1f77b7 100644 (file)
@@ -158,7 +158,7 @@ export class RegisterComponent implements OnInit {
         }
 
         // Auto login
-        this.authService.login(body.username, body.password)
+        this.authService.login({ username: body.username, password: body.password })
           .subscribe({
             next: () => {
               this.signupSuccess = true
index c10b568ba74695a2774c1a9808323b71f47708b2..df92c514554b30dbc666d888d375a3077cea1674 100644 (file)
@@ -3,7 +3,7 @@ import { pairwise } from 'rxjs/operators'
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { FormGroup } from '@angular/forms'
 import { VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR, VIDEO_CHANNEL_NAME_VALIDATOR } from '@app/shared/form-validators/video-channel-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { UserSignupService } from '@app/shared/shared-users'
 
 @Component({
@@ -19,7 +19,7 @@ export class RegisterStepChannelComponent extends FormReactive implements OnInit
   @Output() formBuilt = new EventEmitter<FormGroup>()
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private userSignupService: UserSignupService
   ) {
     super()
index 87d16696ec179498bfd15693bb3b2c2a4ccb280f..2df963b30d86dce101562716e169b125caf4ab7c 100644 (file)
@@ -1,9 +1,7 @@
 import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'
 import { FormGroup } from '@angular/forms'
-import {
-  USER_TERMS_VALIDATOR
-} from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { USER_TERMS_VALIDATOR } from '@app/shared/form-validators/user-validators'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 
 @Component({
   selector: 'my-register-step-terms',
@@ -19,7 +17,7 @@ export class RegisterStepTermsComponent extends FormReactive implements OnInit {
   @Output() codeOfConductClick = new EventEmitter<void>()
 
   constructor (
-    protected formValidatorService: FormValidatorService
+    protected formReactiveService: FormReactiveService
   ) {
     super()
   }
index b89e38a28a73c085fbcfde2142f602f4b0e9da14..822f8f5c5fd40af061d66001fb81646b55f51393 100644 (file)
@@ -8,7 +8,7 @@ import {
   USER_PASSWORD_VALIDATOR,
   USER_USERNAME_VALIDATOR
 } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { UserSignupService } from '@app/shared/shared-users'
 
 @Component({
@@ -23,7 +23,7 @@ export class RegisterStepUserComponent extends FormReactive implements OnInit {
   @Output() formBuilt = new EventEmitter<FormGroup>()
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private userSignupService: UserSignupService
   ) {
     super()
index a0ed66a3a1bb193bb7e511eb1f461c37422a400d..06905f678991336845aaa3046cd737d39dcbe3e8 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, OnInit } from '@angular/core'
 import { Notifier, RedirectService, ServerService } from '@app/core'
 import { USER_EMAIL_VALIDATOR } from '@app/shared/form-validators/user-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { UserSignupService } from '@app/shared/shared-users'
 
 @Component({
@@ -14,7 +14,7 @@ export class VerifyAccountAskSendEmailComponent extends FormReactive implements
   requiresEmailVerification = false
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private userSignupService: UserSignupService,
     private serverService: ServerService,
     private notifier: Notifier,
index bf91c237acaab531f245f1027d45ac61b92ce4a2..dad083bf9b9387249194ba0a6a59291bcac8a5c5 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { ConfirmService, Notifier, ServerService } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { VideoDetails } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
@@ -20,7 +20,7 @@ export class VideoStudioEditComponent extends FormReactive implements OnInit {
   video: VideoDetails
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private serverService: ServerService,
     private notifier: Notifier,
     private router: Router,
index 95d83b131d32f61d71b8e725db68dd2a84af5491..4ab2d42db2294c07309472efd84214bdecd5d35f 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
 import { ServerService } from '@app/core'
 import { VIDEO_CAPTION_FILE_VALIDATOR, VIDEO_CAPTION_LANGUAGE_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { VideoCaptionEdit } from '@app/shared/shared-main'
 import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
 import { HTMLServerConfig, VideoConstant } from '@shared/models'
@@ -26,7 +26,7 @@ export class VideoCaptionAddModalComponent extends FormReactive implements OnIni
   private closingModal = false
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private serverService: ServerService
   ) {
index f33353d36368a8ee0b95aa00733ff1c2c813017e..2cb470a246d5c31b90ecc5d13543ca18b8c753cc 100644 (file)
@@ -1,8 +1,8 @@
 import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
 import { VIDEO_CAPTION_FILE_CONTENT_VALIDATOR } from '@app/shared/form-validators/video-captions-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { VideoCaptionEdit, VideoCaptionService, VideoCaptionWithPathEdit } from '@app/shared/shared-main'
-import { NgbModal, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
 import { HTMLServerConfig, VideoConstant } from '@shared/models'
 import { ServerService } from '../../../../core'
 
@@ -29,8 +29,7 @@ export class VideoCaptionEditModalContentComponent extends FormReactive implemen
 
   constructor (
     protected openedModal: NgbActiveModal,
-    protected formValidatorService: FormValidatorService,
-    private modalService: NgbModal,
+    protected formReactiveService: FormReactiveService,
     private videoCaptionService: VideoCaptionService,
     private serverService: ServerService
   ) {
index 344b99ea23011b77374d788099ec62294764e193..4f2276e8ca0c43101c99436ede5917060f544df4 100644 (file)
@@ -3,7 +3,7 @@ import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular
 import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { LoadingBarService } from '@ngx-loading-bar/core'
@@ -39,7 +39,7 @@ export class VideoGoLiveComponent extends VideoSend implements OnInit, AfterView
   error: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     protected loadingBar: LoadingBarService,
     protected notifier: Notifier,
     protected authService: AuthService,
index 7b9531d27489e6ac1ecdb30c4f4f6c76a0b053f4..4a1408a4af30c1c60f29c80c04f8ede44a3f520a 100644 (file)
@@ -3,7 +3,7 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, OnInit, Output, Vie
 import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
@@ -35,7 +35,7 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
   error: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     protected loadingBar: LoadingBarService,
     protected notifier: Notifier,
     protected authService: AuthService,
index 422f0c64329993aa7965db640ebf9499eb0d1f1b..502f3818ec318721280249954a41ba82b6d36e21 100644 (file)
@@ -4,7 +4,7 @@ import { AfterViewInit, Component, EventEmitter, OnInit, Output } from '@angular
 import { Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService } from '@app/core'
 import { scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
@@ -34,7 +34,7 @@ export class VideoImportUrlComponent extends VideoSend implements OnInit, AfterV
   error: string
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     protected loadingBar: LoadingBarService,
     protected notifier: Notifier,
     protected authService: AuthService,
index 19fba2a83ee70c7624fe64534e5a9042888c8173..b0d846664d8462145ec02f77f3bf11caaad282aa 100644 (file)
@@ -5,7 +5,7 @@ import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit,
 import { ActivatedRoute, Router } from '@angular/router'
 import { AuthService, CanComponentDeactivate, HooksService, MetaService, Notifier, ServerService, UserService } from '@app/core'
 import { genericUploadErrorHandler, scrollToTop } from '@app/helpers'
-import { FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactiveService } from '@app/shared/shared-forms'
 import { BytesPipe, Video, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LoadingBarService } from '@ngx-loading-bar/core'
 import { logger } from '@root-helpers/logger'
@@ -60,7 +60,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy
   private uploadServiceSubscription: Subscription
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     protected loadingBar: LoadingBarService,
     protected notifier: Notifier,
     protected authService: AuthService,
index ed17dff06ec1bb94383451a41e8a9ed289ec39a6..21297144705197402c1cfebc929441018a254214 100644 (file)
@@ -4,7 +4,7 @@ import { SelectChannelItem } from 'src/types/select-options-item.model'
 import { Component, HostListener, OnInit } from '@angular/core'
 import { ActivatedRoute, Router } from '@angular/router'
 import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { Video, VideoCaptionEdit, VideoCaptionService, VideoDetails, VideoEdit, VideoService } from '@app/shared/shared-main'
 import { LiveVideoService } from '@app/shared/shared-video-live'
 import { LoadingBarService } from '@ngx-loading-bar/core'
@@ -33,7 +33,7 @@ export class VideoUpdateComponent extends FormReactive implements OnInit {
   private updateDone = false
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private route: ActivatedRoute,
     private router: Router,
     private notifier: Notifier,
index 9f4a68736b768bc1631794ded4c207e777a0d03c..9a9bfe710d56694c9795939ea6ec072881663ccb 100644 (file)
@@ -16,7 +16,7 @@ import {
 import { Router } from '@angular/router'
 import { Notifier, User } from '@app/core'
 import { VIDEO_COMMENT_TEXT_VALIDATOR } from '@app/shared/form-validators/video-comment-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { Video } from '@app/shared/shared-main'
 import { VideoComment, VideoCommentService } from '@app/shared/shared-video-comment'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -48,7 +48,7 @@ export class VideoCommentAddComponent extends FormReactive implements OnChanges,
   private emojiMarkupList: { emoji: string, name: string }[]
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private notifier: Notifier,
     private videoCommentService: VideoCommentService,
     private modalService: NgbModal,
index ca46866f56ee59b31cf63467bbd11501c3ce9502..7f4fae4aaf4f1d6820aa595ce3b298ee143efd98 100644 (file)
@@ -1,7 +1,7 @@
 import { Hotkey, HotkeysService } from 'angular2-hotkeys'
 import { Observable, ReplaySubject, Subject, throwError as observableThrowError } from 'rxjs'
 import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
-import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'
+import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Router } from '@angular/router'
 import { Notifier } from '@app/core/notification/notifier.service'
@@ -141,7 +141,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
     return !!this.getAccessToken()
   }
 
-  login (username: string, password: string, token?: string) {
+  login (options: {
+    username: string
+    password: string
+    otpToken?: string
+    token?: string
+  }) {
+    const { username, password, token, otpToken } = options
+
     // Form url encoded
     const body = {
       client_id: this.clientId,
@@ -155,7 +162,9 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
 
     if (token) Object.assign(body, { externalAuthToken: token })
 
-    const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
+    let headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
+    if (otpToken) headers = headers.set('x-peertube-otp', otpToken)
+
     return this.http.post<UserLogin>(AuthService.BASE_TOKEN_URL, objectToUrlEncoded(body), { headers })
                .pipe(
                  map(res => Object.assign(res, { username })),
@@ -245,6 +254,14 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
         })
   }
 
+  isOTPMissingError (err: HttpErrorResponse) {
+    if (err.status !== HttpStatusCode.UNAUTHORIZED_401) return false
+
+    if (err.headers.get('x-peertube-otp') !== 'required; app') return false
+
+    return true
+  }
+
   private mergeUserInformation (obj: UserLoginWithUsername): Observable<UserLoginWithUserInformation> {
     // User is not loaded yet, set manually auth header
     const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`)
index 338b8762c97d83a3dcd5d96b9c6cc0f5f16dce24..89a25f0a50e52ba23bd176020c3ddfe2eeea3cde 100644 (file)
@@ -1,28 +1,53 @@
-import { firstValueFrom, Subject } from 'rxjs'
+import { firstValueFrom, map, Observable, Subject } from 'rxjs'
 import { Injectable } from '@angular/core'
 
 type ConfirmOptions = {
   title: string
   message: string
-  inputLabel?: string
-  expectedInputValue?: string
-  confirmButtonText?: string
-}
+} & (
+  {
+    type: 'confirm'
+    confirmButtonText?: string
+  } |
+  {
+    type: 'confirm-password'
+    confirmButtonText?: string
+  } |
+  {
+    type: 'confirm-expected-input'
+    inputLabel?: string
+    expectedInputValue?: string
+    confirmButtonText?: string
+  }
+)
 
 @Injectable()
 export class ConfirmService {
   showConfirm = new Subject<ConfirmOptions>()
-  confirmResponse = new Subject<boolean>()
+  confirmResponse = new Subject<{ confirmed: boolean, value?: string }>()
 
   confirm (message: string, title = '', confirmButtonText?: string) {
-    this.showConfirm.next({ title, message, confirmButtonText })
+    this.showConfirm.next({ type: 'confirm', title, message, confirmButtonText })
+
+    return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
+  }
 
-    return firstValueFrom(this.confirmResponse.asObservable())
+  confirmWithPassword (message: string, title = '', confirmButtonText?: string) {
+    this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText })
+
+    const obs = this.confirmResponse.asObservable()
+      .pipe(map(({ confirmed, value }) => ({ confirmed, password: value })))
+
+    return firstValueFrom(obs)
   }
 
-  confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
-    this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText })
+  confirmWithExpectedInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) {
+    this.showConfirm.next({ type: 'confirm-expected-input', title, message, inputLabel, expectedInputValue, confirmButtonText })
+
+    return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable()))
+  }
 
-    return firstValueFrom(this.confirmResponse.asObservable())
+  private extractConfirmed (obs: Observable<{ confirmed: boolean }>) {
+    return obs.pipe(map(({ confirmed }) => confirmed))
   }
 }
index 7eec2eca62047901005dfc00a102b123f9e26dc4..57dd9ae26fcb54620aaeb90272bc5ea53d9008b3 100644 (file)
@@ -4,6 +4,7 @@ import { Router } from '@angular/router'
 import { DateFormat, dateToHuman } from '@app/helpers'
 import { logger } from '@root-helpers/logger'
 import { HttpStatusCode, ResultList } from '@shared/models'
+import { HttpHeaderResponse } from '@angular/common/http'
 
 @Injectable()
 export class RestExtractor {
@@ -54,10 +55,11 @@ export class RestExtractor {
   handleError (err: any) {
     const errorMessage = this.buildErrorMessage(err)
 
-    const errorObj: { message: string, status: string, body: string } = {
+    const errorObj: { message: string, status: string, body: string, headers: HttpHeaderResponse } = {
       message: errorMessage,
       status: undefined,
-      body: undefined
+      body: undefined,
+      headers: err.headers
     }
 
     if (err.status) {
index 6ba30e4b81f22425da822448918a99ac906d2bca..8385a401200c0fa9cafcb37fda1dc291c502ed67 100644 (file)
@@ -66,6 +66,8 @@ export class User implements UserServerModel {
 
   lastLoginDate: Date | null
 
+  twoFactorEnabled: boolean
+
   createdAt: Date
 
   constructor (hash: Partial<UserServerModel>) {
@@ -108,6 +110,8 @@ export class User implements UserServerModel {
 
     this.notificationSettings = hash.notificationSettings
 
+    this.twoFactorEnabled = hash.twoFactorEnabled
+
     this.createdAt = hash.createdAt
 
     this.pluginAuth = hash.pluginAuth
index c59c2577019a85931d9a0f2a2dd884d30b4237c6..f364165c43fe61c618490a416a72d25f25345807 100644 (file)
@@ -9,9 +9,12 @@
   <div class="modal-body" >
     <div [innerHtml]="message"></div>
 
-    <div *ngIf="inputLabel && expectedInputValue" class="form-group mt-3">
+    <div *ngIf="inputLabel" class="form-group mt-3">
       <label for="confirmInput">{{ inputLabel }}</label>
-      <input type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
+
+      <input *ngIf="!isPasswordInput" type="text" id="confirmInput" name="confirmInput" [(ngModel)]="inputValue" />
+
+      <my-input-text inputId="confirmInput" [(ngModel)]="inputValue"></my-input-text>
     </div>
   </div>
 
index ec4e1d60fbf9e392ead3fadcc18971b20dbd0035..3bb8b9b2153c5157681be5423dffa732b8b1e4f0 100644 (file)
@@ -21,6 +21,8 @@ export class ConfirmComponent implements OnInit {
   inputValue = ''
   confirmButtonText = ''
 
+  isPasswordInput = false
+
   private openedModal: NgbModalRef
 
   constructor (
@@ -31,11 +33,27 @@ export class ConfirmComponent implements OnInit {
 
   ngOnInit () {
     this.confirmService.showConfirm.subscribe(
-      ({ title, message, expectedInputValue, inputLabel, confirmButtonText }) => {
+      payload => {
+        // Reinit fields
+        this.title = ''
+        this.message = ''
+        this.expectedInputValue = ''
+        this.inputLabel = ''
+        this.inputValue = ''
+        this.confirmButtonText = ''
+        this.isPasswordInput = false
+
+        const { type, title, message, confirmButtonText } = payload
+
         this.title = title
 
-        this.inputLabel = inputLabel
-        this.expectedInputValue = expectedInputValue
+        if (type === 'confirm-expected-input') {
+          this.inputLabel = payload.inputLabel
+          this.expectedInputValue = payload.expectedInputValue
+        } else if (type === 'confirm-password') {
+          this.inputLabel = $localize`Confirm your password`
+          this.isPasswordInput = true
+        }
 
         this.confirmButtonText = confirmButtonText || $localize`Confirm`
 
@@ -66,11 +84,13 @@ export class ConfirmComponent implements OnInit {
     this.openedModal = this.modalService.open(this.confirmModal, { centered: true })
 
     this.openedModal.result
-        .then(() => this.confirmService.confirmResponse.next(true))
+        .then(() => {
+          this.confirmService.confirmResponse.next({ confirmed: true, value: this.inputValue })
+        })
         .catch((reason: string) => {
           // If the reason was that the user used the back button, we don't care about the confirm dialog result
           if (!reason || reason !== POP_STATE_MODAL_DISMISS) {
-            this.confirmService.confirmResponse.next(false)
+            this.confirmService.confirmResponse.next({ confirmed: false, value: this.inputValue })
           }
         })
   }
index 3262853d89975adfc150b05b6bde8cacc9d9b204..b93de75eaf5fcc859afcae13fa478572faca4544 100644 (file)
@@ -61,6 +61,15 @@ export const USER_EXISTING_PASSWORD_VALIDATOR: BuildFormValidator = {
   }
 }
 
+export const USER_OTP_TOKEN_VALIDATOR: BuildFormValidator = {
+  VALIDATORS: [
+    Validators.required
+  ],
+  MESSAGES: {
+    required: $localize`OTP token is required.`
+  }
+}
+
 export const USER_PASSWORD_VALIDATOR = {
   VALIDATORS: [
     Validators.required,
index d24a5d58da6c031e5aafeaba44ec40ef609dfd1f..12d503f566e5a58ab492d0f39daf979a44bfc8ba 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
 import { AuthService, HtmlRendererService, Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { logger } from '@root-helpers/logger'
@@ -29,7 +29,7 @@ export class AbuseMessageModalComponent extends FormReactive implements OnInit {
   private abuse: UserAbuse
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private htmlRenderer: HtmlRendererService,
     private auth: AuthService,
index 2600da8dad9505315a3cc4c549ffb2b2a4e7d32f..4ad807d25371cc57898561f8326fe415eea0bc36 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { AbuseService } from '@app/shared/shared-moderation'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -20,7 +20,7 @@ export class ModerationCommentModalComponent extends FormReactive implements OnI
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private notifier: Notifier,
     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 (file)
index 0000000..f1b7e0e
--- /dev/null
@@ -0,0 +1,101 @@
+import { Injectable } from '@angular/core'
+import { AbstractControl, FormGroup } from '@angular/forms'
+import { wait } from '@root-helpers/utils'
+import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
+import { FormValidatorService } from './form-validator.service'
+
+export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
+export type FormReactiveValidationMessages = {
+  [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
+}
+
+@Injectable()
+export class FormReactiveService {
+
+  constructor (private formValidatorService: FormValidatorService) {
+
+  }
+
+  buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
+    const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
+
+    form.statusChanges.subscribe(async () => {
+      // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
+      await this.waitPendingCheck(form)
+
+      this.onStatusChanged({ form, formErrors, validationMessages })
+    })
+
+    return { form, formErrors, validationMessages }
+  }
+
+  async waitPendingCheck (form: FormGroup) {
+    if (form.status !== 'PENDING') return
+
+    // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
+    // return firstValueFrom(form.statusChanges.pipe(filter(status => status !== 'PENDING')))
+    // So we have to fallback to active wait :/
+
+    do {
+      await wait(10)
+    } while (form.status === 'PENDING')
+  }
+
+  markAllAsDirty (controlsArg: { [ key: string ]: AbstractControl }) {
+    const controls = controlsArg
+
+    for (const key of Object.keys(controls)) {
+      const control = controls[key]
+
+      if (control instanceof FormGroup) {
+        this.markAllAsDirty(control.controls)
+        continue
+      }
+
+      control.markAsDirty()
+    }
+  }
+
+  forceCheck (form: FormGroup, formErrors: any, validationMessages: FormReactiveValidationMessages) {
+    this.onStatusChanged({ form, formErrors, validationMessages, onlyDirty: false })
+  }
+
+  private onStatusChanged (options: {
+    form: FormGroup
+    formErrors: FormReactiveErrors
+    validationMessages: FormReactiveValidationMessages
+    onlyDirty?: boolean // default true
+  }) {
+    const { form, formErrors, validationMessages, onlyDirty = true } = options
+
+    for (const field of Object.keys(formErrors)) {
+      if (formErrors[field] && typeof formErrors[field] === 'object') {
+        this.onStatusChanged({
+          form: form.controls[field] as FormGroup,
+          formErrors: formErrors[field] as FormReactiveErrors,
+          validationMessages: validationMessages[field] as FormReactiveValidationMessages,
+          onlyDirty
+        })
+
+        continue
+      }
+
+      // clear previous error message (if any)
+      formErrors[field] = ''
+      const control = form.get(field)
+
+      if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
+
+      const staticMessages = validationMessages[field]
+      for (const key of Object.keys(control.errors)) {
+        const formErrorValue = control.errors[key]
+
+        // Try to find error message in static validation messages first
+        // Then check if the validator returns a string that is the error
+        if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
+        else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
+        else throw new Error('Form error value of ' + field + ' is invalid')
+      }
+    }
+  }
+}
index a19ffdd828e7d78c7388804cbd4d92079edb4519..d1e7be8026fe206ba13acefd2f752160b4c21e0c 100644 (file)
@@ -1,16 +1,9 @@
-
-import { AbstractControl, FormGroup } from '@angular/forms'
-import { wait } from '@root-helpers/utils'
+import { FormGroup } from '@angular/forms'
 import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
-import { FormValidatorService } from './form-validator.service'
-
-export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
-export type FormReactiveValidationMessages = {
-  [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
-}
+import { FormReactiveService, FormReactiveValidationMessages } from './form-reactive.service'
 
 export abstract class FormReactive {
-  protected abstract formValidatorService: FormValidatorService
+  protected abstract formReactiveService: FormReactiveService
   protected formChanged = false
 
   form: FormGroup
@@ -18,86 +11,22 @@ export abstract class FormReactive {
   validationMessages: FormReactiveValidationMessages
 
   buildForm (obj: BuildFormArgument, defaultValues: BuildFormDefaultValues = {}) {
-    const { formErrors, validationMessages, form } = this.formValidatorService.buildForm(obj, defaultValues)
+    const { formErrors, validationMessages, form } = this.formReactiveService.buildForm(obj, defaultValues)
 
     this.form = form
     this.formErrors = formErrors
     this.validationMessages = validationMessages
-
-    this.form.statusChanges.subscribe(async () => {
-      // FIXME: remove when https://github.com/angular/angular/issues/41519 is fixed
-      await this.waitPendingCheck()
-
-      this.onStatusChanged(this.form, this.formErrors, this.validationMessages)
-    })
   }
 
   protected async waitPendingCheck () {
-    if (this.form.status !== 'PENDING') return
-
-    // FIXME: the following line does not work: https://github.com/angular/angular/issues/41519
-    // return firstValueFrom(this.form.statusChanges.pipe(filter(status => status !== 'PENDING')))
-    // So we have to fallback to active wait :/
-
-    do {
-      await wait(10)
-    } while (this.form.status === 'PENDING')
+    return this.formReactiveService.waitPendingCheck(this.form)
   }
 
-  protected markAllAsDirty (controlsArg?: { [ key: string ]: AbstractControl }) {
-    const controls = controlsArg || this.form.controls
-
-    for (const key of Object.keys(controls)) {
-      const control = controls[key]
-
-      if (control instanceof FormGroup) {
-        this.markAllAsDirty(control.controls)
-        continue
-      }
-
-      control.markAsDirty()
-    }
+  protected markAllAsDirty () {
+    return this.formReactiveService.markAllAsDirty(this.form.controls)
   }
 
   protected forceCheck () {
-    this.onStatusChanged(this.form, this.formErrors, this.validationMessages, false)
-  }
-
-  private onStatusChanged (
-    form: FormGroup,
-    formErrors: FormReactiveErrors,
-    validationMessages: FormReactiveValidationMessages,
-    onlyDirty = true
-  ) {
-    for (const field of Object.keys(formErrors)) {
-      if (formErrors[field] && typeof formErrors[field] === 'object') {
-        this.onStatusChanged(
-          form.controls[field] as FormGroup,
-          formErrors[field] as FormReactiveErrors,
-          validationMessages[field] as FormReactiveValidationMessages,
-          onlyDirty
-        )
-        continue
-      }
-
-      // clear previous error message (if any)
-      formErrors[field] = ''
-      const control = form.get(field)
-
-      if (control.dirty) this.formChanged = true
-
-      if (!control || (onlyDirty && !control.dirty) || !control.enabled || !control.errors) continue
-
-      const staticMessages = validationMessages[field]
-      for (const key of Object.keys(control.errors)) {
-        const formErrorValue = control.errors[key]
-
-        // Try to find error message in static validation messages first
-        // Then check if the validator returns a string that is the error
-        if (staticMessages[key]) formErrors[field] += staticMessages[key] + ' '
-        else if (typeof formErrorValue === 'string') formErrors[field] += control.errors[key]
-        else throw new Error('Form error value of ' + field + ' is invalid')
-      }
-    }
+    return this.formReactiveService.forceCheck(this.form, this.formErrors, this.validationMessages)
   }
 }
index f67d5bb33529aab05cf120a36c4953800160526b..897008242383b0907d7d2512aa335fef7166f2c1 100644 (file)
@@ -1,7 +1,7 @@
 import { Injectable } from '@angular/core'
 import { AsyncValidatorFn, FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn } from '@angular/forms'
 import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
-import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive'
+import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
 
 @Injectable()
 export class FormValidatorService {
index 495785e7bf075358c40285c1fc5be0ac657e45b9..bff9862f2beb16b23bdc7ad3b1c501385be2e107 100644 (file)
@@ -1,4 +1,5 @@
 export * from './advanced-input-filter.component'
+export * from './form-reactive.service'
 export * from './form-reactive'
 export * from './form-validator.service'
 export * from './form-validator.service'
index d667ed663afb0dbfa9e412b54d7f6c90fb6920b4..aa4a1cba8347cddf1ead4174a258854c96863928 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, forwardRef, Input } from '@angular/core'
+import { Component, ElementRef, forwardRef, Input, ViewChild } from '@angular/core'
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
 import { Notifier } from '@app/core'
 
@@ -15,6 +15,8 @@ import { Notifier } from '@app/core'
   ]
 })
 export class InputTextComponent implements ControlValueAccessor {
+  @ViewChild('input') inputElement: ElementRef
+
   @Input() inputId = Math.random().toString(11).slice(2, 8) // id cannot be left empty or undefined
   @Input() value = ''
   @Input() autocomplete = 'off'
@@ -65,4 +67,10 @@ export class InputTextComponent implements ControlValueAccessor {
   update () {
     this.propagateChange(this.value)
   }
+
+  focus () {
+    const el: HTMLElement = this.inputElement.nativeElement
+
+    el.focus({ preventScroll: true })
+  }
 }
index 81f076db6b0d5532cc09db1d2bd7edc8259d4823..628affb56fd5af8ae225a148e3ce51cf2340fffb 100644 (file)
@@ -1,4 +1,3 @@
-
 import { InputMaskModule } from 'primeng/inputmask'
 import { NgModule } from '@angular/core'
 import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@@ -7,6 +6,7 @@ import { SharedGlobalIconModule } from '../shared-icons'
 import { SharedMainModule } from '../shared-main/shared-main.module'
 import { AdvancedInputFilterComponent } from './advanced-input-filter.component'
 import { DynamicFormFieldComponent } from './dynamic-form-field.component'
+import { FormReactiveService } from './form-reactive.service'
 import { FormValidatorService } from './form-validator.service'
 import { InputSwitchComponent } from './input-switch.component'
 import { InputTextComponent } from './input-text.component'
@@ -96,7 +96,8 @@ import { TimestampInputComponent } from './timestamp-input.component'
   ],
 
   providers: [
-    FormValidatorService
+    FormValidatorService,
+    FormReactiveService
   ]
 })
 export class SharedFormModule { }
index e4b74f3adbd861adebb76feedb69406da7105e59..93b3a93d6d4255def5a42c0f606353c05fb98f96 100644 (file)
@@ -27,13 +27,16 @@ export class AuthInterceptor implements HttpInterceptor {
                .pipe(
                  catchError((err: HttpErrorResponse) => {
                    const error = err.error as PeerTubeProblemDocument
+                   const isOTPMissingError = this.authService.isOTPMissingError(err)
 
-                   if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
-                     return this.handleTokenExpired(req, next)
-                   }
+                   if (!isOTPMissingError) {
+                     if (err.status === HttpStatusCode.UNAUTHORIZED_401 && error && error.code === OAuth2ErrorCode.INVALID_TOKEN) {
+                       return this.handleTokenExpired(req, next)
+                     }
 
-                   if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
-                     return this.handleNotAuthenticated(err)
+                     if (err.status === HttpStatusCode.UNAUTHORIZED_401) {
+                       return this.handleNotAuthenticated(err)
+                     }
                    }
 
                    return observableThrowError(() => err)
index 20be728f6c9bcabfb8a258aff4736abf143b1c8b..ec2fea528a4eafe170666d5b6029f6febdfcbddf 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { splitAndGetNotEmpty, UNIQUE_HOSTS_VALIDATOR } from '../form-validators/host-validators'
@@ -18,7 +18,7 @@ export class BatchDomainsModalComponent extends FormReactive implements OnInit {
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal
   ) {
     super()
index 78c9b338292f6c0d20e18f541e0d0b1ae5adce8c..d587a97091140c4410bc303492cf5d1fb683032b 100644 (file)
@@ -2,7 +2,7 @@ import { mapValues, pickBy } from 'lodash-es'
 import { Component, OnInit, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
 import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { Account } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -26,7 +26,7 @@ export class AccountReportComponent extends FormReactive implements OnInit {
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private abuseService: AbuseService,
     private notifier: Notifier
index 7c0907ce475f65934827ff816b5d095498489c15..e35d70c8f972c22c0a8940d12a8f98bfa68cca05 100644 (file)
@@ -2,7 +2,7 @@ import { mapValues, pickBy } from 'lodash-es'
 import { Component, Input, OnInit, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
 import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { VideoComment } from '@app/shared/shared-video-comment'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -27,7 +27,7 @@ export class CommentReportComponent extends FormReactive implements OnInit {
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private abuseService: AbuseService,
     private notifier: Notifier
index 38dd929108496cedc2e0c12a92f9aff392a62150..16be8e0a1466f6f8b6fa999a9e72aeb7612347c3 100644 (file)
@@ -3,7 +3,7 @@ import { Component, Input, OnInit, ViewChild } from '@angular/core'
 import { DomSanitizer } from '@angular/platform-browser'
 import { Notifier } from '@app/core'
 import { ABUSE_REASON_VALIDATOR } from '@app/shared/form-validators/abuse-validators'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse'
@@ -27,7 +27,7 @@ export class VideoReportComponent extends FormReactive implements OnInit {
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private abuseService: AbuseService,
     private notifier: Notifier,
index 617408f2a641b389c3138c45702863cc0aac16d5..27dcf043ab066ea2cc75c768c98c727badaf0ad3 100644 (file)
@@ -2,7 +2,7 @@ import { forkJoin } from 'rxjs'
 import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
 import { prepareIcu } from '@app/helpers'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
 import { User } from '@shared/models'
@@ -25,7 +25,7 @@ export class UserBanModalComponent extends FormReactive implements OnInit {
   modalMessage = ''
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private notifier: Notifier,
     private userAdminService: UserAdminService,
index f8b22a3f61175be55cccab06dda85a0f162184f5..3ff53443af7b6645079c3a0edbe9293e2851cc37 100644 (file)
@@ -1,7 +1,7 @@
 import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
 import { Notifier } from '@app/core'
 import { prepareIcu } from '@app/helpers'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { Video } from '@app/shared/shared-main'
 import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
 import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
@@ -25,7 +25,7 @@ export class VideoBlockComponent extends FormReactive implements OnInit {
   private openedModal: NgbModalRef
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private modalService: NgbModal,
     private videoBlocklistService: VideoBlockService,
     private notifier: Notifier
index 13e2e54248de6723592d28a3dc7f30bc4bba2cbc..c2c30d38b4eb140636bb3f33562c5108719491f2 100644 (file)
@@ -1,7 +1,7 @@
 import { Subject, Subscription } from 'rxjs'
 import { Component, Input, OnDestroy, OnInit } from '@angular/core'
 import { AuthService, Notifier, ServerService, ThemeService, UserService } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { HTMLServerConfig, User, UserUpdateMe } from '@shared/models'
 import { SelectOptionsItem } from 'src/types'
 
@@ -22,7 +22,7 @@ export class UserInterfaceSettingsComponent extends FormReactive implements OnIn
   private serverConfig: HTMLServerConfig
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private userService: UserService,
index 7d6b69469c7c44e86d933586ae8059847a62df45..af0870f127c8e7358dad6347eb7809cae49760a9 100644 (file)
@@ -3,7 +3,7 @@ import { Subject, Subscription } from 'rxjs'
 import { first } from 'rxjs/operators'
 import { Component, Input, OnDestroy, OnInit } from '@angular/core'
 import { AuthService, Notifier, ServerService, User, UserService } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { UserUpdateMe } from '@shared/models'
 import { NSFWPolicyType } from '@shared/models/videos/nsfw-policy.type'
 
@@ -22,7 +22,7 @@ export class UserVideoSettingsComponent extends FormReactive implements OnInit,
   formValuesWatcher: Subscription
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private userService: UserService,
index 7bcfdd8aae43e20d1b301b36f190da33336dab14..61bcd53453abd95fc03ece8a966581fc757ab263 100644 (file)
@@ -1,6 +1,6 @@
 import { Component, Input, OnInit } from '@angular/core'
 import { Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { logger } from '@root-helpers/logger'
 import { USER_HANDLE_VALIDATOR } from '../form-validators/user-validators'
 
@@ -15,7 +15,7 @@ export class RemoteSubscribeComponent extends FormReactive implements OnInit {
   @Input() showHelp = false
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private notifier: Notifier
   ) {
     super()
index 8f90f251598f0127924666d6f912c7110b8129d4..20e60486dae5c8d563490c228bf7efd1bcbb7891 100644 (file)
@@ -1,4 +1,5 @@
 export * from './user-admin.service'
 export * from './user-signup.service'
+export * from './two-factor.service'
 
 export * from './shared-users.module'
index 2a1dadf20f2bbc10663f3f3315a03409aaff19d4..5a1675dc94c25565bf0a19f4116503353adaf376 100644 (file)
@@ -1,6 +1,7 @@
 
 import { NgModule } from '@angular/core'
 import { SharedMainModule } from '../shared-main/shared-main.module'
+import { TwoFactorService } from './two-factor.service'
 import { UserAdminService } from './user-admin.service'
 import { UserSignupService } from './user-signup.service'
 
@@ -15,7 +16,8 @@ import { UserSignupService } from './user-signup.service'
 
   providers: [
     UserSignupService,
-    UserAdminService
+    UserAdminService,
+    TwoFactorService
   ]
 })
 export 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 (file)
index 0000000..9ff916f
--- /dev/null
@@ -0,0 +1,52 @@
+import { catchError } from 'rxjs/operators'
+import { HttpClient } from '@angular/common/http'
+import { Injectable } from '@angular/core'
+import { RestExtractor, UserService } from '@app/core'
+import { TwoFactorEnableResult } from '@shared/models'
+
+@Injectable()
+export class TwoFactorService {
+  constructor (
+    private authHttp: HttpClient,
+    private restExtractor: RestExtractor
+  ) { }
+
+  // ---------------------------------------------------------------------------
+
+  requestTwoFactor (options: {
+    userId: number
+    currentPassword: string
+  }) {
+    const { userId, currentPassword } = options
+
+    const url = UserService.BASE_USERS_URL + userId + '/two-factor/request'
+
+    return this.authHttp.post<TwoFactorEnableResult>(url, { currentPassword })
+    .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  confirmTwoFactorRequest (options: {
+    userId: number
+    requestToken: string
+    otpToken: string
+  }) {
+    const { userId, requestToken, otpToken } = options
+
+    const url = UserService.BASE_USERS_URL + userId + '/two-factor/confirm-request'
+
+    return this.authHttp.post(url, { requestToken, otpToken })
+    .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+
+  disableTwoFactor (options: {
+    userId: number
+    currentPassword?: string
+  }) {
+    const { userId, currentPassword } = options
+
+    const url = UserService.BASE_USERS_URL + userId + '/two-factor/disable'
+
+    return this.authHttp.post(url, { currentPassword })
+      .pipe(catchError(err => this.restExtractor.handleError(err)))
+  }
+}
index e019fdd265cf4728ca8c3f8946a334fcc69a2c83..f81de7c6b0c8f929ce69d68fa4d0fe52ffcc1d95 100644 (file)
@@ -3,7 +3,7 @@ import { Subject, Subscription } from 'rxjs'
 import { debounceTime, filter } from 'rxjs/operators'
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnChanges, OnDestroy, OnInit, SimpleChanges } from '@angular/core'
 import { AuthService, DisableForReuseHook, Notifier } from '@app/core'
-import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
+import { FormReactive, FormReactiveService } from '@app/shared/shared-forms'
 import { secondsToTime } from '@shared/core-utils'
 import {
   Video,
@@ -59,7 +59,7 @@ export class VideoAddToPlaylistComponent extends FormReactive implements OnInit,
   private pendingAddId: number
 
   constructor (
-    protected formValidatorService: FormValidatorService,
+    protected formReactiveService: FormReactiveService,
     private authService: AuthService,
     private notifier: Notifier,
     private videoPlaylistService: VideoPlaylistService,
index 9328a27a276c84a1b100c671b9eed36c86817acc..a5d06de9866275bb7e9f1f7aa2f8b0daf6625bd9 100644 (file)
@@ -3,32 +3,32 @@
 
 @import './_bootstrap-variables';
 
-@import '~bootstrap/scss/functions';
-@import '~bootstrap/scss/variables';
-@import '~bootstrap/scss/maps';
-@import '~bootstrap/scss/mixins';
-@import '~bootstrap/scss/utilities';
-
-@import '~bootstrap/scss/root';
-@import '~bootstrap/scss/reboot';
-@import '~bootstrap/scss/type';
-@import '~bootstrap/scss/grid';
-@import '~bootstrap/scss/forms';
-@import '~bootstrap/scss/buttons';
-@import '~bootstrap/scss/dropdown';
-@import '~bootstrap/scss/button-group';
-@import '~bootstrap/scss/nav';
-@import '~bootstrap/scss/card';
-@import '~bootstrap/scss/accordion';
-@import '~bootstrap/scss/alert';
-@import '~bootstrap/scss/close';
-@import '~bootstrap/scss/modal';
-@import '~bootstrap/scss/tooltip';
-@import '~bootstrap/scss/popover';
-@import '~bootstrap/scss/spinners';
-
-@import '~bootstrap/scss/helpers';
-@import '~bootstrap/scss/utilities/api';
+@import 'bootstrap/scss/functions';
+@import 'bootstrap/scss/variables';
+@import 'bootstrap/scss/maps';
+@import 'bootstrap/scss/mixins';
+@import 'bootstrap/scss/utilities';
+
+@import 'bootstrap/scss/root';
+@import 'bootstrap/scss/reboot';
+@import 'bootstrap/scss/type';
+@import 'bootstrap/scss/grid';
+@import 'bootstrap/scss/forms';
+@import 'bootstrap/scss/buttons';
+@import 'bootstrap/scss/dropdown';
+@import 'bootstrap/scss/button-group';
+@import 'bootstrap/scss/nav';
+@import 'bootstrap/scss/card';
+@import 'bootstrap/scss/accordion';
+@import 'bootstrap/scss/alert';
+@import 'bootstrap/scss/close';
+@import 'bootstrap/scss/modal';
+@import 'bootstrap/scss/tooltip';
+@import 'bootstrap/scss/popover';
+@import 'bootstrap/scss/spinners';
+
+@import 'bootstrap/scss/helpers';
+@import 'bootstrap/scss/utilities/api';
 
 .accordion {
   --bs-accordion-color: #{pvar(--mainForegroundColor)};
index c02359f2867dd8f97c0c61e2fbaeedfea23dad87..02fa7f1f0cfacd8cef5f5444a0473ec221544d52 100644 (file)
@@ -1,6 +1,6 @@
 @use 'sass:math';
 @use 'sass:color';
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
 
 $small-view: 800px;
 $mobile-view: 500px;
index 78e3a6de3727f503d497fe79bb5c2cf54d9acc20..e231e4fedd1cc57614022b7d5bf5a869c6f01f31 100644 (file)
@@ -15,7 +15,7 @@ $ng-select-height: 30px;
 $ng-select-value-padding-left: 15px;
 $ng-select-value-font-size: $form-input-font-size;
 
-@import '~@ng-select/ng-select/scss/default.theme';
+@import '@ng-select/ng-select/scss/default.theme';
 
 .ng-select {
   font-size: $ng-select-value-font-size;
index 47b8adda486702fdafb3fbf923255050f84b2d0c..d5f24dd913632f2a3960e88adb17dd7110cdf5b0 100644 (file)
@@ -1,4 +1,4 @@
-@use '~bootstrap/scss/functions' as *;
+@use 'bootstrap/scss/functions' as *;
 
 $primary-foreground-color: #fff;
 $primary-foreground-opacity: 0.9;
index 2d8aaf1eaa61279d4b8699bf3b4a5c049fc11a2b..f94ec620977739f048214b980c94b0a18c30702c 100644 (file)
@@ -10,6 +10,11 @@ webserver:
   hostname: 'localhost'
   port: 9000
 
+# Secrets you need to generate the first time you run PeerTube
+secrets:
+  # Generate one using `openssl rand -hex 32`
+  peertube: ''
+
 rates_limit:
   api:
     # 50 attempts in 10 seconds
index ca93874d23f6c20c97fc079e6a0b0e5d263457f8..ef93afc19322782a6c6e5e76f27e0738781969a4 100644 (file)
@@ -5,6 +5,9 @@ listen:
 webserver:
   https: false
 
+secrets:
+  peertube: 'my super dev secret'
+
 database:
   hostname: 'localhost'
   port: 5432
index 46d574e4237252a182fd020f6acfc87111f20925..e37ff9b8a7558b5b64394db7058a9c8c032cfb7c 100644 (file)
@@ -8,6 +8,11 @@ webserver:
   hostname: 'example.com'
   port: 443
 
+# Secrets you need to generate the first time you run PeerTube
+secret:
+  # Generate one using `openssl rand -hex 32`
+  peertube: ''
+
 rates_limit:
   api:
     # 50 attempts in 10 seconds
index a87642bd834493be38b513f472aac8e30b12b7e8..48cf0c0f679cc95d35225f89f3347aee1b365008 100644 (file)
@@ -5,6 +5,9 @@ listen:
 webserver:
   https: false
 
+secrets:
+  peertube: 'my super secret'
+
 rates_limit:
   signup:
     window: 10 minutes
index dd913896d812b71f3106a7a5f55f318db813d677..6dcf262531579cd1426743efacfe521c84c73c94 100644 (file)
     "node-media-server": "^2.1.4",
     "nodemailer": "^6.0.0",
     "opentelemetry-instrumentation-sequelize": "^0.29.0",
+    "otpauth": "^8.0.3",
     "p-queue": "^6",
     "parse-torrent": "^9.1.0",
     "password-generator": "^2.0.2",
index 2085c67d91ebd6b5ab985c3e0cf70477eb307032..417387a4fc14c440865afb792454c64b5d28af86 100644 (file)
--- a/server.ts
+++ b/server.ts
@@ -45,7 +45,12 @@ try {
 
 import { checkConfig, checkActivityPubUrls, checkFFmpegVersion } from './server/initializers/checker-after-init'
 
-checkConfig()
+try {
+  checkConfig()
+} catch (err) {
+  logger.error('Config error.', { err })
+  process.exit(-1)
+}
 
 // Trust our proxy (IP forwarding...)
 app.set('trust proxy', CONFIG.TRUST_PROXY)
index 07b9ae395a619ccb3dc445af749887535f60ad94..a8677a1d325a78afdfaf19d408c52fde4a5de19d 100644 (file)
@@ -51,6 +51,7 @@ import { myVideosHistoryRouter } from './my-history'
 import { myNotificationsRouter } from './my-notifications'
 import { mySubscriptionsRouter } from './my-subscriptions'
 import { myVideoPlaylistsRouter } from './my-video-playlists'
+import { twoFactorRouter } from './two-factor'
 
 const auditLogger = auditLoggerFactory('users')
 
@@ -66,6 +67,7 @@ const askSendEmailLimiter = buildRateLimiter({
 })
 
 const usersRouter = express.Router()
+usersRouter.use('/', twoFactorRouter)
 usersRouter.use('/', tokensRouter)
 usersRouter.use('/', myNotificationsRouter)
 usersRouter.use('/', mySubscriptionsRouter)
index 012a497910a5667d4728c6ca2a8f146b075613fe..c6afea67c20058103c58ee375d027b0cd00a9bfd 100644 (file)
@@ -1,8 +1,9 @@
 import express from 'express'
 import { logger } from '@server/helpers/logger'
 import { CONFIG } from '@server/initializers/config'
+import { OTP } from '@server/initializers/constants'
 import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
-import { handleOAuthToken } from '@server/lib/auth/oauth'
+import { handleOAuthToken, MissingTwoFactorError } from '@server/lib/auth/oauth'
 import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
 import { Hooks } from '@server/lib/plugins/hooks'
 import { asyncMiddleware, authenticate, buildRateLimiter, openapiOperationDoc } from '@server/middlewares'
@@ -79,6 +80,10 @@ async function handleToken (req: express.Request, res: express.Response, next: e
   } catch (err) {
     logger.warn('Login error', { err })
 
+    if (err instanceof MissingTwoFactorError) {
+      res.set(OTP.HEADER_NAME, OTP.HEADER_REQUIRED_VALUE)
+    }
+
     return res.fail({
       status: err.code,
       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 (file)
index 0000000..e6ae9e4
--- /dev/null
@@ -0,0 +1,95 @@
+import express from 'express'
+import { generateOTPSecret, isOTPValid } from '@server/helpers/otp'
+import { encrypt } from '@server/helpers/peertube-crypto'
+import { CONFIG } from '@server/initializers/config'
+import { Redis } from '@server/lib/redis'
+import { asyncMiddleware, authenticate, usersCheckCurrentPasswordFactory } from '@server/middlewares'
+import {
+  confirmTwoFactorValidator,
+  disableTwoFactorValidator,
+  requestOrConfirmTwoFactorValidator
+} from '@server/middlewares/validators/two-factor'
+import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
+
+const twoFactorRouter = express.Router()
+
+twoFactorRouter.post('/:id/two-factor/request',
+  authenticate,
+  asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
+  asyncMiddleware(requestOrConfirmTwoFactorValidator),
+  asyncMiddleware(requestTwoFactor)
+)
+
+twoFactorRouter.post('/:id/two-factor/confirm-request',
+  authenticate,
+  asyncMiddleware(requestOrConfirmTwoFactorValidator),
+  confirmTwoFactorValidator,
+  asyncMiddleware(confirmRequestTwoFactor)
+)
+
+twoFactorRouter.post('/:id/two-factor/disable',
+  authenticate,
+  asyncMiddleware(usersCheckCurrentPasswordFactory(req => req.params.id)),
+  asyncMiddleware(disableTwoFactorValidator),
+  asyncMiddleware(disableTwoFactor)
+)
+
+// ---------------------------------------------------------------------------
+
+export {
+  twoFactorRouter
+}
+
+// ---------------------------------------------------------------------------
+
+async function requestTwoFactor (req: express.Request, res: express.Response) {
+  const user = res.locals.user
+
+  const { secret, uri } = generateOTPSecret(user.email)
+
+  const encryptedSecret = await encrypt(secret, CONFIG.SECRETS.PEERTUBE)
+  const requestToken = await Redis.Instance.setTwoFactorRequest(user.id, encryptedSecret)
+
+  return res.json({
+    otpRequest: {
+      requestToken,
+      secret,
+      uri
+    }
+  } as TwoFactorEnableResult)
+}
+
+async function confirmRequestTwoFactor (req: express.Request, res: express.Response) {
+  const requestToken = req.body.requestToken
+  const otpToken = req.body.otpToken
+  const user = res.locals.user
+
+  const encryptedSecret = await Redis.Instance.getTwoFactorRequestToken(user.id, requestToken)
+  if (!encryptedSecret) {
+    return res.fail({
+      message: 'Invalid request token',
+      status: HttpStatusCode.FORBIDDEN_403
+    })
+  }
+
+  if (await isOTPValid({ encryptedSecret, token: otpToken }) !== true) {
+    return res.fail({
+      message: 'Invalid OTP token',
+      status: HttpStatusCode.FORBIDDEN_403
+    })
+  }
+
+  user.otpSecret = encryptedSecret
+  await user.save()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
+
+async function disableTwoFactor (req: express.Request, res: express.Response) {
+  const user = res.locals.user
+
+  user.otpSecret = null
+  await user.save()
+
+  return res.sendStatus(HttpStatusCode.NO_CONTENT_204)
+}
index c762f6a29331523b203354d17b22292e26997627..73bd994c17c7618a69db3eb65de751681f8ff6aa 100644 (file)
@@ -6,7 +6,7 @@
 */
 
 import { exec, ExecOptions } from 'child_process'
-import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions } from 'crypto'
+import { ED25519KeyPairOptions, generateKeyPair, randomBytes, RSAKeyPairOptions, scrypt } from 'crypto'
 import { truncate } from 'lodash'
 import { pipeline } from 'stream'
 import { URL } from 'url'
@@ -311,7 +311,17 @@ function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A)
   }
 }
 
+// eslint-disable-next-line max-len
+function 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> {
+  return function promisified (arg1: T, arg2: U, arg3: V): Promise<A> {
+    return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
+      func.apply(null, [ arg1, arg2, arg3, (err: any, res: A) => err ? reject(err) : resolve(res) ])
+    })
+  }
+}
+
 const randomBytesPromise = promisify1<number, Buffer>(randomBytes)
+const scryptPromise = promisify3<string, string, number, Buffer>(scrypt)
 const execPromise2 = promisify2<string, any, string>(exec)
 const execPromise = promisify1<string, string>(exec)
 const pipelinePromise = promisify(pipeline)
@@ -339,6 +349,8 @@ export {
   promisify1,
   promisify2,
 
+  scryptPromise,
+
   randomBytesPromise,
 
   generateRSAKeyPairPromise,
diff --git a/server/helpers/otp.ts b/server/helpers/otp.ts
new file mode 100644 (file)
index 0000000..a32cc96
--- /dev/null
@@ -0,0 +1,58 @@
+import { Secret, TOTP } from 'otpauth'
+import { CONFIG } from '@server/initializers/config'
+import { WEBSERVER } from '@server/initializers/constants'
+import { decrypt } from './peertube-crypto'
+
+async function isOTPValid (options: {
+  encryptedSecret: string
+  token: string
+}) {
+  const { token, encryptedSecret } = options
+
+  const secret = await decrypt(encryptedSecret, CONFIG.SECRETS.PEERTUBE)
+
+  const totp = new TOTP({
+    ...baseOTPOptions(),
+
+    secret
+  })
+
+  const delta = totp.validate({
+    token,
+    window: 1
+  })
+
+  if (delta === null) return false
+
+  return true
+}
+
+function generateOTPSecret (email: string) {
+  const totp = new TOTP({
+    ...baseOTPOptions(),
+
+    label: email,
+    secret: new Secret()
+  })
+
+  return {
+    secret: totp.secret.base32,
+    uri: totp.toString()
+  }
+}
+
+export {
+  isOTPValid,
+  generateOTPSecret
+}
+
+// ---------------------------------------------------------------------------
+
+function baseOTPOptions () {
+  return {
+    issuer: WEBSERVER.HOST,
+    algorithm: 'SHA1',
+    digits: 6,
+    period: 30
+  }
+}
index 8aca509009bb1649aefce6fe7abdc9046e67a9a2..ae7d11800b431888b54a1634eaeaa78480fc54ae 100644 (file)
@@ -1,11 +1,11 @@
 import { compare, genSalt, hash } from 'bcrypt'
-import { createSign, createVerify } from 'crypto'
+import { createCipheriv, createDecipheriv, createSign, createVerify } from 'crypto'
 import { Request } from 'express'
 import { cloneDeep } from 'lodash'
 import { sha256 } from '@shared/extra-utils'
-import { BCRYPT_SALT_SIZE, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
+import { BCRYPT_SALT_SIZE, ENCRYPTION, HTTP_SIGNATURE, PRIVATE_RSA_KEY_SIZE } from '../initializers/constants'
 import { MActor } from '../types/models'
-import { generateRSAKeyPairPromise, promisify1, promisify2 } from './core-utils'
+import { generateRSAKeyPairPromise, promisify1, promisify2, randomBytesPromise, scryptPromise } from './core-utils'
 import { jsonld } from './custom-jsonld-signature'
 import { logger } from './logger'
 
@@ -21,9 +21,13 @@ function createPrivateAndPublicKeys () {
   return generateRSAKeyPairPromise(PRIVATE_RSA_KEY_SIZE)
 }
 
+// ---------------------------------------------------------------------------
 // User password checks
+// ---------------------------------------------------------------------------
 
 function comparePassword (plainPassword: string, hashPassword: string) {
+  if (!plainPassword) return Promise.resolve(false)
+
   return bcryptComparePromise(plainPassword, hashPassword)
 }
 
@@ -33,7 +37,9 @@ async function cryptPassword (password: string) {
   return bcryptHashPromise(password, salt)
 }
 
+// ---------------------------------------------------------------------------
 // HTTP Signature
+// ---------------------------------------------------------------------------
 
 function isHTTPSignatureDigestValid (rawBody: Buffer, req: Request): boolean {
   if (req.headers[HTTP_SIGNATURE.HEADER_NAME] && req.headers['digest']) {
@@ -62,7 +68,9 @@ function parseHTTPSignature (req: Request, clockSkew?: number) {
   return parsed
 }
 
+// ---------------------------------------------------------------------------
 // JSONLD
+// ---------------------------------------------------------------------------
 
 function isJsonLDSignatureVerified (fromActor: MActor, signedDocument: any): Promise<boolean> {
   if (signedDocument.signature.type === 'RsaSignature2017') {
@@ -112,12 +120,42 @@ async function signJsonLDObject <T> (byActor: MActor, data: T) {
   return Object.assign(data, { signature })
 }
 
+// ---------------------------------------------------------------------------
+
 function buildDigest (body: any) {
   const rawBody = typeof body === 'string' ? body : JSON.stringify(body)
 
   return 'SHA-256=' + sha256(rawBody, 'base64')
 }
 
+// ---------------------------------------------------------------------------
+// Encryption
+// ---------------------------------------------------------------------------
+
+async function encrypt (str: string, secret: string) {
+  const iv = await randomBytesPromise(ENCRYPTION.IV)
+
+  const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
+  const cipher = createCipheriv(ENCRYPTION.ALGORITHM, key, iv)
+
+  let encrypted = iv.toString(ENCRYPTION.ENCODING) + ':'
+  encrypted += cipher.update(str, 'utf8', ENCRYPTION.ENCODING)
+  encrypted += cipher.final(ENCRYPTION.ENCODING)
+
+  return encrypted
+}
+
+async function decrypt (encryptedArg: string, secret: string) {
+  const [ ivStr, encryptedStr ] = encryptedArg.split(':')
+
+  const iv = Buffer.from(ivStr, 'hex')
+  const key = await scryptPromise(secret, ENCRYPTION.SALT, 32)
+
+  const decipher = createDecipheriv(ENCRYPTION.ALGORITHM, key, iv)
+
+  return decipher.update(encryptedStr, ENCRYPTION.ENCODING, 'utf8') + decipher.final('utf8')
+}
+
 // ---------------------------------------------------------------------------
 
 export {
@@ -129,7 +167,10 @@ export {
   comparePassword,
   createPrivateAndPublicKeys,
   cryptPassword,
-  signJsonLDObject
+  signJsonLDObject,
+
+  encrypt,
+  decrypt
 }
 
 // ---------------------------------------------------------------------------
index 42839d1c97434ac445d5694481249c393f37b22c..c83fef425af8fc4ddd46ade9745000e54fff27e3 100644 (file)
@@ -42,6 +42,7 @@ function checkConfig () {
     logger.warn('services.csp-logger configuration has been renamed to csp.report_uri. Please update your configuration file.')
   }
 
+  checkSecretsConfig()
   checkEmailConfig()
   checkNSFWPolicyConfig()
   checkLocalRedundancyConfig()
@@ -103,6 +104,12 @@ export {
 
 // ---------------------------------------------------------------------------
 
+function checkSecretsConfig () {
+  if (!CONFIG.SECRETS.PEERTUBE) {
+    throw new Error('secrets.peertube is missing in config. Generate one using `openssl rand -hex 32`')
+  }
+}
+
 function checkEmailConfig () {
   if (!isEmailEnabled()) {
     if (CONFIG.SIGNUP.ENABLED && CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
index 1fd4ba248372f6cdb15b94320e6e648bd04e8ff7..c9268b156fbbc79ae119dbccf6d6ab775b34b88e 100644 (file)
@@ -11,6 +11,7 @@ const config: IConfig = require('config')
 function checkMissedConfig () {
   const required = [ 'listen.port', 'listen.hostname',
     'webserver.https', 'webserver.hostname', 'webserver.port',
+    'secrets.peertube',
     'trust_proxy',
     'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
     'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
index 287bf6f6df69c1efced967133d0d58a920ac1ed0..a5a0d4e46dd385b5af1a6ced51ac2c2c879184f1 100644 (file)
@@ -20,6 +20,9 @@ const CONFIG = {
     PORT: config.get<number>('listen.port'),
     HOSTNAME: config.get<string>('listen.hostname')
   },
+  SECRETS: {
+    PEERTUBE: config.get<string>('secrets.peertube')
+  },
   DATABASE: {
     DBNAME: config.has('database.name') ? config.get<string>('database.name') : 'peertube' + config.get<string>('database.suffix'),
     HOSTNAME: config.get<string>('database.hostname'),
index 9257ebf93ff6c1691827e25ec88c4e1130075f5f..cab61948acfff87efb9394f46d1d4fe7e5ea2b0d 100644 (file)
@@ -1,5 +1,5 @@
 import { RepeatOptions } from 'bullmq'
-import { randomBytes } from 'crypto'
+import { Encoding, randomBytes } from 'crypto'
 import { invert } from 'lodash'
 import { join } from 'path'
 import { randomInt, root } from '@shared/core-utils'
@@ -25,7 +25,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
 
 // ---------------------------------------------------------------------------
 
-const LAST_MIGRATION_VERSION = 740
+const LAST_MIGRATION_VERSION = 745
 
 // ---------------------------------------------------------------------------
 
@@ -637,9 +637,18 @@ let PRIVATE_RSA_KEY_SIZE = 2048
 // Password encryption
 const BCRYPT_SALT_SIZE = 10
 
+const ENCRYPTION = {
+  ALGORITHM: 'aes-256-cbc',
+  IV: 16,
+  SALT: 'peertube',
+  ENCODING: 'hex' as Encoding
+}
+
 const USER_PASSWORD_RESET_LIFETIME = 60000 * 60 // 60 minutes
 const USER_PASSWORD_CREATE_LIFETIME = 60000 * 60 * 24 * 7 // 7 days
 
+const TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME = 60000 * 10 // 10 minutes
+
 const USER_EMAIL_VERIFY_LIFETIME = 60000 * 60 // 60 minutes
 
 const NSFW_POLICY_TYPES: { [ id: string ]: NSFWPolicyType } = {
@@ -805,6 +814,10 @@ const REDUNDANCY = {
 }
 
 const ACCEPT_HEADERS = [ 'html', 'application/json' ].concat(ACTIVITY_PUB.POTENTIAL_ACCEPT_HEADERS)
+const OTP = {
+  HEADER_NAME: 'x-peertube-otp',
+  HEADER_REQUIRED_VALUE: 'required; app'
+}
 
 const ASSETS_PATH = {
   DEFAULT_AUDIO_BACKGROUND: join(root(), 'dist', 'server', 'assets', 'default-audio-background.jpg'),
@@ -953,6 +966,7 @@ const VIDEO_FILTERS = {
 export {
   WEBSERVER,
   API_VERSION,
+  ENCRYPTION,
   VIDEO_LIVE,
   PEERTUBE_VERSION,
   LAZY_STATIC_PATHS,
@@ -986,6 +1000,7 @@ export {
   FOLLOW_STATES,
   DEFAULT_USER_THEME_NAME,
   SERVER_ACTOR_NAME,
+  TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
   PLUGIN_GLOBAL_CSS_FILE_NAME,
   PLUGIN_GLOBAL_CSS_PATH,
   PRIVATE_RSA_KEY_SIZE,
@@ -1041,6 +1056,7 @@ export {
   PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME,
   ASSETS_PATH,
   FILES_CONTENT_HASH,
+  OTP,
   loadLanguages,
   buildLanguages,
   generateContentHash
diff --git a/server/initializers/migrations/0745-user-otp.ts b/server/initializers/migrations/0745-user-otp.ts
new file mode 100644 (file)
index 0000000..157308e
--- /dev/null
@@ -0,0 +1,29 @@
+import * as Sequelize from 'sequelize'
+
+async function up (utils: {
+  transaction: Sequelize.Transaction
+  queryInterface: Sequelize.QueryInterface
+  sequelize: Sequelize.Sequelize
+  db: any
+}): Promise<void> {
+  const { transaction } = utils
+
+  const data = {
+    type: Sequelize.STRING,
+    defaultValue: null,
+    allowNull: true
+  }
+  await utils.queryInterface.addColumn('user', 'otpSecret', data, { transaction })
+
+}
+
+async function down (utils: {
+  queryInterface: Sequelize.QueryInterface
+  transaction: Sequelize.Transaction
+}) {
+}
+
+export {
+  up,
+  down
+}
index fa18873158fca4f174c644301b2b5bbcc8cb5e25..35b05ec5abd25cbe9271e563ebdbe6603f0ce432 100644 (file)
@@ -9,11 +9,23 @@ import OAuth2Server, {
   UnsupportedGrantTypeError
 } from '@node-oauth/oauth2-server'
 import { randomBytesPromise } from '@server/helpers/core-utils'
+import { isOTPValid } from '@server/helpers/otp'
 import { MOAuthClient } from '@server/types/models'
 import { sha1 } from '@shared/extra-utils'
-import { OAUTH_LIFETIME } from '../../initializers/constants'
+import { HttpStatusCode } from '@shared/models'
+import { OAUTH_LIFETIME, OTP } from '../../initializers/constants'
 import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
 
+class MissingTwoFactorError extends Error {
+  code = HttpStatusCode.UNAUTHORIZED_401
+  name = 'missing_two_factor'
+}
+
+class InvalidTwoFactorError extends Error {
+  code = HttpStatusCode.BAD_REQUEST_400
+  name = 'invalid_two_factor'
+}
+
 /**
  *
  * Reimplement some functions of OAuth2Server to inject external auth methods
@@ -94,6 +106,9 @@ function handleOAuthAuthenticate (
 }
 
 export {
+  MissingTwoFactorError,
+  InvalidTwoFactorError,
+
   handleOAuthToken,
   handleOAuthAuthenticate
 }
@@ -118,6 +133,16 @@ async function handlePasswordGrant (options: {
   const user = await getUser(request.body.username, request.body.password, bypassLogin)
   if (!user) throw new InvalidGrantError('Invalid grant: user credentials are invalid')
 
+  if (user.otpSecret) {
+    if (!request.headers[OTP.HEADER_NAME]) {
+      throw new MissingTwoFactorError('Missing two factor header')
+    }
+
+    if (await isOTPValid({ encryptedSecret: user.otpSecret, token: request.headers[OTP.HEADER_NAME] }) !== true) {
+      throw new InvalidTwoFactorError('Invalid two factor header')
+    }
+  }
+
   const token = await buildToken()
 
   return saveToken(token, client, user, { bypassLogin })
index 9b3c7230046c9b2589381f2920f93666657d2e23..b7523492a5895ba50ce3c487e7d1cd0f9161d8fc 100644 (file)
@@ -9,6 +9,7 @@ import {
   CONTACT_FORM_LIFETIME,
   RESUMABLE_UPLOAD_SESSION_LIFETIME,
   TRACKER_RATE_LIMITS,
+  TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME,
   USER_EMAIL_VERIFY_LIFETIME,
   USER_PASSWORD_CREATE_LIFETIME,
   USER_PASSWORD_RESET_LIFETIME,
@@ -108,10 +109,24 @@ class Redis {
     return this.removeValue(this.generateResetPasswordKey(userId))
   }
 
-  async getResetPasswordLink (userId: number) {
+  async getResetPasswordVerificationString (userId: number) {
     return this.getValue(this.generateResetPasswordKey(userId))
   }
 
+  /* ************ Two factor auth request ************ */
+
+  async setTwoFactorRequest (userId: number, otpSecret: string) {
+    const requestToken = await generateRandomString(32)
+
+    await this.setValue(this.generateTwoFactorRequestKey(userId, requestToken), otpSecret, TWO_FACTOR_AUTH_REQUEST_TOKEN_LIFETIME)
+
+    return requestToken
+  }
+
+  async getTwoFactorRequestToken (userId: number, requestToken: string) {
+    return this.getValue(this.generateTwoFactorRequestKey(userId, requestToken))
+  }
+
   /* ************ Email verification ************ */
 
   async setVerifyEmailVerificationString (userId: number) {
@@ -342,6 +357,10 @@ class Redis {
     return 'reset-password-' + userId
   }
 
+  private generateTwoFactorRequestKey (userId: number, token: string) {
+    return 'two-factor-request-' + userId + '-' + token
+  }
+
   private generateVerifyEmailKey (userId: number) {
     return 'verify-email-' + userId
   }
@@ -391,8 +410,8 @@ class Redis {
     return JSON.parse(value)
   }
 
-  private setObject (key: string, value: { [ id: string ]: number | string }) {
-    return this.setValue(key, JSON.stringify(value))
+  private setObject (key: string, value: { [ id: string ]: number | string }, expirationMilliseconds?: number) {
+    return this.setValue(key, JSON.stringify(value), expirationMilliseconds)
   }
 
   private async setValue (key: string, value: string, expirationMilliseconds?: number) {
index bbd03b248f0cbdec8b58e48501d5c88d5e12cd4b..de98cd442eac49a789ce49b6c8a0e57f6bbb9111 100644 (file)
@@ -1,5 +1,6 @@
 export * from './abuses'
 export * from './accounts'
+export * from './users'
 export * from './utils'
 export * from './video-blacklists'
 export * from './video-captions'
diff --git a/server/middlewares/validators/shared/users.ts b/server/middlewares/validators/shared/users.ts
new file mode 100644 (file)
index 0000000..fbaa7db
--- /dev/null
@@ -0,0 +1,62 @@
+import express from 'express'
+import { ActorModel } from '@server/models/actor/actor'
+import { UserModel } from '@server/models/user/user'
+import { MUserDefault } from '@server/types/models'
+import { HttpStatusCode } from '@shared/models'
+
+function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
+  const id = parseInt(idArg + '', 10)
+  return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
+}
+
+function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
+  return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
+}
+
+async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
+  const user = await UserModel.loadByUsernameOrEmail(username, email)
+
+  if (user) {
+    res.fail({
+      status: HttpStatusCode.CONFLICT_409,
+      message: 'User with this username or email already exists.'
+    })
+    return false
+  }
+
+  const actor = await ActorModel.loadLocalByName(username)
+  if (actor) {
+    res.fail({
+      status: HttpStatusCode.CONFLICT_409,
+      message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
+    })
+    return false
+  }
+
+  return true
+}
+
+async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
+  const user = await finder()
+
+  if (!user) {
+    if (abortResponse === true) {
+      res.fail({
+        status: HttpStatusCode.NOT_FOUND_404,
+        message: 'User not found'
+      })
+    }
+
+    return false
+  }
+
+  res.locals.user = user
+  return true
+}
+
+export {
+  checkUserIdExist,
+  checkUserEmailExist,
+  checkUserNameOrEmailDoesNotAlreadyExist,
+  checkUserExist
+}
diff --git a/server/middlewares/validators/two-factor.ts b/server/middlewares/validators/two-factor.ts
new file mode 100644 (file)
index 0000000..106b579
--- /dev/null
@@ -0,0 +1,81 @@
+import express from 'express'
+import { body, param } from 'express-validator'
+import { HttpStatusCode, UserRight } from '@shared/models'
+import { exists, isIdValid } from '../../helpers/custom-validators/misc'
+import { areValidationErrors, checkUserIdExist } from './shared'
+
+const requestOrConfirmTwoFactorValidator = [
+  param('id').custom(isIdValid),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
+
+    if (res.locals.user.otpSecret) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: `Two factor is already enabled.`
+      })
+    }
+
+    return next()
+  }
+]
+
+const confirmTwoFactorValidator = [
+  body('requestToken').custom(exists),
+  body('otpToken').custom(exists),
+
+  (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    return next()
+  }
+]
+
+const disableTwoFactorValidator = [
+  param('id').custom(isIdValid),
+
+  async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+    if (areValidationErrors(req, res)) return
+
+    if (!await checkCanEnableOrDisableTwoFactor(req.params.id, res)) return
+
+    if (!res.locals.user.otpSecret) {
+      return res.fail({
+        status: HttpStatusCode.BAD_REQUEST_400,
+        message: `Two factor is already disabled.`
+      })
+    }
+
+    return next()
+  }
+]
+
+// ---------------------------------------------------------------------------
+
+export {
+  requestOrConfirmTwoFactorValidator,
+  confirmTwoFactorValidator,
+  disableTwoFactorValidator
+}
+
+// ---------------------------------------------------------------------------
+
+async function checkCanEnableOrDisableTwoFactor (userId: number | string, res: express.Response) {
+  const authUser = res.locals.oauth.token.user
+
+  if (!await checkUserIdExist(userId, res)) return
+
+  if (res.locals.user.id !== authUser.id && authUser.hasRight(UserRight.MANAGE_USERS) !== true) {
+    res.fail({
+      status: HttpStatusCode.FORBIDDEN_403,
+      message: `User ${authUser.username} does not have right to change two factor setting of this user.`
+    })
+
+    return false
+  }
+
+  return true
+}
index eb693318fc2a998e603b6e4a6c7be073e55bd867..055af3b64c851209ecd24c97614c6c1ca3175d86 100644 (file)
@@ -1,9 +1,8 @@
 import express from 'express'
 import { body, param, query } from 'express-validator'
 import { Hooks } from '@server/lib/plugins/hooks'
-import { MUserDefault } from '@server/types/models'
 import { HttpStatusCode, UserRegister, UserRight, UserRole } from '@shared/models'
-import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
+import { exists, isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
 import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
 import {
   isUserAdminFlagsValid,
@@ -30,8 +29,15 @@ import { isThemeRegistered } from '../../lib/plugins/theme-utils'
 import { Redis } from '../../lib/redis'
 import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
 import { ActorModel } from '../../models/actor/actor'
-import { UserModel } from '../../models/user/user'
-import { areValidationErrors, doesVideoChannelIdExist, doesVideoExist, isValidVideoIdParam } from './shared'
+import {
+  areValidationErrors,
+  checkUserEmailExist,
+  checkUserIdExist,
+  checkUserNameOrEmailDoesNotAlreadyExist,
+  doesVideoChannelIdExist,
+  doesVideoExist,
+  isValidVideoIdParam
+} from './shared'
 
 const usersListValidator = [
   query('blocked')
@@ -435,7 +441,7 @@ const usersResetPasswordValidator = [
     if (!await checkUserIdExist(req.params.id, res)) return
 
     const user = res.locals.user
-    const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id)
+    const redisVerificationString = await Redis.Instance.getResetPasswordVerificationString(user.id)
 
     if (redisVerificationString !== req.body.verificationString) {
       return res.fail({
@@ -500,6 +506,41 @@ const usersVerifyEmailValidator = [
   }
 ]
 
+const usersCheckCurrentPasswordFactory = (targetUserIdGetter: (req: express.Request) => number | string) => {
+  return [
+    body('currentPassword').optional().custom(exists),
+
+    async (req: express.Request, res: express.Response, next: express.NextFunction) => {
+      if (areValidationErrors(req, res)) return
+
+      const user = res.locals.oauth.token.User
+      const isAdminOrModerator = user.role === UserRole.ADMINISTRATOR || user.role === UserRole.MODERATOR
+      const targetUserId = parseInt(targetUserIdGetter(req) + '')
+
+      // Admin/moderator action on another user, skip the password check
+      if (isAdminOrModerator && targetUserId !== user.id) {
+        return next()
+      }
+
+      if (!req.body.currentPassword) {
+        return res.fail({
+          status: HttpStatusCode.BAD_REQUEST_400,
+          message: 'currentPassword is missing'
+        })
+      }
+
+      if (await user.isPasswordMatch(req.body.currentPassword) !== true) {
+        return res.fail({
+          status: HttpStatusCode.FORBIDDEN_403,
+          message: 'currentPassword is invalid.'
+        })
+      }
+
+      return next()
+    }
+  ]
+}
+
 const userAutocompleteValidator = [
   param('search')
     .isString()
@@ -567,6 +608,7 @@ export {
   usersUpdateValidator,
   usersUpdateMeValidator,
   usersVideoRatingValidator,
+  usersCheckCurrentPasswordFactory,
   ensureUserRegistrationAllowed,
   ensureUserRegistrationAllowedForIP,
   usersGetValidator,
@@ -580,55 +622,3 @@ export {
   ensureCanModerateUser,
   ensureCanManageChannelOrAccount
 }
-
-// ---------------------------------------------------------------------------
-
-function checkUserIdExist (idArg: number | string, res: express.Response, withStats = false) {
-  const id = parseInt(idArg + '', 10)
-  return checkUserExist(() => UserModel.loadByIdWithChannels(id, withStats), res)
-}
-
-function checkUserEmailExist (email: string, res: express.Response, abortResponse = true) {
-  return checkUserExist(() => UserModel.loadByEmail(email), res, abortResponse)
-}
-
-async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email: string, res: express.Response) {
-  const user = await UserModel.loadByUsernameOrEmail(username, email)
-
-  if (user) {
-    res.fail({
-      status: HttpStatusCode.CONFLICT_409,
-      message: 'User with this username or email already exists.'
-    })
-    return false
-  }
-
-  const actor = await ActorModel.loadLocalByName(username)
-  if (actor) {
-    res.fail({
-      status: HttpStatusCode.CONFLICT_409,
-      message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
-    })
-    return false
-  }
-
-  return true
-}
-
-async function checkUserExist (finder: () => Promise<MUserDefault>, res: express.Response, abortResponse = true) {
-  const user = await finder()
-
-  if (!user) {
-    if (abortResponse === true) {
-      res.fail({
-        status: HttpStatusCode.NOT_FOUND_404,
-        message: 'User not found'
-      })
-    }
-
-    return false
-  }
-
-  res.locals.user = user
-  return true
-}
index 1a7c843907b6838135e483032cecba7ffd66c599..34329580bf17ed7c060a0cdcd6d32513fdb58a02 100644 (file)
@@ -403,6 +403,11 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
   @Column
   lastLoginDate: Date
 
+  @AllowNull(true)
+  @Default(null)
+  @Column
+  otpSecret: string
+
   @CreatedAt
   createdAt: Date
 
@@ -935,7 +940,9 @@ export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
 
       pluginAuth: this.pluginAuth,
 
-      lastLoginDate: this.lastLoginDate
+      lastLoginDate: this.lastLoginDate,
+
+      twoFactorEnabled: !!this.otpSecret
     }
 
     if (parameters.withAdminFlags) {
index cd7a38459085b5bc2698398337939cdb58deb99a..33dc8fb763e91a530dbe7aef0d65026dae14721b 100644 (file)
@@ -2,6 +2,7 @@ import './abuses'
 import './accounts'
 import './blocklist'
 import './bulk'
+import './channel-import-videos'
 import './config'
 import './contact-form'
 import './custom-pages'
@@ -17,6 +18,7 @@ import './redundancy'
 import './search'
 import './services'
 import './transcoding'
+import './two-factor'
 import './upload-quota'
 import './user-notifications'
 import './user-subscriptions'
@@ -24,12 +26,11 @@ import './users-admin'
 import './users'
 import './video-blacklist'
 import './video-captions'
+import './video-channel-syncs'
 import './video-channels'
 import './video-comments'
 import './video-files'
 import './video-imports'
-import './video-channel-syncs'
-import './channel-import-videos'
 import './video-playlists'
 import './video-source'
 import './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 (file)
index 0000000..f8365f1
--- /dev/null
@@ -0,0 +1,288 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { HttpStatusCode } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
+
+describe('Test two factor API validators', function () {
+  let server: PeerTubeServer
+
+  let rootId: number
+  let rootPassword: string
+  let rootRequestToken: string
+  let rootOTPToken: string
+
+  let userId: number
+  let userToken = ''
+  let userPassword: string
+  let userRequestToken: string
+  let userOTPToken: string
+
+  // ---------------------------------------------------------------
+
+  before(async function () {
+    this.timeout(30000)
+
+    {
+      server = await createSingleServer(1)
+      await setAccessTokensToServers([ server ])
+    }
+
+    {
+      const result = await server.users.generate('user1')
+      userToken = result.token
+      userId = result.userId
+      userPassword = result.password
+    }
+
+    {
+      const { id } = await server.users.getMyInfo()
+      rootId = id
+      rootPassword = server.store.user.password
+    }
+  })
+
+  describe('When requesting two factor', function () {
+
+    it('Should fail with an unknown user id', async function () {
+      await server.twoFactor.request({ userId: 42, currentPassword: rootPassword, expectedStatus: HttpStatusCode.NOT_FOUND_404 })
+    })
+
+    it('Should fail with an invalid user id', async function () {
+      await server.twoFactor.request({
+        userId: 'invalid' as any,
+        currentPassword: rootPassword,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail to request another user two factor without the appropriate rights', async function () {
+      await server.twoFactor.request({
+        userId: rootId,
+        token: userToken,
+        currentPassword: userPassword,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should succeed to request another user two factor with the appropriate rights', async function () {
+      await server.twoFactor.request({ userId, currentPassword: rootPassword })
+    })
+
+    it('Should fail to request two factor without a password', async function () {
+      await server.twoFactor.request({
+        userId,
+        token: userToken,
+        currentPassword: undefined,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail to request two factor with an incorrect password', async function () {
+      await server.twoFactor.request({
+        userId,
+        token: userToken,
+        currentPassword: rootPassword,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should succeed to request two factor without a password when targeting a remote user with an admin account', async function () {
+      await server.twoFactor.request({ userId })
+    })
+
+    it('Should fail to request two factor without a password when targeting myself with an admin account', async function () {
+      await server.twoFactor.request({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      await server.twoFactor.request({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should succeed to request my two factor auth', async function () {
+      {
+        const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
+        userRequestToken = otpRequest.requestToken
+        userOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
+      }
+
+      {
+        const { otpRequest } = await server.twoFactor.request({ userId: rootId, currentPassword: rootPassword })
+        rootRequestToken = otpRequest.requestToken
+        rootOTPToken = TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
+      }
+    })
+  })
+
+  describe('When confirming two factor request', function () {
+
+    it('Should fail with an unknown user id', async function () {
+      await server.twoFactor.confirmRequest({
+        userId: 42,
+        requestToken: rootRequestToken,
+        otpToken: rootOTPToken,
+        expectedStatus: HttpStatusCode.NOT_FOUND_404
+      })
+    })
+
+    it('Should fail with an invalid user id', async function () {
+      await server.twoFactor.confirmRequest({
+        userId: 'invalid' as any,
+        requestToken: rootRequestToken,
+        otpToken: rootOTPToken,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail to confirm another user two factor request without the appropriate rights', async function () {
+      await server.twoFactor.confirmRequest({
+        userId: rootId,
+        token: userToken,
+        requestToken: rootRequestToken,
+        otpToken: rootOTPToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail without request token', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        requestToken: undefined,
+        otpToken: userOTPToken,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with an invalid request token', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        requestToken: 'toto',
+        otpToken: userOTPToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail with request token of another user', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        requestToken: rootRequestToken,
+        otpToken: userOTPToken,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail without an otp token', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        requestToken: userRequestToken,
+        otpToken: undefined,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail with a bad otp token', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        requestToken: userRequestToken,
+        otpToken: '123456',
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should succeed to confirm another user two factor request with the appropriate rights', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        requestToken: userRequestToken,
+        otpToken: userOTPToken
+      })
+
+      // Reinit
+      await server.twoFactor.disable({ userId, currentPassword: rootPassword })
+    })
+
+    it('Should succeed to confirm my two factor request', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        token: userToken,
+        requestToken: userRequestToken,
+        otpToken: userOTPToken
+      })
+    })
+
+    it('Should fail to confirm again two factor request', async function () {
+      await server.twoFactor.confirmRequest({
+        userId,
+        token: userToken,
+        requestToken: userRequestToken,
+        otpToken: userOTPToken,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+  })
+
+  describe('When disabling two factor', function () {
+
+    it('Should fail with an unknown user id', async function () {
+      await server.twoFactor.disable({
+        userId: 42,
+        currentPassword: rootPassword,
+        expectedStatus: HttpStatusCode.NOT_FOUND_404
+      })
+    })
+
+    it('Should fail with an invalid user id', async function () {
+      await server.twoFactor.disable({
+        userId: 'invalid' as any,
+        currentPassword: rootPassword,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+
+    it('Should fail to disable another user two factor without the appropriate rights', async function () {
+      await server.twoFactor.disable({
+        userId: rootId,
+        token: userToken,
+        currentPassword: userPassword,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should fail to disable two factor with an incorrect password', async function () {
+      await server.twoFactor.disable({
+        userId,
+        token: userToken,
+        currentPassword: rootPassword,
+        expectedStatus: HttpStatusCode.FORBIDDEN_403
+      })
+    })
+
+    it('Should succeed to disable two factor without a password when targeting a remote user with an admin account', async function () {
+      await server.twoFactor.disable({ userId })
+      await server.twoFactor.requestAndConfirm({ userId })
+    })
+
+    it('Should fail to disable two factor without a password when targeting myself with an admin account', async function () {
+      await server.twoFactor.disable({ userId: rootId, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+      await server.twoFactor.disable({ userId: rootId, currentPassword: 'bad', expectedStatus: HttpStatusCode.FORBIDDEN_403 })
+    })
+
+    it('Should succeed to disable another user two factor with the appropriate rights', async function () {
+      await server.twoFactor.disable({ userId, currentPassword: rootPassword })
+
+      await server.twoFactor.requestAndConfirm({ userId })
+    })
+
+    it('Should succeed to update my two factor auth', async function () {
+      await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
+    })
+
+    it('Should fail to disable again two factor', async function () {
+      await server.twoFactor.disable({
+        userId,
+        token: userToken,
+        currentPassword: userPassword,
+        expectedStatus: HttpStatusCode.BAD_REQUEST_400
+      })
+    })
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
index c65152c6f44b65d5361b0c4fa9d4aaeec84b2684..643f1a531f8dbd779403ceaf4bc1e6704816ce63 100644 (file)
@@ -1,3 +1,4 @@
+import './two-factor'
 import './user-subscriptions'
 import './user-videos'
 import './users'
diff --git a/server/tests/api/users/two-factor.ts b/server/tests/api/users/two-factor.ts
new file mode 100644 (file)
index 0000000..0dcab9e
--- /dev/null
@@ -0,0 +1,200 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { expectStartWith } from '@server/tests/shared'
+import { HttpStatusCode } from '@shared/models'
+import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers, TwoFactorCommand } from '@shared/server-commands'
+
+async function login (options: {
+  server: PeerTubeServer
+  username: string
+  password: string
+  otpToken?: string
+  expectedStatus?: HttpStatusCode
+}) {
+  const { server, username, password, otpToken, expectedStatus } = options
+
+  const user = { username, password }
+  const { res, body: { access_token: token } } = await server.login.loginAndGetResponse({ user, otpToken, expectedStatus })
+
+  return { res, token }
+}
+
+describe('Test users', function () {
+  let server: PeerTubeServer
+  let otpSecret: string
+  let requestToken: string
+
+  const userUsername = 'user1'
+  let userId: number
+  let userPassword: string
+  let userToken: string
+
+  before(async function () {
+    this.timeout(30000)
+
+    server = await createSingleServer(1)
+
+    await setAccessTokensToServers([ server ])
+    const res = await server.users.generate(userUsername)
+    userId = res.userId
+    userPassword = res.password
+    userToken = res.token
+  })
+
+  it('Should not add the header on login if two factor is not enabled', async function () {
+    const { res, token } = await login({ server, username: userUsername, password: userPassword })
+
+    expect(res.header['x-peertube-otp']).to.not.exist
+
+    await server.users.getMyInfo({ token })
+  })
+
+  it('Should request two factor and get the secret and uri', async function () {
+    const { otpRequest } = await server.twoFactor.request({ userId, token: userToken, currentPassword: userPassword })
+
+    expect(otpRequest.requestToken).to.exist
+
+    expect(otpRequest.secret).to.exist
+    expect(otpRequest.secret).to.have.lengthOf(32)
+
+    expect(otpRequest.uri).to.exist
+    expectStartWith(otpRequest.uri, 'otpauth://')
+    expect(otpRequest.uri).to.include(otpRequest.secret)
+
+    requestToken = otpRequest.requestToken
+    otpSecret = otpRequest.secret
+  })
+
+  it('Should not have two factor confirmed yet', async function () {
+    const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+    expect(twoFactorEnabled).to.be.false
+  })
+
+  it('Should confirm two factor', async function () {
+    await server.twoFactor.confirmRequest({
+      userId,
+      token: userToken,
+      otpToken: TwoFactorCommand.buildOTP({ secret: otpSecret }).generate(),
+      requestToken
+    })
+  })
+
+  it('Should not add the header on login if two factor is enabled and password is incorrect', async function () {
+    const { res, token } = await login({ server, username: userUsername, password: 'fake', expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
+
+    expect(res.header['x-peertube-otp']).to.not.exist
+    expect(token).to.not.exist
+  })
+
+  it('Should add the header on login if two factor is enabled and password is correct', async function () {
+    const { res, token } = await login({
+      server,
+      username: userUsername,
+      password: userPassword,
+      expectedStatus: HttpStatusCode.UNAUTHORIZED_401
+    })
+
+    expect(res.header['x-peertube-otp']).to.exist
+    expect(token).to.not.exist
+
+    await server.users.getMyInfo({ token })
+  })
+
+  it('Should not login with correct password and incorrect otp secret', async function () {
+    const otp = TwoFactorCommand.buildOTP({ secret: 'a'.repeat(32) })
+
+    const { res, token } = await login({
+      server,
+      username: userUsername,
+      password: userPassword,
+      otpToken: otp.generate(),
+      expectedStatus: HttpStatusCode.BAD_REQUEST_400
+    })
+
+    expect(res.header['x-peertube-otp']).to.not.exist
+    expect(token).to.not.exist
+  })
+
+  it('Should not login with correct password and incorrect otp code', async function () {
+    const { res, token } = await login({
+      server,
+      username: userUsername,
+      password: userPassword,
+      otpToken: '123456',
+      expectedStatus: HttpStatusCode.BAD_REQUEST_400
+    })
+
+    expect(res.header['x-peertube-otp']).to.not.exist
+    expect(token).to.not.exist
+  })
+
+  it('Should not login with incorrect password and correct otp code', async function () {
+    const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
+
+    const { res, token } = await login({
+      server,
+      username: userUsername,
+      password: 'fake',
+      otpToken,
+      expectedStatus: HttpStatusCode.BAD_REQUEST_400
+    })
+
+    expect(res.header['x-peertube-otp']).to.not.exist
+    expect(token).to.not.exist
+  })
+
+  it('Should correctly login with correct password and otp code', async function () {
+    const otpToken = TwoFactorCommand.buildOTP({ secret: otpSecret }).generate()
+
+    const { res, token } = await login({ server, username: userUsername, password: userPassword, otpToken })
+
+    expect(res.header['x-peertube-otp']).to.not.exist
+    expect(token).to.exist
+
+    await server.users.getMyInfo({ token })
+  })
+
+  it('Should have two factor enabled when getting my info', async function () {
+    const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+    expect(twoFactorEnabled).to.be.true
+  })
+
+  it('Should disable two factor and be able to login without otp token', async function () {
+    await server.twoFactor.disable({ userId, token: userToken, currentPassword: userPassword })
+
+    const { res, token } = await login({ server, username: userUsername, password: userPassword })
+    expect(res.header['x-peertube-otp']).to.not.exist
+
+    await server.users.getMyInfo({ token })
+  })
+
+  it('Should have two factor disabled when getting my info', async function () {
+    const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+    expect(twoFactorEnabled).to.be.false
+  })
+
+  it('Should enable two factor auth without password from an admin', async function () {
+    const { otpRequest } = await server.twoFactor.request({ userId })
+
+    await server.twoFactor.confirmRequest({
+      userId,
+      otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate(),
+      requestToken: otpRequest.requestToken
+    })
+
+    const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+    expect(twoFactorEnabled).to.be.true
+  })
+
+  it('Should disable two factor auth without password from an admin', async function () {
+    await server.twoFactor.disable({ userId })
+
+    const { twoFactorEnabled } = await server.users.getMyInfo({ token: userToken })
+    expect(twoFactorEnabled).to.be.false
+  })
+
+  after(async function () {
+    await cleanupTests([ server ])
+  })
+})
diff --git a/server/tests/helpers/crypto.ts b/server/tests/helpers/crypto.ts
new file mode 100644 (file)
index 0000000..b508c71
--- /dev/null
@@ -0,0 +1,33 @@
+/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
+
+import { expect } from 'chai'
+import { decrypt, encrypt } from '@server/helpers/peertube-crypto'
+
+describe('Encrypt/Descrypt', function () {
+
+  it('Should encrypt and decrypt the string', async function () {
+    const secret = 'my_secret'
+    const str = 'my super string'
+
+    const encrypted = await encrypt(str, secret)
+    const decrypted = await decrypt(encrypted, secret)
+
+    expect(str).to.equal(decrypted)
+  })
+
+  it('Should not decrypt without the same secret', async function () {
+    const str = 'my super string'
+
+    const encrypted = await encrypt(str, 'my_secret')
+
+    let error = false
+
+    try {
+      await decrypt(encrypted, 'my_sicret')
+    } catch (err) {
+      error = true
+    }
+
+    expect(error).to.be.true
+  })
+})
index 951208842cba52f3dbaec491084e68f91d84fa6f..42d644c40c657c18d3189ba50a177c026b9bc4ef 100644 (file)
@@ -1,6 +1,7 @@
-import './image'
+import './crypto'
 import './core-utils'
 import './dns'
+import './dns'
 import './comment-model'
 import './markdown'
 import './request'
index b25978587773f5cf2257d122a3bba76ac954b65e..32f7a441c2dd75c9cd8b8e957cffed6ed3a7d42c 100644 (file)
@@ -1,3 +1,4 @@
+export * from './two-factor-enable-result.model'
 export * from './user-create-result.model'
 export * from './user-create.model'
 export * 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 (file)
index 0000000..1fc801f
--- /dev/null
@@ -0,0 +1,7 @@
+export interface TwoFactorEnableResult {
+  otpRequest: {
+    requestToken: string
+    secret: string
+    uri: string
+  }
+}
index 63c5c8a92f961e36da3e9b4e40e064e27cb713e4..7b6494ff82fa7cf89be1e715d4fd612a96d199fe 100644 (file)
@@ -62,6 +62,8 @@ export interface User {
   pluginAuth: string | null
 
   lastLoginDate: Date | null
+
+  twoFactorEnabled: boolean
 }
 
 export interface MyUserSpecialPlaylist {
index a8f8c1d8415f39ca13237d552acd6761c84179ac..7096faf21d50d2f976e6cb941014120e17cbd038 100644 (file)
@@ -13,7 +13,15 @@ import { AbusesCommand } from '../moderation'
 import { OverviewsCommand } from '../overviews'
 import { SearchCommand } from '../search'
 import { SocketIOCommand } from '../socket'
-import { AccountsCommand, BlocklistCommand, LoginCommand, NotificationsCommand, SubscriptionsCommand, UsersCommand } from '../users'
+import {
+  AccountsCommand,
+  BlocklistCommand,
+  LoginCommand,
+  NotificationsCommand,
+  SubscriptionsCommand,
+  TwoFactorCommand,
+  UsersCommand
+} from '../users'
 import {
   BlacklistCommand,
   CaptionsCommand,
@@ -136,6 +144,7 @@ export class PeerTubeServer {
   videos?: VideosCommand
   videoStats?: VideoStatsCommand
   views?: ViewsCommand
+  twoFactor?: TwoFactorCommand
 
   constructor (options: { serverNumber: number } | { url: string }) {
     if ((options as any).url) {
@@ -417,5 +426,6 @@ export class PeerTubeServer {
     this.videoStudio = new VideoStudioCommand(this)
     this.videoStats = new VideoStatsCommand(this)
     this.views = new ViewsCommand(this)
+    this.twoFactor = new TwoFactorCommand(this)
   }
 }
index f6f93b4d2f081fa71cf83285e57080eca3efe3c1..1afc02dc1854d9354918d67bfba5e816610ef90f 100644 (file)
@@ -5,4 +5,5 @@ export * from './login'
 export * from './login-command'
 export * from './notifications-command'
 export * from './subscriptions-command'
+export * from './two-factor-command'
 export * from './users-command'
index 54070e4261f6642f69c23187175ca909853a436b..f2fc6d1c514e6cc7407fc1a2d02ecf17e630cb64 100644 (file)
@@ -2,34 +2,27 @@ import { HttpStatusCode, PeerTubeProblemDocument } from '@shared/models'
 import { unwrapBody } from '../requests'
 import { AbstractCommand, OverrideCommandOptions } from '../shared'
 
+type LoginOptions = OverrideCommandOptions & {
+  client?: { id?: string, secret?: string }
+  user?: { username: string, password?: string }
+  otpToken?: string
+}
+
 export class LoginCommand extends AbstractCommand {
 
-  login (options: OverrideCommandOptions & {
-    client?: { id?: string, secret?: string }
-    user?: { username: string, password?: string }
-  } = {}) {
-    const { client = this.server.store.client, user = this.server.store.user } = options
-    const path = '/api/v1/users/token'
+  async login (options: LoginOptions = {}) {
+    const res = await this._login(options)
 
-    const body = {
-      client_id: client.id,
-      client_secret: client.secret,
-      username: user.username,
-      password: user.password ?? 'password',
-      response_type: 'code',
-      grant_type: 'password',
-      scope: 'upload'
-    }
+    return this.unwrapLoginBody(res.body)
+  }
 
-    return unwrapBody<{ access_token: string, refresh_token: string } & PeerTubeProblemDocument>(this.postBodyRequest({
-      ...options,
+  async loginAndGetResponse (options: LoginOptions = {}) {
+    const res = await this._login(options)
 
-      path,
-      requestType: 'form',
-      fields: body,
-      implicitToken: false,
-      defaultExpectedStatus: HttpStatusCode.OK_200
-    }))
+    return {
+      res,
+      body: this.unwrapLoginBody(res.body)
+    }
   }
 
   getAccessToken (arg1?: { username: string, password?: string }): Promise<string>
@@ -129,4 +122,38 @@ export class LoginCommand extends AbstractCommand {
       defaultExpectedStatus: HttpStatusCode.OK_200
     })
   }
+
+  private _login (options: LoginOptions) {
+    const { client = this.server.store.client, user = this.server.store.user, otpToken } = options
+    const path = '/api/v1/users/token'
+
+    const body = {
+      client_id: client.id,
+      client_secret: client.secret,
+      username: user.username,
+      password: user.password ?? 'password',
+      response_type: 'code',
+      grant_type: 'password',
+      scope: 'upload'
+    }
+
+    const headers = otpToken
+      ? { 'x-peertube-otp': otpToken }
+      : {}
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      headers,
+      requestType: 'form',
+      fields: body,
+      implicitToken: false,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    })
+  }
+
+  private unwrapLoginBody (body: any) {
+    return body as { access_token: string, refresh_token: string } & PeerTubeProblemDocument
+  }
 }
diff --git a/shared/server-commands/users/two-factor-command.ts b/shared/server-commands/users/two-factor-command.ts
new file mode 100644 (file)
index 0000000..5542acf
--- /dev/null
@@ -0,0 +1,92 @@
+import { TOTP } from 'otpauth'
+import { HttpStatusCode, TwoFactorEnableResult } from '@shared/models'
+import { unwrapBody } from '../requests'
+import { AbstractCommand, OverrideCommandOptions } from '../shared'
+
+export class TwoFactorCommand extends AbstractCommand {
+
+  static buildOTP (options: {
+    secret: string
+  }) {
+    const { secret } = options
+
+    return new TOTP({
+      issuer: 'PeerTube',
+      algorithm: 'SHA1',
+      digits: 6,
+      period: 30,
+      secret
+    })
+  }
+
+  request (options: OverrideCommandOptions & {
+    userId: number
+    currentPassword?: string
+  }) {
+    const { currentPassword, userId } = options
+
+    const path = '/api/v1/users/' + userId + '/two-factor/request'
+
+    return unwrapBody<TwoFactorEnableResult>(this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: { currentPassword },
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.OK_200
+    }))
+  }
+
+  confirmRequest (options: OverrideCommandOptions & {
+    userId: number
+    requestToken: string
+    otpToken: string
+  }) {
+    const { userId, requestToken, otpToken } = options
+
+    const path = '/api/v1/users/' + userId + '/two-factor/confirm-request'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: { requestToken, otpToken },
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  disable (options: OverrideCommandOptions & {
+    userId: number
+    currentPassword?: string
+  }) {
+    const { userId, currentPassword } = options
+    const path = '/api/v1/users/' + userId + '/two-factor/disable'
+
+    return this.postBodyRequest({
+      ...options,
+
+      path,
+      fields: { currentPassword },
+      implicitToken: true,
+      defaultExpectedStatus: HttpStatusCode.NO_CONTENT_204
+    })
+  }
+
+  async requestAndConfirm (options: OverrideCommandOptions & {
+    userId: number
+    currentPassword?: string
+  }) {
+    const { userId, currentPassword } = options
+
+    const { otpRequest } = await this.request({ userId, currentPassword })
+
+    await this.confirmRequest({
+      userId,
+      requestToken: otpRequest.requestToken,
+      otpToken: TwoFactorCommand.buildOTP({ secret: otpRequest.secret }).generate()
+    })
+
+    return otpRequest
+  }
+}
index e7d0210595bd937ce4de9b530006ac6b913887fc..811b9685b42c4c3293a5dfcf5b6500cd3eb3dc8f 100644 (file)
@@ -202,7 +202,8 @@ export class UsersCommand extends AbstractCommand {
       token,
       userId: user.id,
       userChannelId: me.videoChannels[0].id,
-      userChannelName: me.videoChannels[0].name
+      userChannelName: me.videoChannels[0].name,
+      password
     }
   }
 
index c62310b761d80324b9c2dbd974b97c1055bc2399..2fb154dbdbe6c7e4384d947ca0f7c897da504121 100644 (file)
@@ -1126,6 +1126,97 @@ paths:
         '404':
           description: user not found
 
+  /users/{id}/two-factor/request:
+    post:
+      summary: Request two factor auth
+      operationId: requestTwoFactor
+      description: Request two factor authentication for a user
+      tags:
+        - Users
+      parameters:
+        - $ref: '#/components/parameters/id'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                currentPassword:
+                  type: string
+                  description: Password of the currently authenticated user
+      responses:
+        '200':
+          description: successful operation
+          content:
+            application/json:
+              schema:
+                type: array
+                items:
+                  $ref: '#/components/schemas/RequestTwoFactorResponse'
+        '403':
+          description: invalid password
+        '404':
+          description: user not found
+
+  /users/{id}/two-factor/confirm-request:
+    post:
+      summary: Confirm two factor auth
+      operationId: confirmTwoFactorRequest
+      description: Confirm a two factor authentication request
+      tags:
+        - Users
+      parameters:
+        - $ref: '#/components/parameters/id'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                requestToken:
+                  type: string
+                  description: Token to identify the two factor request
+                otpToken:
+                  type: string
+                  description: OTP token generated by the app
+              required:
+                - requestToken
+                - otpToken
+      responses:
+        '204':
+          description: successful operation
+        '403':
+          description: invalid request token or OTP token
+        '404':
+          description: user not found
+
+  /users/{id}/two-factor/disable:
+    post:
+      summary: Disable two factor auth
+      operationId: disableTwoFactor
+      description: Disable two factor authentication of a user
+      tags:
+        - Users
+      parameters:
+        - $ref: '#/components/parameters/id'
+      requestBody:
+        content:
+          application/json:
+            schema:
+              type: object
+              properties:
+                currentPassword:
+                  type: string
+                  description: Password of the currently authenticated user
+      responses:
+        '204':
+          description: successful operation
+        '403':
+          description: invalid password
+        '404':
+          description: user not found
+
+
   /users/ask-send-verify-email:
     post:
       summary: Resend user verification link
@@ -8146,6 +8237,21 @@ components:
           description: User can select live latency mode if enabled by the instance
           $ref: '#/components/schemas/LiveVideoLatencyMode'
 
+    RequestTwoFactorResponse:
+      properties:
+        otpRequest:
+          type: object
+          properties:
+            requestToken:
+              type: string
+              description: The token to send to confirm this request
+            secret:
+              type: string
+              description: The OTP secret
+            uri:
+              type: string
+              description: The OTP URI
+
     VideoStudioCreateTask:
       type: array
       items:
index 97eecc3adfc0620717080ace6f2346d6b2fa019b..267863a4d38c34b3ab058be16f2fad64df155b0e 100644 (file)
@@ -49,6 +49,7 @@ In the downloaded example [.env](https://github.com/Chocobozzz/PeerTube/blob/mas
 - `<MY POSTGRES PASSWORD>`
 - `<MY DOMAIN>` without 'https://'
 - `<MY EMAIL ADDRESS>`
+- `<MY PEERTUBE SECRET>`
 
 Other environment variables are used in
 [/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
index 64ddd9e489aaf6f1b32c3222640aa144be9699cc..b400ac4510660d722f8cfa166b2c68e8121c87a3 100644 (file)
@@ -115,8 +115,14 @@ $ cd /var/www/peertube
 $ sudo -u peertube cp peertube-latest/config/production.yaml.example config/production.yaml
 ```
 
-Then edit the `config/production.yaml` file according to your webserver
-and database configuration (`webserver`, `database`, `redis`, `smtp` and `admin.email` sections in particular).
+Then edit the `config/production.yaml` file according to your webserver and database configuration. In particular:
+ * `webserver`: Reverse proxy public information
+ * `secrets`: Secret strings you must generate manually (PeerTube version >= 5.0)
+ * `database`: PostgreSQL settings
+ * `redis`: Redis settings
+ * `smtp`: If you want to use emails
+ * `admin.email`: To correctly fill `root` user email
+
 Keys defined in `config/production.yaml` will override keys defined in `config/default.yaml`.
 
 **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.
index 4e7b21ab676867f204ad133d0d4c8f5a55bb0033..b4e356a58f35cd5d88e8d47cb73e2dcc94fe8297 100644 (file)
@@ -22,6 +22,9 @@ PEERTUBE_WEBSERVER_HOSTNAME=<MY DOMAIN>
 # pass them as a comma separated array:
 PEERTUBE_TRUST_PROXY=["127.0.0.1", "loopback", "172.18.0.0/16"]
 
+# Generate one using `openssl rand -hex 32`
+PEERTUBE_SECRET=<MY PEERTUBE SECRET>
+
 # E-mail configuration
 # If you use a Custom SMTP server
 #PEERTUBE_SMTP_USERNAME=
index 9c84428b78621e5b1bee7ad7c9bac6504f248d82..1d889fe7df5a6b491408d080a9107e167e465494 100644 (file)
@@ -7,6 +7,9 @@ webserver:
     __name: "PEERTUBE_WEBSERVER_HTTPS"
     __format: "json"
 
+secrets:
+  peertube: "PEERTUBE_SECRET"
+
 trust_proxy:
   __name: "PEERTUBE_TRUST_PROXY"
   __format: "json"
index 60fe262fa5bfa2a30154211e108119b1775028d7..8ccc4fd0d36cf266335fccd44d3b505b4ec2f621 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -5945,6 +5945,11 @@ jsprim@^1.2.2:
     json-schema "0.4.0"
     verror "1.10.0"
 
+jssha@~3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/jssha/-/jssha-3.2.0.tgz#88ec50b866dd1411deaddbe6b3e3692e4c710f16"
+  integrity sha512-QuruyBENDWdN4tZwJbQq7/eAK85FqrI4oDbXjy5IBhYD+2pTJyBUWZe8ctWaCkrV0gy6AaelgOZZBMeswEa/6Q==
+
 jstransformer@1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
@@ -7007,6 +7012,13 @@ os-tmpdir@~1.0.2:
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
   integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==
 
+otpauth@^8.0.3:
+  version "8.0.3"
+  resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-8.0.3.tgz#fdbcb24503e93dd7d930a8651f2dc9f8f7ff9c1b"
+  integrity sha512-5abBweT/POpMdVuM0Zk/tvlTHw8Kc8606XX/w8QNLRBDib+FVpseAx12Z21/iVIeCrJOgCY1dBuLS057IOdybw==
+  dependencies:
+    jssha "~3.2.0"
+
 p-cancelable@^2.0.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"