UI: Add 2FA login page

This commit is contained in:
Vladyslav_Prykhodko 2022-05-18 15:44:42 +03:00
parent 866ad82187
commit 1ab9ed2467
14 changed files with 365 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
}
],
]
);

View File

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