Merge pull request #14043 from ArtemDzhereleiko/AD/imp/enforce-2fa

2FA enforcement
This commit is contained in:
Vladyslav Prykhodko 2025-10-15 14:49:53 +03:00 committed by GitHub
commit cdacf1fe53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1165 additions and 187 deletions

View File

@ -68,6 +68,7 @@ export class AuthService {
redirectUrl: string;
oauth2Clients: Array<OAuth2ClientLoginInfo> = null;
twoFactorAuthProviders: Array<TwoFaProviderInfo> = null;
forceTwoFactorAuthProviders: Array<TwoFactorAuthProviderType> = null;
private refreshTokenSubject: ReplaySubject<LoginResponse> = null;
private jwtHelper = new JwtHelperService();
@ -117,6 +118,9 @@ export class AuthService {
if (loginResponse.scope === Authority.PRE_VERIFICATION_TOKEN) {
this.router.navigateByUrl(`login/mfa`);
}
if (loginResponse.scope === Authority.MFA_CONFIGURATION_TOKEN) {
this.router.navigateByUrl(`login/force-mfa`);
}
}
));
}
@ -239,6 +243,15 @@ export class AuthService {
);
}
public getAvailableTwoFaProviders(): Observable<Array<TwoFaProviderInfo>> {
return this.http.get<Array<TwoFaProviderInfo>>(`/api/2fa/providers`, defaultHttpOptions()).pipe(
catchError(() => of([])),
tap((providers) => {
this.forceTwoFactorAuthProviders = providers;
})
);
}
public 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) {
@ -266,6 +279,8 @@ export class AuthService {
if (isAuthenticated) {
if (authState.authUser.authority === Authority.PRE_VERIFICATION_TOKEN) {
result = this.router.parseUrl('login/mfa');
} else if (authState.authUser.authority === Authority.MFA_CONFIGURATION_TOKEN) {
result = this.router.parseUrl('login/force-mfa');
} else if (!path || path === 'login' || this.forceDefaultPlace(authState, path, params)) {
if (this.redirectUrl) {
const redirectUrl = this.redirectUrl;
@ -399,7 +414,7 @@ export class AuthService {
loadUserSubject.error(err);
}
);
} else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN) {
} else if (authPayload.authUser?.authority === Authority.PRE_VERIFICATION_TOKEN || authPayload.authUser?.authority === Authority.MFA_CONFIGURATION_TOKEN) {
loadUserSubject.next(authPayload);
loadUserSubject.complete();
} else if (authPayload.authUser?.userId) {

View File

@ -104,6 +104,16 @@ export class AuthGuard {
}
this.authService.logout();
return of(this.authService.defaultUrl(false));
} else if (path === 'login.force-mfa') {
if (authState.authUser?.authority === Authority.MFA_CONFIGURATION_TOKEN) {
return this.authService.getAvailableTwoFaProviders().pipe(
map(() => {
return true;
})
);
}
this.authService.logout();
return of(this.authService.defaultUrl(false));
} else {
return of(true);
}

View File

@ -30,163 +30,216 @@
<mat-card-content>
<form [formGroup]="twoFaFormGroup" (ngSubmit)="save()">
<fieldset [disabled]="isLoading$ | async">
<div>
<fieldset class="fields-group" formArrayName="providers">
<legend class="group-title" translate>admin.2fa.available-providers</legend>
<ng-container *ngFor="let provider of providersForm.controls; let i = index; let $last = last; trackBy: trackByElement">
<mat-expansion-panel class="provider" [formGroupName]="i">
<mat-expansion-panel-header>
<mat-panel-title class="flex items-center justify-start">
<mat-slide-toggle
(mousedown)="toggleExtensionPanel($event, i, provider.get('enable').value)"
formControlName="enable">
{{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container [ngSwitch]="provider.get('providerType').value">
<ng-container *ngSwitchCase="twoFactorAuthProviderType.TOTP">
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.issuer-name</mat-label>
<input matInput formControlName="issuerName" required>
<mat-error *ngIf="provider.get('issuerName').hasError('required') ||
provider.get('issuerName').hasError('pattern')">
{{ "admin.2fa.issuer-name-required" | translate }}
</mat-error>
</mat-form-field>
</ng-container>
<div *ngSwitchCase="twoFactorAuthProviderType.SMS"
class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.verification-message-template</mat-label>
<input matInput formControlName="smsVerificationMessageTemplate" required>
<mat-error *ngIf="provider.get('smsVerificationMessageTemplate').hasError('required')">
{{ "admin.2fa.verification-message-template-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('smsVerificationMessageTemplate').hasError('pattern')">
{{ "admin.2fa.verification-message-template-pattern" | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.verification-code-lifetime</mat-label>
<input matInput formControlName="verificationCodeLifetime" type="number" step="1" min="1" required>
<mat-error *ngIf="provider.get('verificationCodeLifetime').hasError('required')">
{{ "admin.2fa.verification-code-lifetime-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('verificationCodeLifetime').hasError('min') ||
provider.get('verificationCodeLifetime').hasError('pattern')">
{{ "admin.2fa.verification-code-lifetime-pattern" | translate }}
</mat-error>
</mat-form-field>
</div>
<div *ngSwitchCase="twoFactorAuthProviderType.EMAIL">
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.verification-code-lifetime</mat-label>
<input matInput formControlName="verificationCodeLifetime" type="number" step="1" min="1" required>
<mat-error *ngIf="provider.get('verificationCodeLifetime').hasError('required')">
{{ "admin.2fa.verification-code-lifetime-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('verificationCodeLifetime').hasError('min') ||
provider.get('verificationCodeLifetime').hasError('pattern')">
{{ "admin.2fa.verification-code-lifetime-pattern" | translate }}
</mat-error>
</mat-form-field>
</div>
<div *ngSwitchCase="twoFactorAuthProviderType.BACKUP_CODE">
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.number-of-codes</mat-label>
<input matInput formControlName="codesQuantity" type="number" step="1" min="1" required>
<mat-error *ngIf="provider.get('codesQuantity').hasError('required')">
{{ "admin.2fa.number-of-codes-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('codesQuantity').hasError('min') ||
provider.get('codesQuantity').hasError('pattern')">
{{ "admin.2fa.number-of-codes-pattern" | translate }}
</mat-error>
</mat-form-field>
</div>
</ng-container>
</ng-template>
</mat-expansion-panel>
<mat-divider *ngIf="!$last"></mat-divider>
</ng-container>
</fieldset>
<fieldset class="fields-group">
<legend class="group-title" translate>admin.2fa.verification-limitations</legend>
<div class="input-row flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.total-allowed-time-for-verification</mat-label>
<input matInput required formControlName="totalAllowedTimeForVerification" type="number" step="1" min="60">
<mat-error *ngIf="twoFaFormGroup.get('totalAllowedTimeForVerification').hasError('required')">
{{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }}
</mat-error>
<mat-error *ngIf="twoFaFormGroup.get('totalAllowedTimeForVerification').hasError('pattern')
|| twoFaFormGroup.get('totalAllowedTimeForVerification').hasError('min')">
{{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.retry-verification-code-period</mat-label>
<input matInput required formControlName="minVerificationCodeSendPeriod" type="number" step="1" min="5">
<mat-error *ngIf="twoFaFormGroup.get('minVerificationCodeSendPeriod').hasError('required')">
{{ 'admin.2fa.retry-verification-code-period-required' | translate }}
</mat-error>
<mat-error *ngIf="twoFaFormGroup.get('minVerificationCodeSendPeriod').hasError('pattern')
|| twoFaFormGroup.get('minVerificationCodeSendPeriod').hasError('min')">
{{ 'admin.2fa.retry-verification-code-period-pattern' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.max-verification-failures-before-user-lockout</mat-label>
<input matInput formControlName="maxVerificationFailuresBeforeUserLockout" type="number" step="1" min="0" max="65535">
<mat-error *ngIf="twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('pattern')
|| twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('min')
|| twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('max')">
{{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }}
</mat-error>
</mat-form-field>
</div>
<mat-expansion-panel class="provider">
<div class="tb-form-panel no-padding no-border">
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings no-padding-bottom">
<mat-expansion-panel-header>
<mat-panel-title class="flex items-center justify-start">
<mat-slide-toggle (mousedown)="toggleExtensionPanel($event, providersForm.length, twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').value)"
formControlName="verificationCodeCheckRateLimitEnable">
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-center justify-start"
(mousedown)="toggleExtensionPanel($event, 0, twoFaFormGroup.get('enforceTwoFa').value)"
formControlName="enforceTwoFa">
{{ 'admin.2fa.force-2fa' | translate }}
</mat-slide-toggle>
{{ 'admin.2fa.verification-code-check-rate-limit' | translate }}
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="flex flex-row xs:flex-col gt-xs:gap-2">
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.number-of-checking-attempts</mat-label>
<input matInput formControlName="verificationCodeCheckRateLimitNumber" required type="number" step="1" min="1">
<mat-error *ngIf="twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('required')">
{{ 'admin.2fa.number-of-checking-attempts-required' | translate }}
</mat-error>
<mat-error *ngIf="twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('pattern')
|| twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('min')">
{{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }}
</mat-error>
<section class="tb-form-panel no-padding no-border" formGroupName="enforcedUsersFilter">
<mat-form-field class="mat-block" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>admin.2fa.enforce-for</mat-label>
<mat-select formControlName="type">
<mat-option *ngFor="let type of notificationTargetConfigTypes" [value]="type">
{{ notificationTargetConfigTypeInfoMap.get(type).name | translate }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="mat-block flex-1">
<mat-label translate>admin.2fa.within-time</mat-label>
<input matInput formControlName="verificationCodeCheckRateLimitTime" required type="number" step="1" min="1">
<mat-error *ngIf="twoFaFormGroup.get('verificationCodeCheckRateLimitTime').hasError('required')">
{{ 'admin.2fa.within-time-required' | translate }}
</mat-error>
<mat-error *ngIf="twoFaFormGroup.get('verificationCodeCheckRateLimitTime').hasError('pattern')
|| twoFaFormGroup.get('verificationCodeCheckRateLimitTime').hasError('min')">
{{ 'admin.2fa.within-time-pattern' | translate }}
</mat-error>
</mat-form-field>
</div>
<section class="tb-form-panel no-padding no-border" *ngIf="twoFaFormGroup.get('enforcedUsersFilter.type').value === notificationTargetConfigType.TENANT_ADMINISTRATORS">
<div class="flex flex-1 items-center justify-center">
<tb-toggle-select class="tb-notification-tenant-group" appearance="fill"
formControlName="filterByTenants">
<tb-toggle-option [value]="true">{{ 'tenant.tenant' | translate }}</tb-toggle-option>
<tb-toggle-option [value]="false">{{ 'tenant-profile.tenant-profile' | translate }}</tb-toggle-option>
</tb-toggle-select>
</div>
<ng-container *ngIf="twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').value; else tenantProfiles">
<tb-entity-list
formControlName="tenantsIds"
subscriptSizing="dynamic"
appearance="outline"
labelText="{{ 'tenant.tenants' | translate }}"
placeholderText="{{ 'tenant.tenants' | translate }}"
hint="{{ 'notification.tenants-list-rule-hint' | translate }}"
[entityType]="entityType.TENANT">
</tb-entity-list>
</ng-container>
<ng-template #tenantProfiles>
<tb-entity-list
formControlName="tenantProfilesIds"
subscriptSizing="dynamic"
appearance="outline"
labelText="{{ 'tenant-profile.tenant-profiles' | translate }}"
placeholderText="{{ 'tenant-profile.tenant-profiles' | translate }}"
hint="{{ 'notification.tenant-profiles-list-rule-hint' | translate }}"
[entityType]="entityType.TENANT_PROFILE">
</tb-entity-list>
</ng-template>
</section>
</section>
</ng-template>
</mat-expansion-panel>
</fieldset>
</div>
<section class="tb-form-panel stroked" formArrayName="providers">
<div class="tb-form-panel-title" translate>admin.2fa.available-providers</div>
<ng-container *ngFor="let provider of providersForm.controls; let i = index; trackBy: trackByElement">
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings" [formGroupName]="i">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-center justify-start"
(mousedown)="toggleExtensionPanel($event, i, provider.get('enable').value)"
formControlName="enable">
{{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<ng-container [ngSwitch]="provider.get('providerType').value">
<ng-container *ngSwitchCase="twoFactorAuthProviderType.TOTP">
<mat-form-field class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>admin.2fa.issuer-name</mat-label>
<input matInput formControlName="issuerName" required>
<mat-error *ngIf="provider.get('issuerName').hasError('required') ||
provider.get('issuerName').hasError('pattern')">
{{ "admin.2fa.issuer-name-required" | translate }}
</mat-error>
</mat-form-field>
</ng-container>
<div *ngSwitchCase="twoFactorAuthProviderType.SMS"
>
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>admin.2fa.verification-message-template</mat-label>
<input matInput formControlName="smsVerificationMessageTemplate" required>
<mat-error *ngIf="provider.get('smsVerificationMessageTemplate').hasError('required')">
{{ "admin.2fa.verification-message-template-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('smsVerificationMessageTemplate').hasError('pattern')">
{{ "admin.2fa.verification-message-template-pattern" | translate }}
</mat-error>
</mat-form-field>
<tb-time-unit-input
appearance="outline"
subscriptSizing="dynamic"
required
labelText="{{ 'admin.2fa.verification-code-lifetime' | translate }}"
requiredText="{{ 'admin.2fa.verification-code-lifetime-required' | translate }}"
minErrorText="{{ 'admin.2fa.verification-code-lifetime-pattern' | translate }}"
[minTime]="1"
formControlName="verificationCodeLifetime">
</tb-time-unit-input>
</div>
<div *ngSwitchCase="twoFactorAuthProviderType.EMAIL">
<tb-time-unit-input
appearance="outline"
subscriptSizing="dynamic"
required
labelText="{{ 'admin.2fa.verification-code-lifetime' | translate }}"
requiredText="{{ 'admin.2fa.verification-code-lifetime-required' | translate }}"
minErrorText="{{ 'admin.2fa.verification-code-lifetime-pattern' | translate }}"
[minTime]="1"
formControlName="verificationCodeLifetime">
</tb-time-unit-input>
</div>
<div *ngSwitchCase="twoFactorAuthProviderType.BACKUP_CODE">
<mat-form-field class="mat-block flex-1" appearance="outline" subscriptSizing="dynamic">
<mat-label translate>admin.2fa.number-of-codes</mat-label>
<input matInput formControlName="codesQuantity" type="number" step="1" min="1" required>
<mat-error *ngIf="provider.get('codesQuantity').hasError('required')">
{{ "admin.2fa.number-of-codes-required" | translate }}
</mat-error>
<mat-error *ngIf="provider.get('codesQuantity').hasError('min') ||
provider.get('codesQuantity').hasError('pattern')">
{{ "admin.2fa.number-of-codes-pattern" | translate }}
</mat-error>
</mat-form-field>
</div>
</ng-container>
</ng-template>
</mat-expansion-panel>
</div>
</ng-container>
</section>
<section class="tb-form-panel stroked mb-4">
<div class="tb-form-panel-title" translate>admin.2fa.verification-limitations</div>
<div class="tb-form-panel no-gap no-border no-padding">
<div class="input-row flex flex-col">
<tb-time-unit-input
appearance="outline"
required
labelText="{{ 'admin.2fa.total-allowed-time-for-verification' | translate }}"
requiredText="{{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }}"
minErrorText="{{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }}"
[minTime]="60"
formControlName="totalAllowedTimeForVerification">
</tb-time-unit-input>
<tb-time-unit-input
appearance="outline"
required
labelText="{{ 'admin.2fa.retry-verification-code-period' | translate }}"
requiredText="{{ 'admin.2fa.retry-verification-code-period-required' | translate }}"
minErrorText="{{ 'admin.2fa.retry-verification-code-period-pattern' | translate }}"
[minTime]="5"
formControlName="minVerificationCodeSendPeriod">
</tb-time-unit-input>
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>admin.2fa.max-verification-failures-before-user-lockout</mat-label>
<input matInput formControlName="maxVerificationFailuresBeforeUserLockout" type="number" step="1" min="0" max="65535">
<mat-error *ngIf="twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('pattern')
|| twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('min')
|| twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('max')">
{{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }}
</mat-error>
</mat-form-field>
</div>
<div class="tb-form-panel stroked tb-slide-toggle">
<mat-expansion-panel class="tb-settings">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-slide-toggle class="mat-slide flex items-center justify-start" (mousedown)="toggleExtensionPanel($event, providersForm.length, twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').value)"
formControlName="verificationCodeCheckRateLimitEnable">
{{ 'admin.2fa.verification-code-check-rate-limit' | translate }}
</mat-slide-toggle>
</mat-panel-title>
</mat-expansion-panel-header>
<ng-template matExpansionPanelContent>
<div class="flex flex-col">
<mat-form-field class="mat-block flex-1" appearance="outline">
<mat-label translate>admin.2fa.number-of-checking-attempts</mat-label>
<input matInput formControlName="verificationCodeCheckRateLimitNumber" required type="number" step="1" min="1">
<mat-error *ngIf="twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('required')">
{{ 'admin.2fa.number-of-checking-attempts-required' | translate }}
</mat-error>
<mat-error *ngIf="twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('pattern')
|| twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('min')">
{{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }}
</mat-error>
</mat-form-field>
<tb-time-unit-input
appearance="outline"
subscriptSizing="dynamic"
required
labelText="{{ 'admin.2fa.within-time' | translate }}"
requiredText="{{ 'admin.2fa.within-time-required' | translate }}"
minErrorText="{{ 'admin.2fa.within-time-pattern' | translate }}"
[minTime]="1"
formControlName="verificationCodeCheckRateLimitTime">
</tb-time-unit-input>
</div>
</ng-template>
</mat-expansion-panel>
</div>
</div>
</section>
</div>
<div class="flex flex-row items-center justify-end gap-2">
<button mat-button mat-raised-button color="primary"

View File

@ -71,13 +71,6 @@
}
:host ::ng-deep {
.mat-mdc-form-field {
.mat-mdc-form-field-infix {
width: 100%;
}
}
.mat-expansion-panel {
.mat-expansion-panel-content {
font-size: 16px;

View File

@ -14,7 +14,7 @@
/// limitations under the License.
///
import { Component, OnDestroy, OnInit, QueryList, ViewChildren } from '@angular/core';
import { Component, DestroyRef, OnInit, QueryList, ViewChildren } from '@angular/core';
import { PageComponent } from '@shared/components/page.component';
import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard';
import { Store } from '@ngrx/store';
@ -28,32 +28,40 @@ import {
TwoFactorAuthSettings,
TwoFactorAuthSettingsForm
} from '@shared/models/two-factor-auth.models';
import { isNotEmptyStr } from '@core/utils';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { isDefined, isNotEmptyStr } from '@core/utils';
import { MatExpansionPanel } from '@angular/material/expansion';
import { NotificationTargetConfigType, NotificationTargetConfigTypeInfoMap } from '@shared/models/notification.models';
import { EntityType } from '@shared/models/entity-type.models';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({
selector: 'tb-2fa-settings',
templateUrl: './two-factor-auth-settings.component.html',
styleUrls: [ './settings-card.scss', './two-factor-auth-settings.component.scss']
})
export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy {
export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm {
private readonly destroy$ = new Subject<void>();
private readonly posIntValidation = [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)];
twoFaFormGroup: UntypedFormGroup;
twoFactorAuthProviderType = TwoFactorAuthProviderType;
twoFactorAuthProvidersData = twoFactorAuthProvidersData;
notificationTargetConfigType = NotificationTargetConfigType;
notificationTargetConfigTypes: NotificationTargetConfigType[] = this.allowNotificationTargetConfigTypes();
notificationTargetConfigTypeInfoMap = NotificationTargetConfigTypeInfoMap;
filterByTenants: boolean;
entityType = EntityType;
showMainLoadingBar = false;
@ViewChildren(MatExpansionPanel) expansionPanel: QueryList<MatExpansionPanel>;
constructor(protected store: Store<AppState>,
private twoFaService: TwoFactorAuthenticationService,
private fb: UntypedFormBuilder) {
private fb: UntypedFormBuilder,
private destroyRef: DestroyRef) {
super(store);
}
@ -64,12 +72,6 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
});
}
ngOnDestroy() {
super.ngOnDestroy();
this.destroy$.next();
this.destroy$.complete();
}
confirmForm(): UntypedFormGroup {
return this.twoFaFormGroup;
}
@ -80,7 +82,10 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
this.joinRateLimit(setting, 'verificationCodeCheckRateLimit');
const providers = setting.providers.filter(provider => provider.enable);
providers.forEach(provider => delete provider.enable);
const config = Object.assign(setting, {providers});
const enforcedUsersFilter = this.twoFaFormGroup.get('enforcedUsersFilter').value;
delete enforcedUsersFilter.filterByTenants;
const config = Object.assign(setting, {providers}, {enforcedUsersFilter});
this.filterByTenants = this.twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').value;
this.twoFaService.saveTwoFaSettings(config).subscribe(
(settings) => {
this.setAuthConfigFormValue(settings);
@ -117,6 +122,13 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
private build2faSettingsForm(): void {
this.twoFaFormGroup = this.fb.group({
enforceTwoFa: [false],
enforcedUsersFilter: this.fb.group({
type: [NotificationTargetConfigType.ALL_USERS],
filterByTenants: [true],
tenantsIds: [],
tenantProfilesIds: []
}),
maxVerificationFailuresBeforeUserLockout: [30, [
Validators.pattern(/^\d*$/),
Validators.min(0),
@ -137,7 +149,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
this.buildProvidersSettingsForm(provider);
});
this.twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').valueChanges.pipe(
takeUntil(this.destroy$)
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
if (value) {
this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').enable({emitEvent: false});
@ -148,7 +160,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
}
});
this.providersForm.valueChanges.pipe(
takeUntil(this.destroy$)
takeUntilDestroyed(this.destroyRef)
).subscribe((value: TwoFactorAuthProviderConfigForm[]) => {
const activeProvider = value.filter(provider => provider.enable);
const indexBackupCode = Object.values(TwoFactorAuthProviderType).indexOf(TwoFactorAuthProviderType.BACKUP_CODE);
@ -161,6 +173,15 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
this.providersForm.at(indexBackupCode).get('enable').enable( {emitEvent: false});
}
});
this.twoFaFormGroup.get('enforceTwoFa').valueChanges.pipe(
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
if (value) {
this.twoFaFormGroup.get('enforcedUsersFilter').enable({emitEvent: false});
} else {
this.twoFaFormGroup.get('enforcedUsersFilter').disable({emitEvent: false});
}
});
}
private setAuthConfigFormValue(settings: TwoFactorAuthSettings) {
@ -172,19 +193,24 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
verificationCodeCheckRateLimitTime: checkRateLimitTime || 900,
providers: []
});
if (settings?.enforceTwoFa) {
this.getByIndexPanel(0).open();
}
if (checkRateLimitNumber > 0) {
this.getByIndexPanel(this.providersForm.length).open();
this.getByIndexPanel(this.providersForm.length+1).open();
}
Object.values(TwoFactorAuthProviderType).forEach((provider, index) => {
const findIndex = allowProvidersConfig.indexOf(provider);
if (findIndex > -1) {
processFormValue.providers.push(Object.assign(settings.providers[findIndex], {enable: true}));
this.getByIndexPanel(index).open();
this.getByIndexPanel(index+1).open();
} else {
processFormValue.providers.push({enable: false});
}
});
this.twoFaFormGroup.patchValue(processFormValue);
this.filterByTenants = isDefined(this.filterByTenants) ? this.filterByTenants : !Array.isArray(settings?.enforcedUsersFilter.tenantProfilesIds);
this.twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').patchValue(this.filterByTenants, {onlySelf: true});
}
private buildProvidersSettingsForm(provider: TwoFactorAuthProviderType) {
@ -212,7 +238,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
}
const newProviders = this.fb.group(formControlConfig);
newProviders.get('enable').valueChanges.pipe(
takeUntil(this.destroy$)
takeUntilDestroyed(this.destroyRef)
).subscribe(value => {
if (value) {
newProviders.enable({emitEvent: false});
@ -245,4 +271,12 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI
delete processFormValue[`${property}Number`];
delete processFormValue[`${property}Time`];
}
private allowNotificationTargetConfigTypes(): NotificationTargetConfigType[] {
return [
NotificationTargetConfigType.ALL_USERS,
NotificationTargetConfigType.TENANT_ADMINISTRATORS,
NotificationTargetConfigType.SYSTEM_ADMINISTRATORS
];
}
}

View File

@ -52,6 +52,19 @@
<form [formGroup]="totpConfigForm" class="flex flex-col items-center justify-start" (ngSubmit)="onSaveConfig()">
<p class="mat-body qr-code-description" translate>security.2fa.dialog.scan-qr-code</p>
<canvas class="flex-1" #canvas [style.display]="totpAuthURL ? 'block' : 'none'"></canvas>
<p class="mat-body qr-code-description" translate>login.enter-key-manually</p>
<div class="flex flex-row items-center w-full overflow-hidden max-w-[375px]">
<span tbTruncateWithTooltip class="w-full">{{ totpAuthURLSecret }}</span>
<tb-copy-button
class="attribute-copy"
[disabled]="isLoading$ | async"
[copyText]="totpAuthURLSecret"
tooltipText="{{ 'attribute.copy-key' | translate }}"
tooltipPosition="above"
icon="content_copy"
[style]="{'font-size': '24px'}">
</tb-copy-button>
</div>
<p class="mat-body qr-code-description" style="margin-top: 30px;" translate>security.2fa.dialog.enter-verification-code</p>
<mat-form-field class="mat-block code-container flex-1">
<input matInput formControlName="verificationCode"

View File

@ -42,6 +42,7 @@ export class TotpAuthDialogComponent extends DialogComponent<TotpAuthDialogCompo
totpConfigForm: UntypedFormGroup;
totpAuthURL: string;
totpAuthURLSecret: string;
@ViewChild('stepper', {static: false}) stepper: MatStepper;
@ViewChild('canvas', {static: false}) canvasRef: ElementRef<HTMLCanvasElement>;
@ -55,6 +56,7 @@ export class TotpAuthDialogComponent extends DialogComponent<TotpAuthDialogCompo
this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => {
this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig;
this.totpAuthURL = this.authAccountConfig.authUrl;
this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret');
this.authAccountConfig.useByDefault = true;
import('qrcode').then((QRCode) => {
unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL);

View File

@ -25,6 +25,7 @@ import { CreatePasswordComponent } from '@modules/login/pages/login/create-passw
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { Authority } from '@shared/models/authority.enum';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.component';
const routes: Routes = [
{
@ -83,6 +84,16 @@ const routes: Routes = [
},
canActivate: [AuthGuard]
},
{
path: 'login/force-mfa',
component: ForceTwoFactorAuthLoginComponent,
data: {
title: 'login.two-factor-authentication',
auth: [Authority.MFA_CONFIGURATION_TOKEN],
module: 'public'
},
canActivate: [AuthGuard]
},
{
path: 'activationLinkExpired',
component: LinkExpiredComponent,

View File

@ -25,6 +25,7 @@ import { ResetPasswordComponent } from '@modules/login/pages/login/reset-passwor
import { CreatePasswordComponent } from '@modules/login/pages/login/create-password.component';
import { TwoFactorAuthLoginComponent } from '@modules/login/pages/login/two-factor-auth-login.component';
import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.component';
import { ForceTwoFactorAuthLoginComponent } from '@modules/login/pages/login/force-two-factor-auth-login.component';
@NgModule({
declarations: [
@ -33,7 +34,8 @@ import { LinkExpiredComponent } from '@modules/login/pages/login/link-expired.co
ResetPasswordComponent,
CreatePasswordComponent,
TwoFactorAuthLoginComponent,
LinkExpiredComponent
LinkExpiredComponent,
ForceTwoFactorAuthLoginComponent,
],
imports: [
CommonModule,

View File

@ -0,0 +1,304 @@
<!--
Copyright © 2016-2025 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 flex flex-row items-center justify-center"
style="width: 100%;">
@switch (state()) {
@case (ForceTwoFAState.SETUP) {
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start">
<button mat-icon-button type="button" (click)="cancelLogin()">
<mat-icon>chevron_left</mat-icon>
</button>
{{ (config ? 'login.two-fa' :'login.two-fa-required') | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="providers-container tb-default flex flex-col gap-2">
<p class="mat-body"> {{ (config ? 'login.set-up-verification-method-login' :'login.set-up-verification-method') | translate }}</p>
@for (provider of allowProviders; track provider) {
<button type="button" [disabled]="config?.configs?.[provider]" mat-stroked-button class="provider" (click)="updateState(provider)">
<mat-icon class="tb-mat-18" svgIcon="{{ providersData.get(provider).icon }}"></mat-icon>
{{ providersData.get(provider).name | translate }}
</button>
}
@if (config) {
<button type="button" mat-raised-button color="accent" class="navigation w-full" (click)="cancelLogin()">
{{ 'login.login' | translate }}
</button>
}
</div>
</mat-card-content>
</mat-card>
}
@case (ForceTwoFAState.AUTHENTICATOR_APP) {
@switch (appState()) {
@case (ProvidersState.INPUT) {
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start">
<button mat-icon-button type="button" (click)="state.set(ForceTwoFAState.SETUP)">
<mat-icon>chevron_left</mat-icon>
</button>
{{ 'login.enable-authenticator-app' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="flex flex-col items-center justify-start">
<p class="mat-body qr-code-description mb-4" translate>login.scan-qr-code</p>
<canvas class="flex-1" #canvas [style.display]="totpAuthURL ? 'block' : 'none'"></canvas>
<p class="mat-body qr-code-description" translate>login.enter-key-manually</p>
<div class="flex flex-row items-center mb-8 w-full overflow-hidden">
<span tbTruncateWithTooltip class="w-full">{{ totpAuthURLSecret }}</span>
<tb-copy-button
class="attribute-copy"
[disabled]="isLoading$ | async"
[copyText]="totpAuthURLSecret"
tooltipText="{{ 'login.copy-key' | translate }}"
tooltipPosition="above"
icon="content_copy"
[style]="{'font-size': '24px', color: 'rgba(255,255,255,.8)'}"
>
</tb-copy-button>
</div>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<button type="button" mat-stroked-button class="navigation w-full" (click)="appState.set(ProvidersState.ENTER_CODE)">
{{ 'login.continue' | translate }}
</button>
<button type="button" mat-flat-button class="navigation w-full" (click)="tryAnotherWay(TwoFactorAuthProviderType.TOTP)">
{{ 'login.try-another-way' | translate }}
</button>
</div>
</div>
</mat-card-content>
</mat-card>
}
@case (ProvidersState.ENTER_CODE) {
<ng-container *ngTemplateOutlet="enterCodeTemplateCard; context: {providerType: TwoFactorAuthProviderType.TOTP}"></ng-container>
}
@case (ProvidersState.SUCCESS) {
<ng-container *ngTemplateOutlet="successTemplateCard; context: {providerType: TwoFactorAuthProviderType.TOTP}"></ng-container>
}
}
}
@case (ForceTwoFAState.SMS) {
@switch (smsState()) {
@case (ProvidersState.INPUT) {
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start">
<button mat-icon-button type="button" (click)="state.set(ForceTwoFAState.SETUP)">
<mat-icon>chevron_left</mat-icon>
</button>
{{ 'login.enable-authenticator-sms' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="flex flex-col items-center justify-start">
<form [formGroup]="smsConfigForm" class="mb-12">
<p class="mat-body step-description input" translate>login.sms-description</p>
<div class="flex flex-row items-center justify-between gap-3.75">
<tb-phone-input class="flex-1"
label="{{ 'login.phone-input.phone-input-label' | translate }}"
hint="login.phone-input.phone-input-hint"
requiredErrorText="{{ 'login.phone-input.phone-input-required' | translate }}"
validationErrorText="{{ 'login.phone-input.phone-input-validation' | translate }}"
formControlName="phone"
[floatLabel]="'auto'">
</tb-phone-input>
</div>
</form>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<button type="button" mat-stroked-button [disabled]="(isLoading$ | async) || smsConfigForm.invalid || !smsConfigForm.dirty" class="navigation w-full" (click)="sendSmsCode()">
{{ 'login.send-code' | translate }}
</button>
<button type="button" mat-flat-button class="navigation w-full" (click)="tryAnotherWay(TwoFactorAuthProviderType.SMS)">
{{ 'login.try-another-way' | translate }}
</button>
</div>
</div>
</mat-card-content>
</mat-card>
}
@case (ProvidersState.ENTER_CODE) {
<ng-container *ngTemplateOutlet="enterCodeTemplateCard; context: {providerType: TwoFactorAuthProviderType.SMS}"></ng-container>
}
@case (ProvidersState.SUCCESS) {
<ng-container *ngTemplateOutlet="successTemplateCard; context: {providerType: TwoFactorAuthProviderType.SMS}"></ng-container>
}
}
}
@case (ForceTwoFAState.EMAIL) {
@switch (emailState()) {
@case (ProvidersState.INPUT) {
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start">
<button mat-icon-button type="button" (click)="state.set(ForceTwoFAState.SETUP)">
<mat-icon>chevron_left</mat-icon>
</button>
{{ 'login.enable-authenticator-email' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div class="flex flex-col items-center justify-start">
<form [formGroup]="emailConfigForm" class="mb-8">
<p class="mat-body step-description input" translate>login.email-description</p>
<mat-form-field class="mat-block input-container flex-1">
<input matInput formControlName="email"
type="email" required
placeholder="{{ 'login.email-label' | translate }}" />
<mat-error *ngIf="emailConfigForm.get('email').hasError('required')">
{{ 'login.email-required' | translate }}
</mat-error>
<mat-error *ngIf="emailConfigForm.get('email').hasError('email')">
{{ 'login.invalid-email-format' | translate }}
</mat-error>
</mat-form-field>
</form>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<button type="button" mat-stroked-button [disabled]="(isLoading$ | async) || emailConfigForm.invalid" class="navigation w-full" (click)="sendEmailCode()">
{{ 'login.send-code' | translate }}
</button>
<button type="button" mat-flat-button class="navigation w-full" (click)="tryAnotherWay(TwoFactorAuthProviderType.EMAIL)">
{{ 'login.try-another-way' | translate }}
</button>
</div>
</div>
</mat-card-content>
</mat-card>
}
@case (ProvidersState.ENTER_CODE) {
<ng-container *ngTemplateOutlet="enterCodeTemplateCard; context: {providerType: TwoFactorAuthProviderType.EMAIL}"></ng-container>
}
@case (ProvidersState.SUCCESS) {
<ng-container *ngTemplateOutlet="successTemplateCard; context: {providerType: TwoFactorAuthProviderType.EMAIL}"></ng-container>
}
}
}
@case (ForceTwoFAState.BACKUP_CODE) {
@switch (backupCodeState()) {
@case (BackupCodeState.CODE) {
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start">
<button mat-icon-button type="button" (click)="state.set(ForceTwoFAState.SETUP)">
<mat-icon>chevron_left</mat-icon>
</button>
{{ 'login.get-backup-code' | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<div mat-dialog-content tb-toast class="backup-code">
<p class="mat-body-2 description" translate>login.backup-code-description</p>
<div class="container">
@for (code of backupCode?.codes; track code) {
<div class="code">{{ code }}</div>
}
</div>
<div class="action-buttons flex flex-row items-center justify-start gap-4">
<button type="button" mat-flat-button class="provider w-full" (click)="downloadFile()">
{{ 'login.download-txt' | translate }}
</button>
<button type="button" mat-stroked-button class="provider w-full" (click)="printCode()">
{{ 'login.print' | translate }}
</button>
</div>
<p class="mat-body-2 description" translate>login.backup-code-warn</p>
<button type="button" mat-raised-button color="accent" class="navigation w-full" (click)="backupCodeState.set(BackupCodeState.SUCCESS)">
{{ 'login.continue' | translate }}
</button>
</div>
</mat-card-content>
</mat-card>
}
@case (BackupCodeState.SUCCESS) {
<ng-container *ngTemplateOutlet="successTemplateCard; context: {providerType: TwoFactorAuthProviderType.BACKUP_CODE}"></ng-container>
}
}
}
}
</div>
<ng-template #enterCodeTemplateCard let-providerType="providerType">
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start">
<button mat-icon-button type="button" (click)="goBackByType(providerType)">
<mat-icon>chevron_left</mat-icon>
</button>
{{ twoFactorAuthProvidersEnterCodeCardTranslate.get(providerType).name | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<p class="mat-body inline-block">
{{ twoFactorAuthProvidersEnterCodeCardTranslate.get(providerType).description | translate }}
@if (providerType === TwoFactorAuthProviderType.SMS) {
<span>{{ smsConfigForm.get('phone').value }}</span>
}
@if (providerType === TwoFactorAuthProviderType.EMAIL) {
<span>{{ emailConfigForm.get('email').value }}</span>
}
</p>
<form [formGroup]="configForm" class="flex flex-col items-center justify-start">
<mat-form-field class="mat-block w-full">
<input matInput formControlName="verificationCode"
maxlength="6" type="text" required
inputmode="numeric" pattern="[0-9]*"
autocomplete="off"
placeholder="{{ 'login.verification-code' | translate }}">
<mat-error *ngIf="configForm.get('verificationCode').invalid">
{{ 'login.verification-code-invalid' | translate }}
</mat-error>
</mat-form-field>
<div class="flex flex-col items-center justify-start gap-2 w-full">
<button type="button" mat-flat-button color="accent" [disabled]="(isLoading$ | async) || configForm.invalid || !configForm.dirty" class="navigation w-full" (click)="saveConfig(providerType)">
{{ 'login.confirm' | translate }}
</button>
<button type="button" mat-flat-button class="navigation w-full" (click)="tryAnotherWay(providerType)">
{{ 'login.try-another-way' | translate }}
</button>
</div>
</form>
</mat-card-content>
</mat-card>
</ng-template>
<ng-template #successTemplateCard let-providerType="providerType">
<mat-card appearance="raised" class="tb-two-factor-auth-login-card flex-initial">
<mat-card-header>
<mat-card-title class="mat-headline-5 flex flex-row items-center justify-start pl-10">
{{ twoFactorAuthProvidersSuccessCardTranslate.get(providerType).name | translate }}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<p class="mat-body mb-16" translate>{{ twoFactorAuthProvidersSuccessCardTranslate.get(providerType).description | translate }}</p>
<div class="flex flex-col items-center justify-start">
<div class="flex flex-col items-center justify-start gap-2 w-full">
<button type="button" mat-raised-button color="accent" class="navigation w-full" (click)="cancelLogin()">
{{ 'login.login' | translate }}
</button>
@if (isAnyProviderAvailable) {
<button type="button" mat-flat-button class="navigation w-full" (click)="tryAnotherWay(providerType)">
{{ 'login.add-verification-method' | translate }}
</button>
}
</div>
</div>
</mat-card-content>
</mat-card>
</ng-template>

View File

@ -0,0 +1,110 @@
/**
* Copyright © 2016-2025 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;
width: 100%;
height: 100%;
.tb-two-factor-auth-login-content {
background-color: #eee;
.tb-two-factor-auth-login-card {
max-height: 100vh;
overflow: auto;
padding: 48px 48px 48px 16px;
@media #{$mat-xs} {
height: 100%;
}
@media #{$mat-gt-xs} {
width: 450px !important;
}
.mat-mdc-card-title {
font: 400 28px / 36px Roboto, "Helvetica Neue", sans-serif;
}
.mat-mdc-card-header {
padding: 0;
}
.mat-mdc-card-content {
margin-top: 34px;
margin-left: 40px;
padding: 0;
}
.mat-body {
letter-spacing: 0.25px;
line-height: 16px;
}
.backup-code {
p {
text-align: justify;
}
.container {
border: 1px solid;
border-radius: 4px;
gap: 16px;
display: grid;
grid-template-columns: 1fr 1fr;
justify-items: center;
padding: 16px 0;
margin-bottom: 16px;
.code {
letter-spacing: 0.25px;
font-family: Roboto Mono, "Helvetica Neue", monospace;
}
}
.action-buttons {
margin-bottom: 40px;
}
}
}
}
::ng-deep {
.tb-two-factor-auth-login-content {
.tb-two-factor-auth-login-card {
button.mat-mdc-icon-button {
.mat-icon {
color: rgba(255, 255, 255, 0.8);
}
}
}
.mat-mdc-form-field .mat-mdc-form-field-hint-wrapper {
color: rgba(255, 255, 255, 0.8);
}
}
button.provider, button.navigation {
text-align: start;
font-weight: 400;
color: rgba(255, 255, 255, 0.8);
&:not([disabled][disabled]) {
border-color: rgba(255, 255, 255, .8);
}
}
}
}

View File

@ -0,0 +1,300 @@
///
/// Copyright © 2016-2025 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, ElementRef, OnDestroy, OnInit, signal, ViewChild } 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 { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service';
import {
AccountTwoFaSettings,
BackupCodeTwoFactorAuthAccountConfig,
TotpTwoFactorAuthAccountConfig,
TwoFactorAuthAccountConfig,
twoFactorAuthProvidersEnterCodeCardTranslate,
twoFactorAuthProvidersLoginData,
twoFactorAuthProvidersSuccessCardTranslate,
TwoFactorAuthProviderType
} from '@shared/models/two-factor-auth.models';
import { phoneNumberPattern } from '@shared/models/settings.models';
import { deepClone, isDefinedAndNotNull, unwrapModule } from '@core/utils';
import { MatDialog } from '@angular/material/dialog';
import { DialogService } from '@core/services/dialog.service';
import { getCurrentAuthUser } from '@core/auth/auth.selectors';
import printTemplate from '@home/pages/security/authentication-dialog/backup-code-print-template.raw';
import { ImportExportService } from '@shared/import-export/import-export.service';
import { mergeMap, tap } from 'rxjs/operators';
enum ForceTwoFAState {
SETUP = 'setup',
AUTHENTICATOR_APP = 'authenticatorApp',
SMS = 'sms',
EMAIL = 'email',
BACKUP_CODE = 'backupCode',
}
enum ProvidersState {
INPUT = 'INPUT',
ENTER_CODE = 'ENTER_CODE',
SUCCESS = 'SUCCESS',
}
enum BackupCodeState {
CODE = 'CODE',
SUCCESS = 'SUCCESS',
}
@Component({
selector: 'tb-force-two-factor-auth-login',
templateUrl: './force-two-factor-auth-login.component.html',
styleUrls: ['./force-two-factor-auth-login.component.scss']
})
export class ForceTwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy {
TwoFactorAuthProviderType = TwoFactorAuthProviderType;
providersData = twoFactorAuthProvidersLoginData;
allowProviders: TwoFactorAuthProviderType[] = [];
config: AccountTwoFaSettings;
twoFactorAuthProvidersEnterCodeCardTranslate = twoFactorAuthProvidersEnterCodeCardTranslate;
twoFactorAuthProvidersSuccessCardTranslate = twoFactorAuthProvidersSuccessCardTranslate;
ForceTwoFAState = ForceTwoFAState;
ProvidersState = ProvidersState;
BackupCodeState = BackupCodeState
state = signal<ForceTwoFAState>(ForceTwoFAState.SETUP);
appState = signal<ProvidersState>(ProvidersState.INPUT);
smsState = signal<ProvidersState>(ProvidersState.INPUT);
emailState = signal<ProvidersState>(ProvidersState.INPUT);
backupCodeState = signal<BackupCodeState>(BackupCodeState.CODE);
totpAuthURL: string;
totpAuthURLSecret: string;
backupCode: BackupCodeTwoFactorAuthAccountConfig;
configForm: UntypedFormGroup;
smsConfigForm: UntypedFormGroup;
emailConfigForm: UntypedFormGroup;
private providersInfo: TwoFactorAuthProviderType[];
private authAccountConfig: TwoFactorAuthAccountConfig;
private useByDefault: boolean = true;
@ViewChild('canvas', {static: false}) canvasRef: ElementRef<HTMLCanvasElement>;
constructor(protected store: Store<AppState>,
private authService: AuthService,
private twoFaService: TwoFactorAuthenticationService,
private importExportService: ImportExportService,
public dialog: MatDialog,
public dialogService: DialogService,
private fb: UntypedFormBuilder) {
super(store);
}
ngOnInit() {
this.providersInfo = this.authService.forceTwoFactorAuthProviders;
this.allowedProviders();
this.configForm = this.fb.group({
verificationCode: ['', [
Validators.required,
Validators.minLength(6),
Validators.maxLength(6),
Validators.pattern(/^\d*$/)
]]
});
this.smsConfigForm = this.fb.group({
phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]]
});
this.emailConfigForm = this.fb.group({
email: [getCurrentAuthUser(this.store).sub, [Validators.required, Validators.email]]
});
this.twoFaService.getAccountTwoFaSettings().subscribe(accountConfig => {
if (accountConfig) {
this.config = accountConfig;
this.useByDefault = false;
}
});
}
goBackByType(type: TwoFactorAuthProviderType) {
switch (type) {
case TwoFactorAuthProviderType.TOTP:
this.appState.set(ProvidersState.INPUT);
this.updateQRCode();
break;
case TwoFactorAuthProviderType.SMS:
this.smsState.set(ProvidersState.INPUT);
break;
case TwoFactorAuthProviderType.EMAIL:
this.emailState.set(ProvidersState.INPUT);
break;
}
}
get isAnyProviderAvailable() {
return this.config?.configs ? Object.keys(this.config?.configs)?.length < this.allowProviders?.length : true;
}
private allowedProviders() {
if (isDefinedAndNotNull(this.config)) {
this.allowProviders = this.providersInfo;
} else {
this.allowProviders = this.providersInfo.filter(provider => provider !== TwoFactorAuthProviderType.BACKUP_CODE);
}
}
updateState(type: TwoFactorAuthProviderType) {
switch (type) {
case TwoFactorAuthProviderType.TOTP:
this.state.set(ForceTwoFAState.AUTHENTICATOR_APP);
this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => {
this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig;
this.totpAuthURL = this.authAccountConfig.authUrl;
this.totpAuthURLSecret = new URL(this.totpAuthURL).searchParams.get('secret');
this.authAccountConfig.useByDefault = this.useByDefault;
this.useByDefault = false;
this.updateQRCode();
});
break;
case TwoFactorAuthProviderType.SMS:
this.state.set(ForceTwoFAState.SMS);
break;
case TwoFactorAuthProviderType.EMAIL:
this.state.set(ForceTwoFAState.EMAIL);
break;
case TwoFactorAuthProviderType.BACKUP_CODE:
this.state.set(ForceTwoFAState.BACKUP_CODE);
this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.BACKUP_CODE).pipe(
tap((data: BackupCodeTwoFactorAuthAccountConfig) => this.backupCode = data),
mergeMap(data => this.twoFaService.verifyAndSaveTwoFaAccountConfig(data, null, {ignoreLoading: true}))
).subscribe((config) => {
this.config = config;
});
break;
}
}
sendSmsCode() {
if (this.smsConfigForm.valid) {
this.authAccountConfig = {
providerType: TwoFactorAuthProviderType.SMS,
useByDefault: this.useByDefault,
phoneNumber: this.smsConfigForm.get('phone').value as string
};
this.useByDefault = false;
this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.smsState.set(ProvidersState.ENTER_CODE));
}
}
sendEmailCode() {
if (this.emailConfigForm.valid) {
this.authAccountConfig = {
providerType: TwoFactorAuthProviderType.EMAIL,
useByDefault: this.useByDefault,
email: this.emailConfigForm.get('email').value as string
};
this.useByDefault = false;
this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => this.emailState.set(ProvidersState.ENTER_CODE));
}
}
tryAnotherWay(type: TwoFactorAuthProviderType) {
this.state.set(ForceTwoFAState.SETUP);
this.configForm.reset();
switch (type) {
case TwoFactorAuthProviderType.TOTP:
this.appState.set(ProvidersState.INPUT);
break;
case TwoFactorAuthProviderType.SMS:
this.smsState.set(ProvidersState.INPUT);
this.smsConfigForm.reset();
break;
case TwoFactorAuthProviderType.EMAIL:
this.emailState.set(ProvidersState.INPUT)
this.emailConfigForm.get('email').reset(getCurrentAuthUser(this.store).sub);
break;
}
}
saveConfig(type: TwoFactorAuthProviderType) {
if (this.configForm.valid) {
this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig,
this.configForm.get('verificationCode').value).subscribe((config) => {
switch (type) {
case TwoFactorAuthProviderType.TOTP:
this.appState.set(ProvidersState.SUCCESS);
break;
case TwoFactorAuthProviderType.SMS:
this.smsState.set(ProvidersState.SUCCESS);
break;
case TwoFactorAuthProviderType.EMAIL:
this.emailState.set(ProvidersState.SUCCESS);
break;
}
this.config = config;
this.authAccountConfig = null;
this.allowedProviders();
});
}
}
private updateQRCode() {
import('qrcode').then((QRCode) => {
unwrapModule(QRCode).toCanvas(this.canvasRef.nativeElement, this.totpAuthURL);
this.canvasRef.nativeElement.style.width = 'auto';
this.canvasRef.nativeElement.style.height = 'auto';
});
}
ngOnDestroy() {
super.ngOnDestroy();
}
cancelLogin() {
this.authService.logout();
}
downloadFile() {
this.importExportService.exportText(this.backupCode.codes, 'backup-codes');
}
printCode() {
const codeTemplate = deepClone(this.backupCode.codes)
.map(code => `<div class="code-row"><input type="checkbox"><span class="code">${code}</span></div>`).join('');
const printPage = printTemplate.replace('${codesBlock}', codeTemplate);
const newWindow = window.open('', 'Print backup code');
newWindow.document.open();
newWindow.document.write(printPage);
setTimeout(() => {
newWindow.print();
newWindow.document.close();
setTimeout(() => {
newWindow.close();
}, 10);
}, 0);
}
}

View File

@ -72,9 +72,19 @@
}
::ng-deep {
.tb-two-factor-auth-login-content {
.tb-two-factor-auth-login-card {
button.mat-mdc-icon-button {
.mat-icon {
color: rgba(255, 255, 255, 0.8);
}
}
}
}
button.provider {
text-align: start;
font-weight: 400;
color: rgba(255, 255, 255, 0.8);
&:not([disabled][disabled]) {
border-color: rgba(255, 255, 255, .8);
}

View File

@ -37,12 +37,12 @@
(focus)="focus()"
autocomplete="off"
[required]="required">
<mat-hint innerHTML="{{ 'phone-input.phone-input-hint' | translate: {phoneNumber: phonePlaceholder} }}"></mat-hint>
<mat-hint innerHTML="{{ hint | translate: {phoneNumber: phonePlaceholder} }}"></mat-hint>
<mat-error *ngIf="phoneFormGroup.get('phoneNumber').hasError('required')">
{{ 'phone-input.phone-input-required' | translate }}
{{ requiredErrorText }}
</mat-error>
<mat-error *ngIf="phoneFormGroup.get('phoneNumber').hasError('invalidPhoneNumber')">
{{ 'phone-input.phone-input-validation' | translate }}
{{ validationErrorText }}
</mat-error>
</mat-form-field>
</div>

View File

@ -77,6 +77,15 @@ export class PhoneInputComponent implements OnInit, ControlValueAccessor, Valida
@Input()
label = this.translate.instant('phone-input.phone-input-label');
@Input()
hint = 'phone-input.phone-input-hint';
@Input()
requiredErrorText = this.translate.instant('phone-input.phone-input-required');
@Input()
validationErrorText = this.translate.instant('phone-input.phone-input-validation');
get showFlagSelect(): boolean {
return this.enableFlagsSelect && !this.isLegacy;
}

View File

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

View File

@ -14,7 +14,11 @@
/// limitations under the License.
///
import { UsersFilter } from '@shared/models/notification.models';
export interface TwoFactorAuthSettings {
enforceTwoFa: boolean;
enforcedUsersFilter: UsersFilter;
maxVerificationFailuresBeforeUserLockout: number;
providers: Array<TwoFactorAuthProviderConfig>;
totalAllowedTimeForVerification: number;
@ -24,12 +28,18 @@ export interface TwoFactorAuthSettings {
}
export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{
enforceTwoFa: boolean;
enforcedUsersFilter: UsersFilterWithFilterByTenant;
providers: Array<TwoFactorAuthProviderConfigForm>;
verificationCodeCheckRateLimitEnable: boolean;
verificationCodeCheckRateLimitNumber: number;
verificationCodeCheckRateLimitTime: number;
}
export interface UsersFilterWithFilterByTenant extends UsersFilter{
filterByTenants?: boolean;
}
export type TwoFactorAuthProviderConfig = Partial<TotpTwoFactorAuthProviderConfig | SmsTwoFactorAuthProviderConfig |
EmailTwoFactorAuthProviderConfig>;
@ -183,3 +193,61 @@ export const twoFactorAuthProvidersLoginData = new Map<TwoFactorAuthProviderType
]
]
);
export const twoFactorAuthProvidersEnterCodeCardTranslate = new Map<TwoFactorAuthProviderType, Omit<TwoFactorAuthProviderData, 'activatedHint'>>(
[
[
TwoFactorAuthProviderType.TOTP, {
name: 'login.enable-authenticator-app',
description: 'login.enable-authenticator-app-description'
}
],
[
TwoFactorAuthProviderType.SMS, {
name: 'login.enable-authenticator-sms',
description: 'login.enable-authenticator-sms-description'
}
],
[
TwoFactorAuthProviderType.EMAIL, {
name: 'login.enable-authenticator-email',
description: 'login.enable-authenticator-email-description'
}
],
[
TwoFactorAuthProviderType.BACKUP_CODE, {
name: 'security.2fa.provider.backup_code',
description: 'login.backup-code-auth-description'
}
]
]
);
export const twoFactorAuthProvidersSuccessCardTranslate = new Map<TwoFactorAuthProviderType, Omit<TwoFactorAuthProviderData, 'activatedHint'>>(
[
[
TwoFactorAuthProviderType.TOTP, {
name: 'login.authenticator-app-success',
description: 'login.authenticator-app-success-description'
}
],
[
TwoFactorAuthProviderType.SMS, {
name: 'login.authenticator-sms-success',
description: 'login.authenticator-sms-success-description'
}
],
[
TwoFactorAuthProviderType.EMAIL, {
name: 'login.authenticator-email-success',
description: 'login.authenticator-email-success-description'
}
],
[
TwoFactorAuthProviderType.BACKUP_CODE, {
name: 'login.authenticator-backup-code-success',
description: 'login.authenticator-backup-code-success-description'
}
]
]
);

View File

@ -496,24 +496,26 @@
"number-of-codes-pattern": "Number of codes must be a positive integer.",
"number-of-codes-required": "Number of codes is required.",
"provider": "Provider",
"retry-verification-code-period": "Retry verification code period (sec)",
"retry-verification-code-period": "Retry verification code period",
"retry-verification-code-period-pattern": "Minimal period time is 5 sec",
"retry-verification-code-period-required": "Retry verification code period is required.",
"total-allowed-time-for-verification": "Total allowed time for verification (sec)",
"total-allowed-time-for-verification": "Total allowed time for verification",
"total-allowed-time-for-verification-pattern": "Minimal total allowed time is 60 sec",
"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-lifetime": "Verification code lifetime (sec)",
"verification-code-lifetime": "Verification code lifetime",
"verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.",
"verification-code-lifetime-required": "Verification code lifetime is required.",
"verification-message-template": "Verification message template",
"verification-limitations": "Verification limitations",
"verification-message-template-pattern": "Verification message need to contains pattern: ${code}",
"verification-message-template-required": "Verification message template is required.",
"within-time": "Within time (sec)",
"within-time": "Within time",
"within-time-pattern": "Time must be a positive integer.",
"within-time-required": "Time is required."
"within-time-required": "Time is required.",
"force-2fa": "Force two-factor authentication",
"enforce-for": "Enforce for"
},
"jwt": {
"security-settings": "JWT security settings",
@ -3884,7 +3886,48 @@
"activation-link-expired": "Activation link has expired",
"activation-link-expired-message": "The link to activate your profile has expired. You can return to the login page to receive a new email.",
"reset-password-link-expired": "Password reset link has expired",
"reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email."
"reset-password-link-expired-message": "The link to reset your password has expired. You can return to the login page to receive a new email.",
"two-fa": "Two-factor authentication",
"two-fa-required": "Two-factor authentication is required",
"set-up-verification-method": "Set up a verification method to continue",
"set-up-verification-method-login": "Set up a verification method or login",
"enable-authenticator-app": "Enable authenticator app",
"enable-authenticator-app-description": "Please enter the security code from your authenticator app",
"enable-authenticator-sms": "Enable SMS authenticator",
"enable-authenticator-sms-description": "Enter a 6-digit code we just sent to ",
"enable-authenticator-email": "Enable email authenticator",
"enable-authenticator-email-description": "A security code has been sent to your email address at ",
"enter-key-manually": "or enter this 32-digits key manually:",
"continue": "Continue",
"confirm": "Confirm",
"authenticator-app-success": "Authenticator app successfully enabled",
"authenticator-app-success-description": "The next time you log in, you will need to provide a two-factor authentication code",
"authenticator-sms-success": "SMS authenticator successfully enabled",
"authenticator-sms-success-description": "The next time you log in, you will be prompted to enter the security code that will be sent to the phone number",
"authenticator-email-success": "Email authenticator successfully enabled",
"authenticator-email-success-description": "The next time you log in, you will be prompted to enter the security code that will be sent to your email address",
"authenticator-backup-code-success": "Backup code successfully enabled",
"authenticator-backup-code-success-description": "The next time you log in, you will be prompted to enter the security code or use one of backup code.",
"add-verification-method": "Add verification method",
"get-backup-code": "Get backup code",
"copy-key": "Copy key",
"send-code": "Send code",
"email-label": "Email",
"sms-description": "Enter a phone number to use as your authenticator.",
"backup-code-description": "Print out the codes so you have them handy when you need to use them to log in to your account. You can use each backup code once.",
"backup-code-warn": "Once you leave this page, these codes cannot be shown again. Store them safely using the options below.",
"download-txt": "Download (txt)",
"print": "Print",
"verification-code": "6-digit code",
"verification-code-invalid": "Invalid verification code format",
"scan-qr-code": "Scan this QR code with your verification app",
"phone-input": {
"phone-input-label": "Phone number",
"phone-input-required": "Phone number is required",
"phone-input-validation": "Phone number is invalid or not possible",
"phone-input-pattern": "Invalid phone number. Should be in E.164 format, ex. {{phoneNumber}}",
"phone-input-hint": "Phone Number in E.164 format, ex. {{phoneNumber}}"
}
},
"markdown": {
"edit": "Edit",