From d12b40fb96d56786a96c06a621f3d8e0a0d24f4a Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 7 Oct 2022 11:06:28 +0200 Subject: [PATCH] Implement two factor in client --- client/src/app/+login/login.component.html | 46 +++++--- client/src/app/+login/login.component.ts | 38 ++++++- .../+my-account/my-account-routing.module.ts | 11 ++ .../my-account-change-email.component.ts | 4 +- .../my-account-change-password.component.ts | 4 +- .../my-account-danger-zone.component.ts | 2 +- .../my-account-settings.component.html | 10 ++ .../my-account-two-factor/index.ts | 3 + ...y-account-two-factor-button.component.html | 12 ++ .../my-account-two-factor-button.component.ts | 49 ++++++++ .../my-account-two-factor.component.html | 54 +++++++++ .../my-account-two-factor.component.scss | 16 +++ .../my-account-two-factor.component.ts | 105 ++++++++++++++++++ .../two-factor.service.ts | 52 +++++++++ .../src/app/+my-account/my-account.module.ts | 14 ++- .../my-video-channels.component.ts | 2 +- .../+signup/+register/register.component.ts | 2 +- client/src/app/core/auth/auth.service.ts | 23 +++- .../src/app/core/confirm/confirm.service.ts | 47 ++++++-- .../app/core/rest/rest-extractor.service.ts | 6 +- client/src/app/core/users/user.model.ts | 4 + client/src/app/modal/confirm.component.html | 7 +- client/src/app/modal/confirm.component.ts | 30 ++++- .../shared/form-validators/user-validators.ts | 9 ++ .../shared-forms/form-reactive.service.ts | 101 +++++++++++++++++ .../app/shared/shared-forms/form-reactive.ts | 7 +- .../shared-forms/form-validator.service.ts | 2 +- client/src/app/shared/shared-forms/index.ts | 1 + .../shared-forms/input-text.component.ts | 10 +- .../shared/shared-forms/shared-form.module.ts | 5 +- .../auth/auth-interceptor.service.ts | 13 ++- 31 files changed, 621 insertions(+), 68 deletions(-) create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts create mode 100644 client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts create mode 100644 client/src/app/shared/shared-forms/form-reactive.service.ts diff --git a/client/src/app/+login/login.component.html b/client/src/app/+login/login.component.html index f3a2476f9..49b443a20 100644 --- a/client/src/app/+login/login.component.html +++ b/client/src/app/+login/login.component.html @@ -39,34 +39,48 @@
-
-
- - + +
+
+ + +
+ +
{{ formErrors.username }}
+ +
+ ⚠️ Most email addresses do not include capital letters. +
-
{{ formErrors.username }}
+
+ -
- ⚠️ Most email addresses do not include capital letters. +
-
+
-
- +
+

Enter the two-factor code generated by your phone app:

+ +
- +
+
+ +
+ +
+ +
+
+
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/index.ts new file mode 100644 index 000000000..ef83009a5 --- /dev/null +++ b/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' diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html new file mode 100644 index 000000000..2fcfffbf3 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.html @@ -0,0 +1,12 @@ +
+ +

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.

+ + Enable two-factor authentication +
+ + + Disable two-factor authentication + + +
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts new file mode 100644 index 000000000..03b00e933 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor-button.component.ts @@ -0,0 +1,49 @@ +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 + + 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 index 000000000..16c344e3b --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.html @@ -0,0 +1,54 @@ +

+ + Two factor authentication +

+ +
+ Two factor authentication is already enabled. +
+ +
+ + + + +
Confirm your password to enable two factor authentication
+ + + + + +
+ + + +

+ 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. +

