Browse Source

UI: Add 2FA login page

pull/6235/head
Vladyslav_Prykhodko 4 years ago
parent
commit
1ab9ed2467
  1. 32
      ui-ngx/src/app/core/auth/auth.service.ts
  2. 13
      ui-ngx/src/app/core/guards/auth.guard.ts
  3. 8
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html
  4. 8
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html
  5. 4
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html
  6. 12
      ui-ngx/src/app/modules/login/login-routing.module.ts
  7. 4
      ui-ngx/src/app/modules/login/login.module.ts
  8. 79
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html
  9. 66
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss
  10. 109
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts
  11. 3
      ui-ngx/src/app/shared/models/authority.enum.ts
  12. 3
      ui-ngx/src/app/shared/models/login.models.ts
  13. 31
      ui-ngx/src/app/shared/models/two-factor-auth.models.ts
  14. 9
      ui-ngx/src/assets/locale/locale.constant-en_US.json

32
ui-ngx/src/app/core/auth/auth.service.ts

@ -45,7 +45,8 @@ import { ActionNotificationShow } from '@core/notification/notification.actions'
import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
import { AlertDialogComponent } from '@shared/components/dialog/alert-dialog.component';
import { OAuth2ClientInfo, PlatformType } from '@shared/models/oauth2.models';
import { isDefinedAndNotNull, isMobileApp } from '@core/utils';
import { isMobileApp } from '@core/utils';
import { TwoFactorAuthProviderType, TwoFaProviderInfo } from '@shared/models/two-factor-auth.models';
@Injectable({
providedIn: 'root'
@ -70,6 +71,7 @@ export class AuthService {
redirectUrl: string;
oauth2Clients: Array<OAuth2ClientInfo> = null;
twoFactorAuthProviders: Array<TwoFaProviderInfo> = null;
private refreshTokenSubject: ReplaySubject<LoginResponse> = null;
private jwtHelper = new JwtHelperService();
@ -114,6 +116,18 @@ export class AuthService {
public login(loginRequest: LoginRequest): Observable<LoginResponse> {
return this.http.post<LoginResponse>('/api/auth/login', loginRequest, defaultHttpOptions()).pipe(
tap((loginResponse: LoginResponse) => {
this.setUserFromJwtToken(loginResponse.token, loginResponse.refreshToken, true);
if (loginResponse.scope === Authority.PRE_VERIFICATION_TOKEN) {
this.router.navigateByUrl(`login/mfa`);
}
}
));
}
public checkTwoFaVerificationCode(providerType: TwoFactorAuthProviderType, verificationCode: number): Observable<LoginResponse> {
return this.http.post<LoginResponse>(`/api/auth/2fa/verification/check?providerType=${providerType}&verificationCode=${verificationCode}`,
null, defaultHttpOptions()).pipe(
tap((loginResponse: LoginResponse) => {
this.setUserFromJwtToken(loginResponse.token, loginResponse.refreshToken, true);
}
@ -215,6 +229,15 @@ export class AuthService {
);
}
public getAvailableTwoFaLoginProviders(): Observable<Array<TwoFaProviderInfo>> {
return this.http.get<Array<TwoFaProviderInfo>>(`/api/auth/2fa/providers`, defaultHttpOptions()).pipe(
catchError(() => of([])),
tap((providers) => {
this.twoFactorAuthProviders = providers;
})
);
}
private forceDefaultPlace(authState?: AuthState, path?: string, params?: any): boolean {
if (authState && authState.authUser) {
if (authState.authUser.authority === Authority.TENANT_ADMIN || authState.authUser.authority === Authority.CUSTOMER_USER) {
@ -382,6 +405,9 @@ export class AuthService {
loadUserSubject.error(err);
}
);
} else if (authPayload.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) {
loadUserSubject.next(authPayload);
loadUserSubject.complete();
} else if (authPayload.authUser.userId) {
this.userService.getUser(authPayload.authUser.userId).subscribe(
(user) => {
@ -594,8 +620,8 @@ export class AuthService {
private updateAndValidateToken(token, prefix, notify) {
let valid = false;
const tokenData = this.jwtHelper.decodeToken(token);
const issuedAt = tokenData.iat;
const expTime = tokenData.exp;
const issuedAt = tokenData?.iat;
const expTime = tokenData?.exp;
if (issuedAt && expTime) {
const ttl = expTime - issuedAt;
if (ttl > 0) {

13
ui-ngx/src/app/core/guards/auth.guard.ts

@ -94,6 +94,8 @@ export class AuthGuard implements CanActivate, CanActivateChild {
return true;
})
);
} else if (path === 'login.mfa') {
return of(this.router.parseUrl('/login'));
} else {
return of(true);
}
@ -114,6 +116,17 @@ export class AuthGuard implements CanActivate, CanActivateChild {
this.mobileService.handleMobileNavigation(path, params);
return of(false);
}
if (authState.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) {
if (path === 'login.mfa') {
return this.authService.getAvailableTwoFaLoginProviders().pipe(
map(() => {
return true;
})
);
}
this.authService.logout();
return of(false);
}
const defaultUrl = this.authService.defaultUrl(true, authState, path, params);
if (defaultUrl) {
// this.authService.gotoDefaultPlace(true);

8
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html

@ -37,9 +37,9 @@
<form [formGroup]="emailConfigForm" (ngSubmit)="nextStep()">
<p class="mat-body step-description input" translate>security.2fa.dialog.email-step-description</p>
<div fxLayout="row" fxLayoutAlign="space-between center">
<mat-form-field fxFlex class="mat-block input-container" floatLabel="always">
<mat-form-field fxFlex class="mat-block input-container" hideRequiredMarker floatLabel="always">
<mat-label></mat-label>
<input matInput formControlName="email"
<input matInput formControlName="email" required
placeholder="{{ 'security.2fa.dialog.email-step-label' | translate }}" />
<mat-error *ngIf="emailConfigForm.get('email').hasError('required')">
{{ 'user.email-required' | translate }}
@ -64,11 +64,11 @@
{{ 'security.2fa.dialog.verification-step-description' | translate : {address: emailConfigForm.get('email').value} }}
</p>
<div fxLayout="row" fxLayoutAlign="space-between center">
<mat-form-field fxFlex class="mat-block code-container" floatLabel="always">
<mat-form-field fxFlex class="mat-block code-container" hideRequiredMarker floatLabel="always">
<mat-label></mat-label>
<input matInput formControlName="verificationCode"
maxlength="6" type="text"
pattern="\d*"
pattern="\d*" required
placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}">
<mat-error *ngIf="emailVerificationForm.get('verificationCode').invalid">
{{ 'security.2fa.dialog.verification-code-invalid' | translate }}

8
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html

@ -37,9 +37,9 @@
<form [formGroup]="smsConfigForm" (ngSubmit)="nextStep()">
<p class="mat-body step-description input" translate>security.2fa.dialog.sms-step-description</p>
<div fxLayout="row" fxLayoutAlign="space-between center">
<mat-form-field fxFlex class="mat-block input-container" floatLabel="always">
<mat-form-field fxFlex class="mat-block input-container" floatLabel="always" hideRequiredMarker>
<mat-label></mat-label>
<input type="tel"
<input type="tel" required
[pattern]="phoneNumberPattern"
matInput formControlName="phone"
placeholder="{{ 'security.2fa.dialog.sms-step-label' | translate }}">
@ -67,11 +67,11 @@
{{ 'security.2fa.dialog.verification-step-description' | translate : {address: smsConfigForm.get('phone').value} }}
</p>
<div fxLayout="row" fxLayoutAlign="space-between center">
<mat-form-field fxFlex class="mat-block code-container" floatLabel="always">
<mat-form-field fxFlex class="mat-block code-container" floatLabel="always" hideRequiredMarker>
<mat-label></mat-label>
<input matInput formControlName="verificationCode"
maxlength="6" type="text"
pattern="\d*"
pattern="\d*" required
placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}">
<mat-error *ngIf="smsVerificationForm.get('verificationCode').invalid">
{{ 'security.2fa.dialog.verification-code-invalid' | translate }}

4
ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html

@ -53,11 +53,11 @@
<p class="mat-body qr-code-description" translate>security.2fa.dialog.scan-qr-code</p>
<canvas fxFlex #canvas [ngStyle]="{display: totpAuthURL ? 'block' : 'none'}"></canvas>
<p class="mat-body qr-code-description" style="margin-top: 30px;" translate>security.2fa.dialog.enter-verification-code</p>
<mat-form-field fxFlex class="mat-block code-container" floatLabel="always">
<mat-form-field fxFlex class="mat-block code-container" floatLabel="always" hideRequiredMarker>
<mat-label></mat-label>
<input matInput formControlName="verificationCode"
maxlength="6" type="text"
pattern="\d*"
pattern="\d*" required
placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}">
<mat-error *ngIf="totpConfigForm.get('verificationCode').invalid">
{{ 'security.2fa.dialog.verification-code-invalid' | translate }}

12
ui-ngx/src/app/modules/login/login-routing.module.ts

@ -22,6 +22,8 @@ import { AuthGuard } from '@core/guards/auth.guard';
import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-password-request.component';
import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component';
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { Authority } from '@shared/models/authority.enum';
const routes: Routes = [
{
@ -69,6 +71,16 @@ const routes: Routes = [
module: 'public'
},
canActivate: [AuthGuard]
},
{
path: 'login/mfa',
component: TwoFactorAuthLoginComponent,
data: {
title: 'login.create-password',
auth: [Authority.PRE_VERIFICATION_TOKEN],
module: 'public'
},
canActivate: [AuthGuard]
}
];

4
ui-ngx/src/app/modules/login/login.module.ts

@ -23,13 +23,15 @@ import { SharedModule } from '@app/shared/shared.module';
import { ResetPasswordRequestComponent } from '@modules/login/pages/login/reset-password-request.component';
import { ResetPasswordComponent } from '@modules/login/pages/login/reset-password.component';
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
@NgModule({
declarations: [
LoginComponent,
ResetPasswordRequestComponent,
ResetPasswordComponent,
CreatePasswordComponent
CreatePasswordComponent,
TwoFactorAuthLoginComponent
],
imports: [
CommonModule,

79
ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html

@ -0,0 +1,79 @@
<!--
Copyright © 2016-2022 The Thingsboard Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="tb-two-factor-auth-login-content mat-app-background tb-dark" fxLayout="row" fxLayoutAlign="center center"
style="width: 100%;">
<mat-card fxFlex="initial" class="tb-two-factor-auth-login-card" >
<mat-card-title class="mat-headline" fxLayout="row" fxLayoutAlign="start center">
<button mat-icon-button type="button" (click)="cancelLogin()">
<mat-icon>chevron_left</mat-icon>
</button>
{{ 'login.verify-your-identity' | translate }}
</mat-card-title>
<mat-card-content>
<div class="providers-container tb-default" fxLayout="column" fxLayoutGap="8px" *ngIf="!selectedProvider">
<p class="mat-body" translate>login.select-way-to-verify</p>
<ng-container *ngFor="let provider of allowProviders">
<button type="button" mat-stroked-button class="provider" (click)="selectProvider(provider)">
<mat-icon class="icon" svgIcon="{{ providersData.get(provider).icon }}"></mat-icon>
{{ providersData.get(provider).name | translate }}
</button>
</ng-container>
</div>
<form [formGroup]="verificationForm" (ngSubmit)="sendVerificationCode()" *ngIf="selectedProvider">
<fieldset [disabled]="isLoading$ | async">
<div tb-toast fxLayout="column">
<p class="mat-body">{{ providerDescription }}</p>
<div fxLayout="row" class="code-block" fxLayoutGap="8px">
<mat-form-field class="mat-block" fxFlex floatLabel="always" hideRequiredMarker>
<mat-label></mat-label>
<input matInput formControlName="verificationCode"
required maxlength="6" type="text" pattern="\d*"
placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}"/>
<mat-error *ngIf="verificationForm.get('verificationCode').invalid">
{{ 'security.2fa.dialog.verification-code-invalid' | translate }}
</mat-error>
</mat-form-field>
<div fxLayoutAlign="start center" *ngIf="selectedProvider !== twoFactorAuthProvider.TOTP">
<button
mat-button
(click)="sendCode()"
type="button">
{{ 'login.resend-code' | translate }}
</button>
</div>
</div>
<span style="height: 40px;"></span>
<div fxLayout="column" fxLayoutGap="8px">
<button mat-raised-button
color="accent"
[disabled]="(isLoading$ | async) || verificationForm.invalid"
type="submit">
{{ 'action.continue' | translate }}
</button>
<button mat-button
type="button"
(click)="selectProvider(null)">
{{ 'login.try-another-way' | translate }}
</button>
</div>
</div>
</fieldset>
</form>
</mat-card-content>
</mat-card>
</div>

66
ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss

@ -0,0 +1,66 @@
/**
* Copyright © 2016-2022 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@import '../../../../../scss/constants';
:host {
display: flex;
flex: 1 1 0;
.tb-two-factor-auth-login-content {
background-color: #eee;
.tb-two-factor-auth-login-card {
padding: 48px 48px 48px 16px;
@media #{$mat-gt-xs} {
width: 450px !important;
}
.mat-card-title{
font: 500 28px / 36px Roboto, "Helvetica Neue", sans-serif;
}
.mat-card-content {
margin-top: 44px;
margin-left: 40px;
}
.mat-body {
letter-spacing: 0.25px;
line-height: 16px;
margin: 0;
}
.code-block {
margin-top: 16px;
}
.providers-container{
padding: 0;
.mat-body {
padding-bottom: 8px;
}
}
}
}
::ng-deep{
button.provider {
text-align: start;
&:not(.mat-button-disabled) {
border-color: rgba(255, 255, 255, .8);
}
}
}
}

109
ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts

@ -0,0 +1,109 @@
///
/// Copyright © 2016-2022 The Thingsboard Authors
///
/// Licensed under the Apache License, Version 2.0 (the "License");
/// you may not use this file except in compliance with the License.
/// You may obtain a copy of the License at
///
/// http://www.apache.org/licenses/LICENSE-2.0
///
/// Unless required by applicable law or agreed to in writing, software
/// distributed under the License is distributed on an "AS IS" BASIS,
/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
/// See the License for the specific language governing permissions and
/// limitations under the License.
///
import { Component, OnInit } from '@angular/core';
import { AuthService } from '@core/auth/auth.service';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
import { PageComponent } from '@shared/components/page.component';
import { FormBuilder, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import {
twoFactorAuthProvidersLoginData,
TwoFactorAuthProviderType,
TwoFaProviderInfo
} from '@shared/models/two-factor-auth.models';
import { TranslateService } from '@ngx-translate/core';
@Component({
selector: 'tb-two-factor-auth-login',
templateUrl: './two-factor-auth-login.component.html',
styleUrls: ['./two-factor-auth-login.component.scss']
})
export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit {
private providersInfo: TwoFaProviderInfo[];
selectedProvider: TwoFactorAuthProviderType;
twoFactorAuthProvider = TwoFactorAuthProviderType;
allowProviders: TwoFactorAuthProviderType[] = [];
providersData = twoFactorAuthProvidersLoginData;
providerDescription = '';
verificationForm = this.fb.group({
verificationCode: ['', [
Validators.required,
Validators.minLength(6),
Validators.maxLength(6),
Validators.pattern(/^\d*$/)
]]
});
constructor(protected store: Store<AppState>,
private twoFactorAuthService: TwoFactorAuthenticationService,
private authService: AuthService,
private translate: TranslateService,
private fb: FormBuilder) {
super(store);
}
ngOnInit() {
this.providersInfo = this.authService.twoFactorAuthProviders;
Object.values(TwoFactorAuthProviderType).forEach(provider => {
const providerConfig = this.providersInfo.find(config => config.type === provider);
if (providerConfig) {
if (providerConfig.default) {
this.selectedProvider = providerConfig.type;
this.providerDescription = this.translate.instant(this.providersData.get(providerConfig.type).description, {
contact: providerConfig.contact
});
}
this.allowProviders.push(providerConfig.type);
}
});
if (this.selectedProvider !== TwoFactorAuthProviderType.TOTP) {
this.sendCode();
}
}
sendVerificationCode() {
if (this.verificationForm.valid && this.selectedProvider) {
this.authService.checkTwoFaVerificationCode(this.selectedProvider, this.verificationForm.get('verificationCode').value).subscribe(
() => {}
);
}
}
selectProvider(type: TwoFactorAuthProviderType) {
this.selectedProvider = type;
const providerConfig = this.providersInfo.find(config => config.type === type);
this.providerDescription = this.translate.instant(this.providersData.get(providerConfig.type).description, {
contact: providerConfig.contact
});
if (type !== TwoFactorAuthProviderType.TOTP && type !== null) {
this.sendCode();
}
}
sendCode() {
this.twoFactorAuthService.requestTwoFaVerificationCodeSend(this.selectedProvider).subscribe(() => {});
}
cancelLogin() {
this.authService.logout();
}
}

3
ui-ngx/src/app/shared/models/authority.enum.ts

@ -19,5 +19,6 @@ export enum Authority {
TENANT_ADMIN = 'TENANT_ADMIN',
CUSTOMER_USER = 'CUSTOMER_USER',
REFRESH_TOKEN = 'REFRESH_TOKEN',
ANONYMOUS = 'ANONYMOUS'
ANONYMOUS = 'ANONYMOUS',
PRE_VERIFICATION_TOKEN = 'PRE_VERIFICATION_TOKEN'
}

3
ui-ngx/src/app/shared/models/login.models.ts

@ -14,6 +14,8 @@
/// limitations under the License.
///
import { Authority } from '@shared/models/authority.enum';
export interface LoginRequest {
username: string;
password: string;
@ -26,4 +28,5 @@ export interface PublicLoginRequest {
export interface LoginResponse {
token: string;
refreshToken: string;
scope?: Authority;
}

31
ui-ngx/src/app/shared/models/two-factor-auth.models.ts

@ -92,6 +92,7 @@ export interface AccountTwoFaSettings {
export interface TwoFaProviderInfo {
type: TwoFactorAuthProviderType;
default: boolean;
contact?: string;
}
export interface TwoFactorAuthProviderData {
@ -99,6 +100,10 @@ export interface TwoFactorAuthProviderData {
description: string;
}
export interface TwoFactorAuthProviderLoginData extends TwoFactorAuthProviderData {
icon: string;
}
export const twoFactorAuthProvidersData = new Map<TwoFactorAuthProviderType, TwoFactorAuthProviderData>(
[
[
@ -121,3 +126,29 @@ export const twoFactorAuthProvidersData = new Map<TwoFactorAuthProviderType, Two
],
]
);
export const twoFactorAuthProvidersLoginData = new Map<TwoFactorAuthProviderType, TwoFactorAuthProviderLoginData>(
[
[
TwoFactorAuthProviderType.TOTP, {
name: 'security.2fa.provider.totp',
description: 'login.totp-auth-description',
icon: 'mdi:cellphone-key'
}
],
[
TwoFactorAuthProviderType.SMS, {
name: 'security.2fa.provider.sms',
description: 'login.sms-auth-description',
icon: 'mdi:message-reply-text-outline'
}
],
[
TwoFactorAuthProviderType.EMAIL, {
name: 'security.2fa.provider.email',
description: 'login.email-auth-description',
icon: 'mdi:email-outline'
}
],
]
);

9
ui-ngx/src/assets/locale/locale.constant-en_US.json

@ -2480,7 +2480,14 @@
"email": "Email",
"login-with": "Login with {{name}}",
"or": "or",
"error": "Login error"
"error": "Login error",
"verify-your-identity": "Verify your identity",
"select-way-to-verify": "Select a way to verify",
"resend-code": "Resend code",
"try-another-way": "Try another way",
"totp-auth-description": "Please enter the security code from your authenticator app.",
"sms-auth-description": "A security code has been sent to your phone at {{contact}}.",
"email-auth-description": "A security code has been sent to your email address at {{contact}}."
},
"markdown": {
"copy-code": "Click to copy",

Loading…
Cancel
Save