committed by
GitHub
51 changed files with 1686 additions and 426 deletions
@ -0,0 +1,24 @@ |
|||||
|
/** |
||||
|
* 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. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.security.auth; |
||||
|
|
||||
|
import org.thingsboard.server.service.security.model.SecurityUser; |
||||
|
|
||||
|
public class MfaConfigurationToken extends AbstractJwtAuthenticationToken { |
||||
|
public MfaConfigurationToken(SecurityUser securityUser) { |
||||
|
super(securityUser); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
/** |
||||
|
* 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. |
||||
|
*/ |
||||
|
package org.thingsboard.server.service.security.permission; |
||||
|
|
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Component |
||||
|
public class MfaConfigurationPermissions extends AbstractPermissions { |
||||
|
|
||||
|
public MfaConfigurationPermissions() { |
||||
|
super(); |
||||
|
// for compatibility with PE
|
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
/** |
||||
|
* 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. |
||||
|
*/ |
||||
|
package org.thingsboard.server.common.data.notification.targets.platform; |
||||
|
|
||||
|
public interface SystemLevelUsersFilter extends UsersFilter { |
||||
|
} |
||||
@ -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> |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue