瀏覽代碼

Implement two factor in client

Chocobozzz 1 年之前
父節點
當前提交
d12b40fb96
共有 31 個文件被更改,包括 621 次插入68 次删除
  1. 30 16
      client/src/app/+login/login.component.html
  2. 32 6
      client/src/app/+login/login.component.ts
  3. 11 0
      client/src/app/+my-account/my-account-routing.module.ts
  4. 2 2
      client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts
  5. 2 2
      client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts
  6. 1 1
      client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts
  7. 10 0
      client/src/app/+my-account/my-account-settings/my-account-settings.component.html
  8. 3 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts
  9. 12 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html
  10. 49 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts
  11. 54 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html
  12. 16 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss
  13. 105 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts
  14. 52 0
      client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts
  15. 13 1
      client/src/app/+my-account/my-account.module.ts
  16. 1 1
      client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts
  17. 1 1
      client/src/app/+signup/+register/register.component.ts
  18. 20 3
      client/src/app/core/auth/auth.service.ts
  19. 36 11
      client/src/app/core/confirm/confirm.service.ts
  20. 4 2
      client/src/app/core/rest/rest-extractor.service.ts
  21. 4 0
      client/src/app/core/users/user.model.ts
  22. 5 2
      client/src/app/modal/confirm.component.html
  23. 25 5
      client/src/app/modal/confirm.component.ts
  24. 9 0
      client/src/app/shared/form-validators/user-validators.ts
  25. 101 0
      client/src/app/shared/shared-forms/form-reactive.service.ts
  26. 1 6
      client/src/app/shared/shared-forms/form-reactive.ts
  27. 1 1
      client/src/app/shared/shared-forms/form-validator.service.ts
  28. 1 0
      client/src/app/shared/shared-forms/index.ts
  29. 9 1
      client/src/app/shared/shared-forms/input-text.component.ts
  30. 3 2
      client/src/app/shared/shared-forms/shared-form.module.ts
  31. 8 5
      client/src/app/shared/shared-main/auth/auth-interceptor.service.ts

+ 30 - 16
client/src/app/+login/login.component.html

@@ -39,34 +39,48 @@
       <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">

+ 32 - 6
client/src/app/+login/login.component.ts

@@ -4,7 +4,8 @@ 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, FormValidatorService, 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 +21,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,6 +39,8 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
     codeOfConduct: false
   }
 
+  otpStep = false
+
   private openedForgotPasswordModal: NgbModalRef
   private serverConfig: ServerConfig
 
@@ -82,7 +86,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 +126,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 +177,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 +197,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

+ 11 - 0
client/src/app/+my-account/my-account-routing.module.ts

@@ -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',

+ 2 - 2
client/src/app/+my-account/my-account-settings/my-account-change-email/my-account-change-email.component.ts

@@ -4,7 +4,7 @@ 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 { HttpStatusCode, User } from '@shared/models'
 
 @Component({
   selector: 'my-account-change-email',
@@ -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
           }

+ 2 - 2
client/src/app/+my-account/my-account-settings/my-account-change-password/my-account-change-password.component.ts

@@ -7,7 +7,7 @@ import {
   USER_PASSWORD_VALIDATOR
 } from '@app/shared/form-validators/user-validators'
 import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
-import { User } from '@shared/models'
+import { HttpStatusCode, User } from '@shared/models'
 
 @Component({
   selector: 'my-account-change-password',
@@ -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
           }

+ 1 - 1
client/src/app/+my-account/my-account-settings/my-account-danger-zone/my-account-danger-zone.component.ts

@@ -18,7 +18,7 @@ export class MyAccountDangerZoneComponent {
   ) { }
 
   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

+ 10 - 0
client/src/app/+my-account/my-account-settings/my-account-settings.component.html

@@ -62,6 +62,16 @@
   </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>

+ 3 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts

@@ -0,0 +1,3 @@
+export * from './my-account-two-factor-button.component'
+export * from './my-account-two-factor.component'
+export * from './two-factor.service'

+ 12 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html

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

+ 49 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts

@@ -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 './two-factor.service'
+
+@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)
+      })
+  }
+}

+ 54 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html

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

+ 16 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss

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

+ 105 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts

@@ -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 './two-factor.service'
+
+@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
+  }
+}

+ 52 - 0
client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts

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

+ 13 - 1
client/src/app/+my-account/my-account.module.ts

@@ -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'
@@ -23,12 +24,18 @@ 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,
+  TwoFactorService
+} from './my-account-settings/my-account-two-factor'
 import { MyAccountComponent } from './my-account.component'
 
 @NgModule({
   imports: [
     MyAccountRoutingModule,
 
+    QRCodeModule,
     AutoCompleteModule,
     TableModule,
     DragDropModule,
@@ -52,6 +59,9 @@ import { MyAccountComponent } from './my-account.component'
     MyAccountChangeEmailComponent,
     MyAccountApplicationsComponent,
 
+    MyAccountTwoFactorButtonComponent,
+    MyAccountTwoFactorComponent,
+
     MyAccountDangerZoneComponent,
     MyAccountBlocklistComponent,
     MyAccountAbusesListComponent,
@@ -64,7 +74,9 @@ import { MyAccountComponent } from './my-account.component'
     MyAccountComponent
   ],
 
-  providers: []
+  providers: [
+    TwoFactorService
+  ]
 })
 export class MyAccountModule {
 }

+ 1 - 1
client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts

@@ -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})!`,

+ 1 - 1
client/src/app/+signup/+register/register.component.ts

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

+ 20 - 3
client/src/app/core/auth/auth.service.ts

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

+ 36 - 11
client/src/app/core/confirm/confirm.service.ts

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

+ 4 - 2
client/src/app/core/rest/rest-extractor.service.ts

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

+ 4 - 0
client/src/app/core/users/user.model.ts

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

+ 5 - 2
client/src/app/modal/confirm.component.html

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

+ 25 - 5
client/src/app/modal/confirm.component.ts

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

+ 9 - 0
client/src/app/shared/form-validators/user-validators.ts

@@ -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,

+ 101 - 0
client/src/app/shared/shared-forms/form-reactive.service.ts

@@ -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()
+    }
+  }
+
+  protected 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')
+      }
+    }
+  }
+}

+ 1 - 6
client/src/app/shared/shared-forms/form-reactive.ts

@@ -1,14 +1,9 @@
-
 import { AbstractControl, FormGroup } from '@angular/forms'
 import { wait } from '@root-helpers/utils'
 import { BuildFormArgument, BuildFormDefaultValues } from '../form-validators/form-validator.model'
+import { FormReactiveErrors, FormReactiveValidationMessages } from './form-reactive.service'
 import { FormValidatorService } from './form-validator.service'
 
-export type FormReactiveErrors = { [ id: string ]: string | FormReactiveErrors }
-export type FormReactiveValidationMessages = {
-  [ id: string ]: { [ name: string ]: string } | FormReactiveValidationMessages
-}
-
 export abstract class FormReactive {
   protected abstract formValidatorService: FormValidatorService
   protected formChanged = false

+ 1 - 1
client/src/app/shared/shared-forms/form-validator.service.ts

@@ -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 {

+ 1 - 0
client/src/app/shared/shared-forms/index.ts

@@ -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'

+ 9 - 1
client/src/app/shared/shared-forms/input-text.component.ts

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

+ 3 - 2
client/src/app/shared/shared-forms/shared-form.module.ts

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

+ 8 - 5
client/src/app/shared/shared-main/auth/auth-interceptor.service.ts

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