+ + + +
+ If you can't scan the QR code and need to enter it manually, here is the plain-text secret: +
+ +
{{ twoFactorSecret }}
+ +
+ + +
Enter the code generated by your authenticator app to confirm
+ + + + +
+
+ +
diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss new file mode 100644 index 000000000..cee016bb8 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.scss @@ -0,0 +1,16 @@ +@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 index 000000000..e4d4188f7 --- /dev/null +++ b/client/src/app/+my-account/my-account-settings/my-account-two-factor/my-account-two-factor.component.ts @@ -0,0 +1,105 @@ +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 + } +} diff --git a/client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts b/client/src/app/+my-account/my-account-settings/my-account-two-factor/two-factor.service.ts new file mode 100644 index 000000000..c0e5ac492 --- /dev/null +++ b/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(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))) + } +} diff --git a/client/src/app/+my-account/my-account.module.ts b/client/src/app/+my-account/my-account.module.ts index 4081e4f01..f5beaa4db 100644 --- a/client/src/app/+my-account/my-account.module.ts +++ b/client/src/app/+my-account/my-account.module.ts @@ -1,3 +1,4 @@ +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 { } diff --git a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts index 205ad7a89..ece59c2ff 100644 --- a/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts +++ b/client/src/app/+my-library/+my-video-channels/my-video-channels.component.ts @@ -40,7 +40,7 @@ export class MyVideoChannelsComponent { } 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})!`, diff --git a/client/src/app/+signup/+register/register.component.ts b/client/src/app/+signup/+register/register.component.ts index 4ab327b1b..958770ebf 100644 --- a/client/src/app/+signup/+register/register.component.ts +++ b/client/src/app/+signup/+register/register.component.ts @@ -158,7 +158,7 @@ export class RegisterComponent implements OnInit { } // Auto login - this.authService.login(body.username, body.password) + this.authService.login({ username: body.username, password: body.password }) .subscribe({ next: () => { this.signupSuccess = true diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts index ca46866f5..7f4fae4aa 100644 --- a/client/src/app/core/auth/auth.service.ts +++ b/client/src/app/core/auth/auth.service.ts @@ -1,7 +1,7 @@ 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(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 { // User is not loaded yet, set manually auth header const headers = new HttpHeaders().set('Authorization', `${obj.token_type} ${obj.access_token}`) diff --git a/client/src/app/core/confirm/confirm.service.ts b/client/src/app/core/confirm/confirm.service.ts index 338b8762c..89a25f0a5 100644 --- a/client/src/app/core/confirm/confirm.service.ts +++ b/client/src/app/core/confirm/confirm.service.ts @@ -1,28 +1,53 @@ -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() - confirmResponse = new Subject() + 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.confirmResponse.asObservable()) + return firstValueFrom(this.extractConfirmed(this.confirmResponse.asObservable())) } - confirmWithInput (message: string, inputLabel: string, expectedInputValue: string, title = '', confirmButtonText?: string) { - this.showConfirm.next({ title, message, inputLabel, expectedInputValue, confirmButtonText }) + confirmWithPassword (message: string, title = '', confirmButtonText?: string) { + this.showConfirm.next({ type: 'confirm-password', title, message, confirmButtonText }) - return firstValueFrom(this.confirmResponse.asObservable()) + const obs = this.confirmResponse.asObservable() + .pipe(map(({ confirmed, value }) => ({ confirmed, password: value }))) + + return firstValueFrom(obs) + } + + 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())) + } + + private extractConfirmed (obs: Observable<{ confirmed: boolean }>) { + return obs.pipe(map(({ confirmed }) => confirmed)) } } diff --git a/client/src/app/core/rest/rest-extractor.service.ts b/client/src/app/core/rest/rest-extractor.service.ts index 7eec2eca6..57dd9ae26 100644 --- a/client/src/app/core/rest/rest-extractor.service.ts +++ b/client/src/app/core/rest/rest-extractor.service.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router' 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) { diff --git a/client/src/app/core/users/user.model.ts b/client/src/app/core/users/user.model.ts index 6ba30e4b8..8385a4012 100644 --- a/client/src/app/core/users/user.model.ts +++ b/client/src/app/core/users/user.model.ts @@ -66,6 +66,8 @@ export class User implements UserServerModel { lastLoginDate: Date | null + twoFactorEnabled: boolean + createdAt: Date constructor (hash: Partial) { @@ -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 diff --git a/client/src/app/modal/confirm.component.html b/client/src/app/modal/confirm.component.html index c59c25770..f364165c4 100644 --- a/client/src/app/modal/confirm.component.html +++ b/client/src/app/modal/confirm.component.html @@ -9,9 +9,12 @@