diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts
index 15ab552d53..becf23ebb6 100644
--- a/ui-ngx/src/app/core/services/menu.service.ts
+++ b/ui-ngx/src/app/core/services/menu.service.ts
@@ -374,14 +374,6 @@ export class MenuService {
path: '/settings/home',
icon: 'settings_applications'
},
- {
- id: guid(),
- name: 'admin.2fa.2fa',
- type: 'link',
- path: '/settings/2fa',
- icon: 'mdi:two-factor-authentication',
- isMdiIcon: true
- },
{
id: guid(),
name: 'resource.resources-library',
@@ -518,12 +510,6 @@ export class MenuService {
icon: 'settings_applications',
path: '/settings/home'
},
- {
- name: 'admin.2fa.2fa',
- icon: 'mdi:two-factor-authentication',
- isMdiIcon: true,
- path: '/settings/2fa'
- },
{
name: 'resource.resources-library',
icon: 'folder',
diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
index e63eec7b52..b381d39b3c 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts
@@ -190,7 +190,7 @@ const routes: Routes = [
component: TwoFactorAuthSettingsComponent,
canDeactivate: [ConfirmOnExitGuard],
data: {
- auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN],
+ auth: [Authority.SYS_ADMIN],
title: 'admin.2fa.2fa',
breadcrumb: {
label: 'admin.2fa.2fa',
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
index fccec76b54..bcc5a695e2 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
+++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
@@ -27,159 +27,171 @@
-
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss
index 8fb412f648..2ae3981717 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss
+++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss
@@ -15,7 +15,66 @@
*/
:host{
- .container {
- margin-bottom: 1em;
+
+ .fields-group {
+ margin: 8px 0;
+ border: 1px groove rgba(0, 0, 0, .25);
+ border-radius: 4px;
+ position: relative;
+ padding-bottom: 8px;
+
+ legend {
+ color: rgba(0, 0, 0, .7);
+ width: fit-content;
+ }
+
+ &:not(:last-of-type) {
+ padding: 8px;
+ }
+
+ &:last-of-type {
+ margin-bottom: 24px;
+ legend {
+ margin: 0 8px;
+ }
+ }
+
+ .rate-limit-toggle {
+ display: block;
+ margin-bottom: 8px;
+ }
+ }
+
+ .mat-expansion-panel {
+ box-shadow: none;
+ margin: 1px 0 0;
+ &.provider {
+ overflow: inherit;
+ .mat-expansion-panel-header {
+ padding: 0 24px 0 8px;
+ &.mat-expanded {
+ height: 48px;
+ }
+ .mat-slide-toggle {
+ margin-right: 8px;
+ }
+ }
+ .mat-expansion-panel-header-title {
+ height: 40px;
+ }
+ }
+ }
+}
+
+:host ::ng-deep {
+ .mat-expansion-panel {
+ &.provider {
+ .mat-expansion-panel-header > .mat-content {
+ overflow: inherit;
+ }
+ .mat-expansion-panel-body {
+ padding: 0 16px 8px 8px;
+ }
+ }
}
}
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
index 5caf1533ed..b23576f549 100644
--- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
+++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
@@ -14,21 +14,21 @@
/// limitations under the License.
///
-import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { Store } from '@ngrx/store';
import { AppState } from '@core/core.state';
-import { ActivatedRoute } from '@angular/router';
-import { AbstractControl, FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
-import { DialogService } from '@core/services/dialog.service';
-import { TranslateService } from '@ngx-translate/core';
-import { WINDOW } from '@core/services/window.service';
+import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
-import { AuthState } from '@core/auth/auth.models';
-import { getCurrentAuthState } from '@core/auth/auth.selectors';
-import { Authority } from '@shared/models/authority.enum';
-import { TwoFactorAuthProviderType, TwoFactorAuthSettings } from '@shared/models/two-factor-auth.models';
+import {
+ TwoFactorAuthProviderType,
+ TwoFactorAuthSettings,
+ TwoFactorAuthSettingsForm
+} from '@shared/models/two-factor-auth.models';
+import { deepClone, isNotEmptyStr } from '@core/utils';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'tb-2fa-settings',
@@ -37,56 +37,72 @@ import { TwoFactorAuthProviderType, TwoFactorAuthSettings } from '@shared/models
})
export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy {
- private authState: AuthState = getCurrentAuthState(this.store);
- private authUser = this.authState.authUser;
+ private readonly destroy$ = new Subject();
twoFaFormGroup: FormGroup;
- twoFactorAuthProviderTypes = Object.keys(TwoFactorAuthProviderType);
twoFactorAuthProviderType = TwoFactorAuthProviderType;
constructor(protected store: Store,
- private route: ActivatedRoute,
private twoFaService: TwoFactorAuthenticationService,
- private fb: FormBuilder,
- private dialogService: DialogService,
- private translate: TranslateService,
- @Inject(WINDOW) private window: Window) {
+ private fb: FormBuilder) {
super(store);
}
ngOnInit() {
this.build2faSettingsForm();
this.twoFaService.getTwoFaSettings().subscribe((setting) => {
- this.initTwoFactorAuthForm(setting);
+ this.setAuthConfigFormValue(setting);
});
}
ngOnDestroy() {
super.ngOnDestroy();
+ this.destroy$.next();
+ this.destroy$.complete();
}
confirmForm(): FormGroup {
return this.twoFaFormGroup;
}
- isTenantAdmin(): boolean {
- return this.authUser.authority === Authority.TENANT_ADMIN;
+ save() {
+ if (this.twoFaFormGroup.valid) {
+ const setting = this.twoFaFormGroup.value as TwoFactorAuthSettingsForm;
+ this.joinRateLimit(setting, 'verificationCodeCheckRateLimit');
+ this.joinRateLimit(setting, 'verificationCodeSendRateLimit');
+ const providers = setting.providers.filter(provider => provider.enable);
+ providers.forEach(provider => delete provider.enable);
+ const config = Object.assign(setting, {providers});
+ this.twoFaService.saveTwoFaSettings(config).subscribe(
+ () => {
+ this.twoFaFormGroup.markAsUntouched();
+ this.twoFaFormGroup.markAsPristine();
+ }
+ );
+ } else {
+ Object.keys(this.twoFaFormGroup.controls).forEach(field => {
+ const control = this.twoFaFormGroup.get(field);
+ control.markAsTouched({onlySelf: true});
+ });
+ }
}
- save() {
- const setting = this.twoFaFormGroup.value;
- this.twoFaService.saveTwoFaSettings(setting).subscribe(
- (twoFactorAuthSettings) => {
- this.twoFaFormGroup.patchValue(twoFactorAuthSettings, {emitEvent: false});
- this.twoFaFormGroup.markAsUntouched();
- this.twoFaFormGroup.markAsPristine();
- }
- );
+ toggleProviders($event: Event): void {
+ if ($event) {
+ $event.stopPropagation();
+ }
+ }
+
+ trackByElement(i: number, item: any) {
+ return item;
+ }
+
+ get providersForm(): FormArray {
+ return this.twoFaFormGroup.get('providers') as FormArray;
}
private build2faSettingsForm(): void {
this.twoFaFormGroup = this.fb.group({
- useSystemTwoFactorAuthSettings: [this.isTenantAdmin()],
maxVerificationFailuresBeforeUserLockout: [30, [
Validators.required,
Validators.pattern(/^\d*$/),
@@ -98,91 +114,122 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
Validators.min(1),
Validators.pattern(/^\d*$/)
]],
- verificationCodeCheckRateLimit: ['3:900', [Validators.required, Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)]],
- verificationCodeSendRateLimit: ['1:60', [Validators.required, Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)]],
+ verificationCodeCheckRateLimitEnable: [false],
+ verificationCodeCheckRateLimitNumber: ['3', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]],
+ verificationCodeCheckRateLimitTime: ['900', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]],
+ verificationCodeSendRateLimitEnable: [false],
+ verificationCodeSendRateLimitNumber: ['1', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]],
+ verificationCodeSendRateLimitTime: ['60', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]],
providers: this.fb.array([])
});
- }
-
- private initTwoFactorAuthForm(settings: TwoFactorAuthSettings) {
- settings.providers.forEach(() => {
- this.addProvider();
+ Object.values(TwoFactorAuthProviderType).forEach(provider => {
+ this.buildProvidersSettingsForm(provider);
+ });
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').enable({emitEvent: false});
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitTime').enable({emitEvent: false});
+ } else {
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').disable({emitEvent: false});
+ this.twoFaFormGroup.get('verificationCodeCheckRateLimitTime').disable({emitEvent: false});
+ }
+ });
+ this.twoFaFormGroup.get('verificationCodeSendRateLimitEnable').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ this.twoFaFormGroup.get('verificationCodeSendRateLimitNumber').enable({emitEvent: false});
+ this.twoFaFormGroup.get('verificationCodeSendRateLimitTime').enable({emitEvent: false});
+ } else {
+ this.twoFaFormGroup.get('verificationCodeSendRateLimitNumber').disable({emitEvent: false});
+ this.twoFaFormGroup.get('verificationCodeSendRateLimitTime').disable({emitEvent: false});
+ }
});
- this.twoFaFormGroup.patchValue(settings);
- this.twoFaFormGroup.markAsPristine();
}
- addProvider() {
- const newProviders = this.fb.group({
- providerType: [TwoFactorAuthProviderType.TOTP],
- issuerName: ['ThingsBoard', Validators.required],
- smsVerificationMessageTemplate: [{
- value: 'Verification code: ${verificationCode}',
- disabled: true
- }, [
- Validators.required,
- Validators.pattern(/\${verificationCode}/)
- ]],
- verificationCodeLifetime: [{
- value: 120,
- disabled: true
- }, [
- Validators.required,
- Validators.min(1),
- Validators.pattern(/^\d*$/)
- ]]
+ private setAuthConfigFormValue(settings: TwoFactorAuthSettings) {
+ const [checkRateLimitNumber, checkRateLimitTime] = this.splitRateLimit(settings.verificationCodeCheckRateLimit);
+ const [sendRateLimitNumber, sendRateLimitTime] = this.splitRateLimit(settings.verificationCodeSendRateLimit);
+ const allowProvidersConfig = settings.providers.map(provider => provider.providerType);
+ const processFormValue: TwoFactorAuthSettingsForm = Object.assign(deepClone(settings), {
+ verificationCodeCheckRateLimitEnable: checkRateLimitNumber > 0,
+ verificationCodeCheckRateLimitNumber: checkRateLimitNumber || 3,
+ verificationCodeCheckRateLimitTime: checkRateLimitTime || 900,
+ verificationCodeSendRateLimitEnable: sendRateLimitNumber > 0,
+ verificationCodeSendRateLimitNumber: sendRateLimitNumber || 1,
+ verificationCodeSendRateLimitTime: sendRateLimitTime || 60,
+ providers: []
});
- newProviders.get('providerType').valueChanges.subscribe(type => {
- switch (type) {
- case TwoFactorAuthProviderType.SMS:
- newProviders.get('issuerName').disable({emitEvent: false});
- newProviders.get('smsVerificationMessageTemplate').enable({emitEvent: false});
- newProviders.get('verificationCodeLifetime').enable({emitEvent: false});
- break;
- case TwoFactorAuthProviderType.TOTP:
- newProviders.get('issuerName').enable({emitEvent: false});
- newProviders.get('smsVerificationMessageTemplate').disable({emitEvent: false});
- newProviders.get('verificationCodeLifetime').disable({emitEvent: false});
- break;
- case TwoFactorAuthProviderType.EMAIL:
- newProviders.get('issuerName').disable({emitEvent: false});
- newProviders.get('smsVerificationMessageTemplate').disable({emitEvent: false});
- newProviders.get('verificationCodeLifetime').enable({emitEvent: false});
- break;
+ Object.values(TwoFactorAuthProviderType).forEach(provider => {
+ const index = allowProvidersConfig.indexOf(provider);
+ if (index > -1) {
+ processFormValue.providers.push(Object.assign(settings.providers[index], {enable: true}));
+ } else {
+ processFormValue.providers.push({enable: false});
}
});
- if (this.providersForm.length) {
- const selectedProviderTypes = this.providersForm.value.map(providers => providers.providerType);
- const allowProviders = this.twoFactorAuthProviderTypes.filter(provider => !selectedProviderTypes.includes(provider));
- newProviders.get('providerType').setValue(allowProviders[0]);
- newProviders.updateValueAndValidity();
- }
- this.providersForm.push(newProviders);
- this.providersForm.markAsDirty();
+ this.twoFaFormGroup.patchValue(processFormValue);
}
- removeProviders($event: Event, index: number): void {
- if ($event) {
- $event.stopPropagation();
- $event.preventDefault();
+ private buildProvidersSettingsForm(provider: TwoFactorAuthProviderType) {
+ const formControlConfig: {[key: string]: any} = {
+ providerType: [provider],
+ enable: [false]
+ };
+ switch (provider) {
+ case TwoFactorAuthProviderType.TOTP:
+ formControlConfig.issuerName = [{value: 'ThingsBoard', disabled: true}, Validators.required];
+ break;
+ case TwoFactorAuthProviderType.SMS:
+ formControlConfig.smsVerificationMessageTemplate = [{value: 'Verification code: ${verificationCode}', disabled: true}, [
+ Validators.required,
+ Validators.pattern(/\${verificationCode}/)
+ ]];
+ formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, [
+ Validators.required,
+ Validators.min(1),
+ Validators.pattern(/^\d*$/)
+ ]];
+ break;
+ case TwoFactorAuthProviderType.EMAIL:
+ formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, [
+ Validators.required,
+ Validators.min(1),
+ Validators.pattern(/^\d*$/)
+ ]];
+ break;
}
- this.providersForm.removeAt(index);
- this.providersForm.markAsTouched();
- this.providersForm.markAsDirty();
- }
-
- get providersForm(): FormArray {
- return this.twoFaFormGroup.get('providers') as FormArray;
+ const newProviders = this.fb.group(formControlConfig);
+ newProviders.get('enable').valueChanges.pipe(
+ takeUntil(this.destroy$)
+ ).subscribe(value => {
+ if (value) {
+ newProviders.enable({emitEvent: false});
+ } else {
+ newProviders.disable({emitEvent: false});
+ newProviders.get('enable').enable({emitEvent: false});
+ newProviders.get('providerType').enable({emitEvent: false});
+ }
+ });
+ this.providersForm.push(newProviders);
}
- trackByElement(i: number, item: any) {
- return item;
+ private splitRateLimit(setting: string): [number, number] {
+ if (isNotEmptyStr(setting)) {
+ const [attemptNumber, time] = setting.split(':');
+ return [parseInt(attemptNumber, 10), parseInt(time, 10)];
+ }
+ return [0, 0];
}
- selectedTypes(type: TwoFactorAuthProviderType, index: number): boolean {
- const selectedProviderTypes: TwoFactorAuthProviderType[] = this.providersForm.value.map(providers => providers.providerType);
- selectedProviderTypes.splice(index, 1);
- return selectedProviderTypes.includes(type);
+ private joinRateLimit(processFormValue: TwoFactorAuthSettingsForm, property: string) {
+ if (processFormValue[`${property}Enable`]) {
+ processFormValue[property] = [processFormValue[`${property}Number`], processFormValue[`${property}Time`]].join(':');
+ }
+ delete processFormValue[`${property}Enable`];
+ delete processFormValue[`${property}Number`];
+ delete processFormValue[`${property}Time`];
}
-
}
diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts
index 731c1212e9..57e4e6b409 100644
--- a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts
+++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts
@@ -23,9 +23,22 @@ export interface TwoFactorAuthSettings {
verificationCodeSendRateLimit: string;
}
+export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{
+ providers: Array;
+ verificationCodeCheckRateLimitEnable: boolean;
+ verificationCodeCheckRateLimitNumber: number;
+ verificationCodeCheckRateLimitTime: number;
+ verificationCodeSendRateLimitEnable: boolean;
+ verificationCodeSendRateLimitNumber: number;
+ verificationCodeSendRateLimitTime: number;
+}
+
export type TwoFactorAuthProviderConfig = Partial;
+export type TwoFactorAuthProviderConfigForm = Partial & TwoFactorAuthProviderFormConfig;
+
export interface TotpTwoFactorAuthProviderConfig {
providerType: TwoFactorAuthProviderType;
issuerName: string;
@@ -42,6 +55,10 @@ export interface EmailTwoFactorAuthProviderConfig {
verificationCodeLifetime: number;
}
+export interface TwoFactorAuthProviderFormConfig {
+ enable: boolean;
+}
+
export enum TwoFactorAuthProviderType{
TOTP = 'TOTP',
SMS = 'SMS',
@@ -69,5 +86,38 @@ export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwo
export interface AccountTwoFaSettings {
- configs?: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig};
+ configs: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig};
}
+
+export interface TwoFaProviderInfo {
+ type: TwoFactorAuthProviderType;
+ default: boolean;
+}
+
+export interface TwoFactorAuthProviderData {
+ name: string;
+ description: string;
+}
+
+export const twoFactorAuthProvidersData = new Map(
+ [
+ [
+ TwoFactorAuthProviderType.TOTP, {
+ name: 'Authentication app',
+ description: 'Use apps like Google Authenticator, Authy, or Duo on your phone to authenticate. It will generate a security code for logging in.'
+ }
+ ],
+ [
+ TwoFactorAuthProviderType.SMS, {
+ name: 'SMS',
+ description: 'Use your phone to authenticate. We\'ll send you a security code via SMS message when you log in.'
+ }
+ ],
+ [
+ TwoFactorAuthProviderType.EMAIL, {
+ name: 'Email',
+ description: 'Use a security code sent to your email address to authenticate.'
+ }
+ ],
+ ]
+);
diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json
index 72bd583ef8..28ea3e1f7e 100644
--- a/ui-ngx/src/assets/locale/locale.constant-en_US.json
+++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json
@@ -123,6 +123,7 @@
"number-from-required": "Phone Number From is required.",
"number-to": "Phone Number To",
"number-to-required": "Phone Number To is required.",
+ "phone-number": "Phone Number",
"phone-number-hint": "Phone Number in E.164 format, ex. +19995550123",
"phone-number-hint-twilio": "Phone Number in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX",
"phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.",
@@ -314,30 +315,32 @@
},
"2fa": {
"2fa": "Two-factor authentication",
- "add-provider": "Add provider",
- "available-providers": "Available providers:",
+ "available-providers": "Available providers",
+ "general-setting": "General setting",
"issuer-name": "Issuer name",
"issuer-name-required": "Issuer name is required.",
"max-verification-failures-before-user-lockout": "Max verification failures before user lockout",
"max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.",
"max-verification-failures-before-user-lockout-required": "Max verification failures is required.",
+ "number-of-attempts": "Number of attempts",
+ "number-of-attempts-pattern": "Number of attempts must be a positive integer.",
+ "number-of-attempts-required": "Number of attempts is required.",
"provider": "Provider",
"total-allowed-time-for-verification": "Total allowed time for verification (sec)",
"total-allowed-time-for-verification-pattern": "Total allowed time must be a positive integer.",
"total-allowed-time-for-verification-required": "Total allowed time is required.",
"use-system-two-factor-auth-settings": "Use system two factor auth settings",
"verification-code-check-rate-limit": "Verification code check rate limit",
- "verification-code-check-rate-limit-pattern": "Verification code check limit has invalid format",
- "verification-code-check-rate-limit-required": "Verification code check rate limit is required.",
"verification-code-lifetime": "Verification code lifetime (sec)",
"verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.",
"verification-code-lifetime-required": "Verification code lifetime is required.",
"verification-code-send-rate-limit": "Verification code send rate limit",
- "verification-code-send-rate-limit-pattern": "Verification code send limit has invalid format",
- "verification-code-send-rate-limit-required": "Verification code send rate limit is required.",
"verification-message-template": "Verification message template",
"verification-message-template-pattern": "Verification message need to contains pattern: ${verificationCode}",
- "verification-message-template-required": "Verification message template is required."
+ "verification-message-template-required": "Verification message template is required.",
+ "within-time": "Within time (sec)",
+ "within-time-pattern": "Time must be a positive integer.",
+ "within-time-required": "Time is required."
}
},
"alarm": {