Browse Source

Merge pull request #13629 from thingsboard/feature/2fa-enforce

2FA enforcement
pull/14199/head
Viacheslav Klimov 11 months ago
committed by GitHub
parent
commit
2866dad1e6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 14
      application/src/main/java/org/thingsboard/server/controller/AuthController.java
  2. 47
      application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java
  3. 50
      application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java
  4. 13
      application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java
  5. 24
      application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java
  6. 22
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java
  7. 6
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java
  8. 49
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java
  9. 10
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java
  10. 2
      application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java
  11. 8
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
  12. 32
      application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java
  13. 22
      application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
  14. 2
      application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java
  15. 13
      application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java
  16. 28
      application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java
  17. 2
      application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java
  18. 2
      application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java
  19. 12
      application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
  20. 152
      application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java
  21. 74
      application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java
  22. 2
      application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java
  23. 1
      common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java
  24. 6
      common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java
  25. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java
  26. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java
  27. 19
      common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java
  28. 2
      common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java
  29. 4
      common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java
  30. 3
      common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java
  31. 51
      dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java
  32. 1
      dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
  33. 83
      dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java
  34. 17
      ui-ngx/src/app/core/auth/auth.service.ts
  35. 10
      ui-ngx/src/app/core/guards/auth.guard.ts
  36. 351
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html
  37. 7
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss
  38. 72
      ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts
  39. 13
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html
  40. 2
      ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts
  41. 11
      ui-ngx/src/app/modules/login/login-routing.module.ts
  42. 4
      ui-ngx/src/app/modules/login/login.module.ts
  43. 304
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html
  44. 110
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss
  45. 300
      ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts
  46. 10
      ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss
  47. 6
      ui-ngx/src/app/shared/components/phone-input.component.html
  48. 9
      ui-ngx/src/app/shared/components/phone-input.component.ts
  49. 3
      ui-ngx/src/app/shared/models/authority.enum.ts
  50. 68
      ui-ngx/src/app/shared/models/two-factor-auth.models.ts
  51. 55
      ui-ngx/src/assets/locale/locale.constant-en_US.json

14
application/src/main/java/org/thingsboard/server/controller/AuthController.java

@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.UserCredentials;
import org.thingsboard.server.common.data.security.event.UserCredentialsInvalidationEvent;
import org.thingsboard.server.common.data.security.event.UserSessionInvalidationEvent;
@ -48,7 +49,9 @@ import org.thingsboard.server.common.data.security.model.UserPasswordPolicy;
import org.thingsboard.server.config.annotations.ApiOperation;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
import org.thingsboard.server.service.security.auth.rest.RestAwareAuthenticationSuccessHandler;
import org.thingsboard.server.service.security.model.ActivateUserRequest;
import org.thingsboard.server.service.security.model.ChangePasswordRequest;
import org.thingsboard.server.service.security.model.ResetPasswordEmailRequest;
@ -74,7 +77,8 @@ public class AuthController extends BaseController {
private final SecuritySettingsService securitySettingsService;
private final RateLimitService rateLimitService;
private final ApplicationEventPublisher eventPublisher;
private final TwoFactorAuthService twoFactorAuthService;
private final RestAwareAuthenticationSuccessHandler authenticationSuccessHandler;
@ApiOperation(value = "Get current User (getUser)",
notes = "Get the information about the User which credentials are used to perform this REST API call.")
@ -221,7 +225,13 @@ public class AuthController extends BaseController {
}
}
var tokenPair = tokenFactory.createTokenPair(securityUser);
JwtPair tokenPair;
if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), user)) {
tokenPair = authenticationSuccessHandler.createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN);
} else {
tokenPair = tokenFactory.createTokenPair(securityUser);
}
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGIN, null);
return tokenPair;
}

47
application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java

@ -56,7 +56,6 @@ public class TwoFactorAuthConfigController extends BaseController {
private final TwoFaConfigManager twoFaConfigManager;
private final TwoFactorAuthService twoFactorAuthService;
@ApiOperation(value = "Get account 2FA settings (getAccountTwoFaSettings)",
notes = "Get user's account 2FA configuration. Configuration contains configs for different 2FA providers." + NEW_LINE +
"Example:\n" +
@ -67,13 +66,12 @@ public class TwoFactorAuthConfigController extends BaseController {
" }\n}\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@GetMapping("/account/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException {
SecurityUser user = getCurrentUser();
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null);
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user).orElse(null);
}
@ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)",
notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE +
"For TOTP, this will return a corresponding account config template " +
@ -99,7 +97,7 @@ public class TwoFactorAuthConfigController extends BaseController {
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config/generate")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public TwoFaAccountConfig generateTwoFaAccountConfig(@Parameter(description = "2FA provider type to generate new account config for", schema = @Schema(defaultValue = "TOTP", requiredMode = Schema.RequiredMode.REQUIRED))
@RequestParam TwoFaProviderType providerType) throws Exception {
SecurityUser user = getCurrentUser();
@ -127,7 +125,7 @@ public class TwoFactorAuthConfigController extends BaseController {
"or if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config/submit")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception {
SecurityUser user = getCurrentUser();
twoFactorAuthService.prepareVerificationCode(user, accountConfig, false);
@ -139,11 +137,11 @@ public class TwoFactorAuthConfigController extends BaseController {
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig,
@RequestParam(required = false) String verificationCode) throws Exception {
SecurityUser user = getCurrentUser();
if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) {
if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, accountConfig.getProviderType()).isPresent()) {
throw new IllegalArgumentException("2FA provider is already configured");
}
@ -154,7 +152,7 @@ public class TwoFactorAuthConfigController extends BaseController {
verificationSuccess = true;
}
if (verificationSuccess) {
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig);
} else {
throw new IllegalArgumentException("Verification code is incorrect");
}
@ -162,42 +160,41 @@ public class TwoFactorAuthConfigController extends BaseController {
@ApiOperation(value = "Update 2FA account config (updateTwoFaAccountConfig)", notes =
"Update config for a given provider type. \n" +
"Update request example:\n" +
"```\n{\n \"useByDefault\": true\n}\n```\n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
"Update request example:\n" +
"```\n{\n \"useByDefault\": true\n}\n```\n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PutMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType,
@RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException {
SecurityUser user = getCurrentUser();
TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType)
.orElseThrow(() -> new IllegalArgumentException("Config for " + providerType + " 2FA provider not found"));
accountConfig.setUseByDefault(updateRequest.isUseByDefault());
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig);
}
@ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes =
"Delete 2FA config for a given 2FA provider type. \n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@DeleteMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException {
SecurityUser user = getCurrentUser();
return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType);
return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user, providerType);
}
@ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes =
"Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" +
"Example of response:\n" +
"```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER
"Example of response:\n" +
"```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER
)
@GetMapping("/providers")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')")
public List<TwoFaProviderType> getAvailableTwoFaProviders() throws ThingsboardException {
return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true)
.map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream()
@ -205,7 +202,6 @@ public class TwoFactorAuthConfigController extends BaseController {
.collect(Collectors.toList());
}
@ApiOperation(value = "Get platform 2FA settings (getPlatformTwoFaSettings)",
notes = "Get platform settings for 2FA. The settings are described for savePlatformTwoFaSettings API method. " +
"If 2FA is not configured, then an empty response will be returned." +
@ -260,11 +256,10 @@ public class TwoFactorAuthConfigController extends BaseController {
@PostMapping("/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN')")
public PlatformTwoFaSettings savePlatformTwoFaSettings(@Parameter(description = "Settings value", required = true)
@RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException {
@RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException {
return twoFaConfigManager.savePlatformTwoFaSettings(getTenantId(), twoFaSettings);
}
@Data
public static class TwoFaAccountConfigUpdateRequest {
private boolean useByDefault;

50
application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java

@ -28,7 +28,6 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
@ -64,7 +63,6 @@ public class TwoFactorAuthController extends BaseController {
private final SystemSecurityService systemSecurityService;
private final UserService userService;
@ApiOperation(value = "Request 2FA verification code (requestTwoFaVerificationCode)",
notes = "Request 2FA verification code." + NEW_LINE +
"To make a request to this endpoint, you need an access token with the scope of PRE_VERIFICATION_TOKEN, " +
@ -92,30 +90,28 @@ public class TwoFactorAuthController extends BaseController {
SecurityUser user = getCurrentUser();
boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true);
if (verificationSuccess) {
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null);
user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal());
return tokenFactory.createTokenPair(user);
logLogInAction(servletRequest, user, null);
return createTokenPair(user);
} else {
ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.BAD_REQUEST_PARAMS);
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error);
IllegalArgumentException error = new IllegalArgumentException("Verification code is incorrect");
logLogInAction(servletRequest, user, error);
throw error;
}
}
@ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes =
"Get the list of 2FA provider infos available for user to use. Example:\n" +
"```\n[\n" +
" {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" +
" {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" +
" {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" +
"]\n```")
"```\n[\n" +
" {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" +
" {\n \"type\": \"TOTP\",\n \"default\": false,\n \"contact\": null\n },\n" +
" {\n \"type\": \"SMS\",\n \"default\": false,\n \"contact\": \"+38********12\"\n }\n" +
"]\n```")
@GetMapping("/providers")
@PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')")
public List<TwoFaProviderInfo> getAvailableTwoFaProviders() throws ThingsboardException {
SecurityUser user = getCurrentUser();
Optional<PlatformTwoFaSettings> platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true);
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId())
return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user)
.map(settings -> settings.getConfigs().values()).orElse(Collections.emptyList())
.stream().map(config -> {
String contact = null;
@ -139,6 +135,32 @@ public class TwoFactorAuthController extends BaseController {
.collect(Collectors.toList());
}
@ApiOperation(value = "Get regular token pair after successfully configuring 2FA",
notes = "Checks 2FA is configured, returning token pair on success.")
@PostMapping("/login")
@PreAuthorize("hasAuthority('MFA_CONFIGURATION_TOKEN')")
public JwtPair authenticateByTwoFaConfigurationToken(HttpServletRequest servletRequest) throws ThingsboardException {
SecurityUser user = getCurrentUser();
if (twoFactorAuthService.isTwoFaEnabled(user.getTenantId(), user)) {
logLogInAction(servletRequest, user, null);
return createTokenPair(user);
} else {
IllegalArgumentException error = new IllegalArgumentException("2FA is not configured");
logLogInAction(servletRequest, user, error);
throw error;
}
}
private JwtPair createTokenPair(SecurityUser user) {
log.debug("[{}][{}] Creating token pair for user", user.getTenantId(), user.getId());
user = new SecurityUser(userService.findUserById(user.getTenantId(), user.getId()), true, user.getUserPrincipal());
return tokenFactory.createTokenPair(user);
}
private void logLogInAction(HttpServletRequest servletRequest, SecurityUser user, Exception error) {
systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error);
}
@Data
@AllArgsConstructor
@Builder

13
application/src/main/java/org/thingsboard/server/service/security/auth/AuthExceptionHandler.java

@ -18,8 +18,10 @@ package org.thingsboard.server.service.security.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
@ -32,6 +34,10 @@ public class AuthExceptionHandler extends OncePerRequestFilter {
private final ThingsboardErrorResponseHandler errorResponseHandler;
@Value("${server.log_controller_error_stack_trace}")
@Getter
private boolean logControllerErrorStackTrace;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
try {
@ -39,8 +45,15 @@ public class AuthExceptionHandler extends OncePerRequestFilter {
} catch (AuthenticationException e) {
throw e;
} catch (Exception e) {
log(e);
errorResponseHandler.handle(e, response);
}
}
private void log(Exception e) {
if (logControllerErrorStackTrace) {
log.error("Auth error", e);
}
}
}

24
application/src/main/java/org/thingsboard/server/service/security/auth/MfaConfigurationToken.java

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

22
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java

@ -28,6 +28,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
@ -61,12 +62,25 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
private static final ThingsboardException TOO_MANY_REQUESTS_ERROR = new ThingsboardException("Too many requests", ThingsboardErrorCode.TOO_MANY_REQUESTS);
@Override
public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) {
return configManager.getAccountTwoFaSettings(tenantId, userId)
public boolean isTwoFaEnabled(TenantId tenantId, User user) {
return configManager.getAccountTwoFaSettings(tenantId, user)
.map(settings -> !settings.getConfigs().isEmpty())
.orElse(false);
}
@Override
public boolean isEnforceTwoFaEnabled(TenantId tenantId, User user) {
SystemLevelUsersFilter enforcedUsersFilter = configManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true)
.filter(PlatformTwoFaSettings::isEnforceTwoFa)
.map(PlatformTwoFaSettings::getEnforcedUsersFilter)
.orElse(null);
if (enforcedUsersFilter == null) {
return false;
}
return userService.matchesFilter(tenantId, enforcedUsersFilter, user);
}
@Override
public void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException {
getTwoFaProvider(providerType).check(tenantId);
@ -75,7 +89,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
@Override
public void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception {
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType)
.orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR);
prepareVerificationCode(user, accountConfig, checkLimits);
}
@ -104,7 +118,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
@Override
public boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException {
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user, providerType)
.orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR);
return checkVerificationCode(user, verificationCode, accountConfig, checkLimits);
}

6
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java

@ -18,17 +18,17 @@ package org.thingsboard.server.service.security.auth.mfa;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.service.security.model.SecurityUser;
public interface TwoFactorAuthService {
boolean isTwoFaEnabled(TenantId tenantId, UserId userId);
boolean isTwoFaEnabled(TenantId tenantId, User user);
void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException;
boolean isEnforceTwoFaEnabled(TenantId tenantId, User user);
void checkProvider(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException;
void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception;

49
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java

@ -21,15 +21,16 @@ import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.UserAuthSettings;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.dao.exception.DataValidationException;
import org.thingsboard.server.dao.service.ConstraintValidator;
import org.thingsboard.server.dao.settings.AdminSettingsDao;
import org.thingsboard.server.dao.settings.AdminSettingsService;
@ -55,9 +56,9 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
@Override
public Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, UserId userId) {
public Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, User user) {
PlatformTwoFaSettings platformTwoFaSettings = getPlatformTwoFaSettings(tenantId, true).orElse(null);
return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId))
return Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId()))
.map(userAuthSettings -> {
AccountTwoFaSettings twoFaSettings = userAuthSettings.getTwoFaSettings();
if (twoFaSettings == null) return null;
@ -79,17 +80,22 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
}
if (updateNeeded) {
twoFaSettings = saveAccountTwoFaSettings(tenantId, userId, twoFaSettings);
twoFaSettings = saveAccountTwoFaSettings(tenantId, user, twoFaSettings);
}
return twoFaSettings;
});
}
protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) {
UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId))
protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, User user, AccountTwoFaSettings settings) {
if (settings.getConfigs().isEmpty()) {
if (twoFactorAuthService.isEnforceTwoFaEnabled(tenantId, user)) {
throw new DataValidationException("At least one 2FA provider is required");
}
}
UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(user.getId()))
.orElseGet(() -> {
UserAuthSettings newUserAuthSettings = new UserAuthSettings();
newUserAuthSettings.setUserId(userId);
newUserAuthSettings.setUserId(user.getId());
return newUserAuthSettings;
});
userAuthSettings.setTwoFaSettings(settings);
@ -101,18 +107,18 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
@Override
public Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) {
return getAccountTwoFaSettings(tenantId, userId)
public Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) {
return getAccountTwoFaSettings(tenantId, user)
.map(AccountTwoFaSettings::getConfigs)
.flatMap(configs -> Optional.ofNullable(configs.get(providerType)));
}
@Override
public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig) {
public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig) {
getTwoFaProviderConfig(tenantId, accountConfig.getProviderType())
.orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured"));
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> {
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user).orElseGet(() -> {
AccountTwoFaSettings newSettings = new AccountTwoFaSettings();
newSettings.setConfigs(new LinkedHashMap<>());
return newSettings;
@ -128,12 +134,12 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
if (configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) {
configs.values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true));
}
return saveAccountTwoFaSettings(tenantId, userId, settings);
return saveAccountTwoFaSettings(tenantId, user, settings);
}
@Override
public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) {
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId)
public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType) {
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, user)
.orElseThrow(() -> new IllegalArgumentException("2FA not configured"));
settings.getConfigs().remove(providerType);
if (settings.getConfigs().size() == 1) {
@ -145,7 +151,7 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
.min(Comparator.comparing(TwoFaAccountConfig::getProviderType))
.ifPresent(config -> config.setUseByDefault(true));
}
return saveAccountTwoFaSettings(tenantId, userId, settings);
return saveAccountTwoFaSettings(tenantId, user, settings);
}
@ -166,6 +172,19 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
for (TwoFaProviderConfig providerConfig : twoFactorAuthSettings.getProviders()) {
twoFactorAuthService.checkProvider(tenantId, providerConfig.getProviderType());
}
if (tenantId.isSysTenantId()) {
if (twoFactorAuthSettings.isEnforceTwoFa()) {
if (twoFactorAuthSettings.getProviders().isEmpty()) {
throw new DataValidationException("At least one 2FA provider is required if enforcing is enabled");
}
if (twoFactorAuthSettings.getEnforcedUsersFilter() == null) {
throw new DataValidationException("Users filter to enforce 2FA for is required");
}
}
} else {
twoFactorAuthSettings.setEnforceTwoFa(false);
twoFactorAuthSettings.setEnforcedUsersFilter(null);
}
AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY))
.orElseGet(() -> {

10
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java

@ -15,9 +15,9 @@
*/
package org.thingsboard.server.service.security.auth.mfa.config;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
@ -27,14 +27,14 @@ import java.util.Optional;
public interface TwoFaConfigManager {
Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, UserId userId);
Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, User user);
Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType);
Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType);
AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig);
AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, User user, TwoFaAccountConfig accountConfig);
AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType);
AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, User user, TwoFaProviderType providerType);
Optional<PlatformTwoFaSettings> getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault);

2
application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/BackupCodeTwoFaProvider.java

@ -58,7 +58,7 @@ public class BackupCodeTwoFaProvider implements TwoFaProvider<BackupCodeTwoFaPro
public boolean checkVerificationCode(SecurityUser user, String code, BackupCodeTwoFaProviderConfig providerConfig, BackupCodeTwoFaAccountConfig accountConfig) {
if (CollectionsUtil.contains(accountConfig.getCodes(), code)) {
accountConfig.getCodes().remove(code);
twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user, accountConfig);
return true;
} else {
return false;

8
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java

@ -44,6 +44,7 @@ import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.MfaAuthenticationToken;
import org.thingsboard.server.service.security.auth.MfaConfigurationToken;
import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService;
import org.thingsboard.server.service.security.exception.UserPasswordNotValidException;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -82,11 +83,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
Assert.notNull(authentication, "No authentication data provided");
Object principal = authentication.getPrincipal();
if (!(principal instanceof UserPrincipal)) {
if (!(principal instanceof UserPrincipal userPrincipal)) {
throw new BadCredentialsException("Authentication Failed. Bad user principal.");
}
UserPrincipal userPrincipal = (UserPrincipal) principal;
SecurityUser securityUser;
if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) {
String username = userPrincipal.getValue();
@ -103,8 +103,10 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
}
securityUser = authenticateByUsernameAndPassword(authentication, userPrincipal, username, password);
if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser.getId())) {
if (twoFactorAuthService.isTwoFaEnabled(securityUser.getTenantId(), securityUser)) {
return new MfaAuthenticationToken(securityUser);
} else if (twoFactorAuthService.isEnforceTwoFaEnabled(securityUser.getTenantId(), securityUser)) {
return new MfaConfigurationToken(securityUser);
} else {
systemSecurityService.logLoginAction(securityUser, authentication.getDetails(), ActionType.LOGIN, null);
}

32
application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java

@ -15,11 +15,11 @@
*/
package org.thingsboard.server.service.security.auth.rest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
@ -30,6 +30,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.service.security.auth.MfaAuthenticationToken;
import org.thingsboard.server.service.security.auth.MfaConfigurationToken;
import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager;
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
@ -38,7 +39,7 @@ import java.io.IOException;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
@Component(value = "defaultAuthenticationSuccessHandler")
@Slf4j @Component(value = "defaultAuthenticationSuccessHandler")
@RequiredArgsConstructor
public class RestAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenFactory tokenFactory;
@ -46,18 +47,14 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
Authentication authentication) throws IOException {
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
JwtPair tokenPair = new JwtPair();
JwtPair tokenPair;
if (authentication instanceof MfaAuthenticationToken) {
int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true)
.flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30));
tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken());
tokenPair.setRefreshToken(null);
tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN);
tokenPair = createMfaTokenPair(securityUser, Authority.PRE_VERIFICATION_TOKEN);
} else if (authentication instanceof MfaConfigurationToken) {
tokenPair = createMfaTokenPair(securityUser, Authority.MFA_CONFIGURATION_TOKEN);
} else {
tokenPair = tokenFactory.createTokenPair(securityUser);
}
@ -69,6 +66,19 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc
clearAuthenticationAttributes(request);
}
public JwtPair createMfaTokenPair(SecurityUser securityUser, Authority scope) {
log.debug("[{}][{}] Creating {} token", securityUser.getTenantId(), securityUser.getId(), scope);
JwtPair tokenPair = new JwtPair();
int preVerificationTokenLifetime = twoFaConfigManager.getPlatformTwoFaSettings(securityUser.getTenantId(), true)
.flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())
.filter(time -> time > 0))
.orElse((int) TimeUnit.MINUTES.toSeconds(30));
tokenPair.setToken(tokenFactory.createMfaToken(securityUser, scope, preVerificationTokenLifetime).getToken());
tokenPair.setRefreshToken(null);
tokenPair.setScope(scope);
return tokenPair;
}
/**
* Removes temporary authentication-related data which may have been stored
* in the session during the authentication process..

22
application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java

@ -115,13 +115,16 @@ public class JwtTokenFactory {
throw new IllegalArgumentException("JWT Token doesn't have any scopes");
}
Authority authority = Authority.parse(scopes.get(0));
SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class))));
securityUser.setEmail(subject);
securityUser.setAuthority(Authority.parse(scopes.get(0)));
securityUser.setAuthority(authority);
String tenantId = claims.get(TENANT_ID, String.class);
if (tenantId != null) {
securityUser.setTenantId(TenantId.fromUUID(UUID.fromString(tenantId)));
} else if (securityUser.getAuthority() == Authority.SYS_ADMIN) {
} else if (authority == Authority.SYS_ADMIN) {
securityUser.setTenantId(TenantId.SYS_TENANT_ID);
}
String customerId = claims.get(CUSTOMER_ID, String.class);
@ -132,18 +135,15 @@ public class JwtTokenFactory {
securityUser.setSessionId(claims.get(SESSION_ID, String.class));
}
UserPrincipal principal;
if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_TOKEN) {
boolean isPublic = false;
if (authority != Authority.PRE_VERIFICATION_TOKEN && authority != Authority.MFA_CONFIGURATION_TOKEN) {
securityUser.setFirstName(claims.get(FIRST_NAME, String.class));
securityUser.setLastName(claims.get(LAST_NAME, String.class));
securityUser.setEnabled(claims.get(ENABLED, Boolean.class));
boolean isPublic = claims.get(IS_PUBLIC, Boolean.class);
principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject);
} else {
principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, subject);
isPublic = claims.get(IS_PUBLIC, Boolean.class);
}
UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject);
securityUser.setUserPrincipal(principal);
return securityUser;
}
@ -179,8 +179,8 @@ public class JwtTokenFactory {
return securityUser;
}
public JwtToken createPreVerificationToken(SecurityUser user, Integer expirationTime) {
JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime)
public JwtToken createMfaToken(SecurityUser user, Authority scope, Integer expirationTime) {
JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(scope.name()), expirationTime)
.claim(TENANT_ID, user.getTenantId().toString());
if (user.getCustomerId() != null) {
jwtBuilder.claim(CUSTOMER_ID, user.getCustomerId().toString());

2
application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java

@ -28,7 +28,7 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@Component(value = "customerUserPermissions")
@Component
public class CustomerUserPermissions extends AbstractPermissions {
public CustomerUserPermissions() {

13
application/src/main/java/org/thingsboard/server/service/security/permission/DefaultAccessControlService.java

@ -16,7 +16,6 @@
package org.thingsboard.server.service.security.permission;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.thingsboard.server.common.data.HasTenantId;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
@ -33,18 +32,18 @@ import java.util.Optional;
@Slf4j
public class DefaultAccessControlService implements AccessControlService {
private static final String INCORRECT_TENANT_ID = "Incorrect tenantId ";
private static final String YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION = "You don't have permission to perform this operation!";
private final Map<Authority, Permissions> authorityPermissions = new HashMap<>();
public DefaultAccessControlService(
@Qualifier("sysAdminPermissions") Permissions sysAdminPermissions,
@Qualifier("tenantAdminPermissions") Permissions tenantAdminPermissions,
@Qualifier("customerUserPermissions") Permissions customerUserPermissions) {
public DefaultAccessControlService(SysAdminPermissions sysAdminPermissions,
TenantAdminPermissions tenantAdminPermissions,
CustomerUserPermissions customerUserPermissions,
MfaConfigurationPermissions mfaConfigurationPermissions) {
authorityPermissions.put(Authority.SYS_ADMIN, sysAdminPermissions);
authorityPermissions.put(Authority.TENANT_ADMIN, tenantAdminPermissions);
authorityPermissions.put(Authority.CUSTOMER_USER, customerUserPermissions);
authorityPermissions.put(Authority.MFA_CONFIGURATION_TOKEN, mfaConfigurationPermissions);
}
@Override
@ -58,7 +57,7 @@ public class DefaultAccessControlService implements AccessControlService {
@Override
@SuppressWarnings("unchecked")
public <I extends EntityId, T extends HasTenantId> void checkPermission(SecurityUser user, Resource resource,
Operation operation, I entityId, T entity) throws ThingsboardException {
Operation operation, I entityId, T entity) throws ThingsboardException {
PermissionChecker permissionChecker = getPermissionChecker(user.getAuthority(), resource);
if (!permissionChecker.hasPermission(user, operation, entityId, entity)) {
permissionDenied();

28
application/src/main/java/org/thingsboard/server/service/security/permission/MfaConfigurationPermissions.java

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

2
application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java

@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@Component(value = "sysAdminPermissions")
@Component
public class SysAdminPermissions extends AbstractPermissions {
public SysAdminPermissions() {

2
application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java

@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.service.security.model.SecurityUser;
@Component(value = "tenantAdminPermissions")
@Component
public class TenantAdminPermissions extends AbstractPermissions {
public TenantAdminPermissions() {

12
application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java

@ -142,6 +142,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.DeviceCredentialsType;
import org.thingsboard.server.common.data.security.model.JwtPair;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.msg.session.FeatureType;
@ -209,7 +210,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
protected static final String TENANT_ADMIN_PASSWORD = "tenant";
protected static final String DIFFERENT_TENANT_ADMIN_EMAIL = "testdifftenant@thingsboard.org";
private static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String DIFFERENT_TENANT_ADMIN_PASSWORD = "difftenant";
protected static final String CUSTOMER_USER_EMAIL = "testcustomer@thingsboard.org";
private static final String CUSTOMER_USER_PASSWORD = "customer";
@ -602,8 +603,13 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
Assert.assertNotNull(tokenInfo);
Assert.assertTrue(tokenInfo.has("token"));
Assert.assertTrue(tokenInfo.has("refreshToken"));
String token = tokenInfo.get("token").asText();
String refreshToken = tokenInfo.get("refreshToken").asText();
validateAndSetJwtToken(JacksonUtil.treeToValue(tokenInfo, JwtPair.class), username);
}
protected void validateAndSetJwtToken(JwtPair jwtPair, String username) {
Assert.assertNotNull(jwtPair);
String token = jwtPair.getToken();
String refreshToken = jwtPair.getRefreshToken();
validateJwtToken(token, username);
validateJwtToken(refreshToken, username);
this.token = token;

152
application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java

@ -30,6 +30,8 @@ import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.CacheConstants;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.targets.platform.AllUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig;
@ -48,6 +50,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFaP
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
@ -85,7 +88,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaConfigManager.deletePlatformTwoFaSettings(tenantId);
}
@Test
public void testSavePlatformTwoFaSettings() throws Exception {
loginSysAdmin();
@ -102,15 +104,32 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaSettings.setVerificationCodeCheckRateLimit("3:900");
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
twoFaSettings.setTotalAllowedTimeForVerification(3600);
twoFaSettings.setEnforceTwoFa(true);
twoFaSettings.setEnforcedUsersFilter(new AllUsersFilter());
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
saveTwoFaSettings(twoFaSettings);
PlatformTwoFaSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class);
PlatformTwoFaSettings savedTwoFaSettings = findTwoFaSettings();
assertThat(savedTwoFaSettings.getProviders()).hasSize(2);
assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig);
}
@Test
public void testSavePlatformTwoFaSettingsWithEnforceTwoFaWithoutProviders() throws Exception {
loginSysAdmin();
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(List.of());
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setVerificationCodeCheckRateLimit("3:900");
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
twoFaSettings.setTotalAllowedTimeForVerification(3600);
twoFaSettings.setEnforceTwoFa(true);
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isBadRequest());
}
@Test
public void testSavePlatformTwoFaSettings_validationError() throws Exception {
loginSysAdmin();
@ -157,17 +176,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(errorResponse).containsIgnoringCase("verificationCodeLifetime is required");
}
private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception {
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings)
.andExpect(status().isBadRequest()));
}
@Test
public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception {
configureSmsTwoFaProvider("${code}");
@ -268,24 +276,6 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(errorMessage).containsIgnoringCase("verification code is incorrect");
}
private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception {
TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP")
.andExpect(status().isOk()), TwoFaAccountConfig.class);
assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class);
assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> {
UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build();
assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth");
assertThat(otpAuthUrl.getHost()).isEqualTo("totp");
assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName());
assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL);
assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> {
assertDoesNotThrow(() -> Base32.decode(secretKey));
});
});
return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig;
}
@Test
public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception {
testVerifyAndSaveTotpTwoFaAccountConfig();
@ -419,6 +409,56 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig);
}
@Test
public void testIsTwoFaEnabled() throws Exception {
configureSmsTwoFaProvider("${code}");
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig);
assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUser)).isTrue();
}
@Test
public void testDeleteTwoFaAccountConfig() throws Exception {
configureSmsTwoFaProvider("${code}");
loginTenantAdmin();
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUser, accountConfig);
AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class);
TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS);
assertThat(savedAccountConfig).isEqualTo(accountConfig);
PlatformTwoFaSettings twoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, true).get();
twoFaSettings.setEnforceTwoFa(true);
TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter();
enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId()));
twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
String errorMessage = getErrorMessage(doDelete("/api/2fa/account/config?providerType=SMS")
.andExpect(status().isBadRequest()));
assertThat(errorMessage).isEqualTo("At least one 2FA provider is required");
twoFaSettings.setEnforceTwoFa(false);
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk());
assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs())
.doesNotContainKey(TwoFaProviderType.SMS);
}
private PlatformTwoFaSettings findTwoFaSettings() throws Exception {
return doGet("/api/2fa/settings", PlatformTwoFaSettings.class);
}
private void saveTwoFaSettings(PlatformTwoFaSettings twoFaSettings) throws Exception {
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
}
private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception {
TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
totpTwoFaProviderConfig.setIssuerName("tb");
@ -441,37 +481,35 @@ public class TwoFactorAuthConfigTest extends AbstractControllerTest {
twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList()));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk());
saveTwoFaSettings(twoFaSettings);
}
@Test
public void testIsTwoFaEnabled() throws Exception {
configureSmsTwoFaProvider("${code}");
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig);
private TotpTwoFaAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFaProviderConfig totpTwoFaProviderConfig) throws Exception {
TwoFaAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP")
.andExpect(status().isOk()), TwoFaAccountConfig.class);
assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFaAccountConfig.class);
assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue();
assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> {
UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build();
assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth");
assertThat(otpAuthUrl.getHost()).isEqualTo("totp");
assertThat(otpAuthUrl.getQueryParams().getFirst("issuer")).isEqualTo(totpTwoFaProviderConfig.getIssuerName());
assertThat(otpAuthUrl.getPath()).isEqualTo("/%s:%s", totpTwoFaProviderConfig.getIssuerName(), TENANT_ADMIN_EMAIL);
assertThat(otpAuthUrl.getQueryParams().getFirst("secret")).satisfies(secretKey -> {
assertDoesNotThrow(() -> Base32.decode(secretKey));
});
});
return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig;
}
@Test
public void testDeleteTwoFaAccountConfig() throws Exception {
configureSmsTwoFaProvider("${code}");
SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig();
accountConfig.setPhoneNumber("+38050505050");
loginTenantAdmin();
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig);
AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class);
TwoFaAccountConfig savedAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS);
assertThat(savedAccountConfig).isEqualTo(accountConfig);
doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk());
private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception {
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs())
.doesNotContainKey(TwoFaProviderType.SMS);
return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings)
.andExpect(status().isBadRequest()));
}
}

74
application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java

@ -25,6 +25,7 @@ import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.web.util.UriComponentsBuilder;
import org.thingsboard.rule.engine.api.SmsService;
import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.User;
@ -33,6 +34,7 @@ import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.audit.AuditLog;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.page.SortOrder;
import org.thingsboard.server.common.data.page.TimePageLink;
@ -58,6 +60,7 @@ import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@ -116,7 +119,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_totp() throws Exception {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=TOTP")
.andExpect(status().isOk());
@ -136,7 +139,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_sms() throws Exception {
configureSmsTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=SMS")
.andExpect(status().isOk());
@ -160,7 +163,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setTotalAllowedTimeForVerification(65);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
await("expiration of the pre-verification token")
.atLeast(Duration.ofSeconds(30).plusMillis(500))
@ -177,7 +180,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
Stream.generate(() -> StringUtils.randomNumeric(6))
.limit(9)
@ -206,7 +209,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setMinVerificationCodeSendPeriod(10);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
doPost("/api/auth/2fa/verification/send?providerType=TOTP")
.andExpect(status().isOk());
@ -230,7 +233,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaSettings.setVerificationCodeCheckRateLimit("3:10");
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
for (int i = 0; i < 3; i++) {
String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect")
@ -258,7 +261,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
@Test
public void testCheckVerificationCode_invalidVerificationCode() throws Exception {
configureTotpTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) {
String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode)
@ -273,7 +276,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
smsTwoFaProviderConfig.setVerificationCodeLifetime(10);
});
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
ArgumentCaptor<String> verificationCodeCaptor = ArgumentCaptor.forClass(String.class);
doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk());
@ -296,7 +299,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
public void testTwoFa_logLoginAction() throws Exception {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa();
logInWithPreVerificationToken(username, password);
logInWithMfaToken(username, password, Authority.PRE_VERIFICATION_TOKEN);
await("async audit log saving").during(1, TimeUnit.SECONDS);
doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect")
@ -334,7 +337,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
@Test
public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException {
configureTotpTwoFa();
twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP);
twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user, TwoFaProviderType.TOTP);
assertDoesNotThrow(() -> {
login(username, password);
@ -368,17 +371,17 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP);
totpTwoFaAccountConfig.setUseByDefault(true);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, totpTwoFaAccountConfig);
SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig();
smsTwoFaAccountConfig.setPhoneNumber("+38012312322");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, smsTwoFaAccountConfig);
EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig();
emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail());
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser, emailTwoFaAccountConfig);
logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678");
logInWithMfaToken(twoFaUser.getEmail(), "12345678", Authority.PRE_VERIFICATION_TOKEN);
Map<TwoFaProviderType, TwoFactorAuthController.TwoFaProviderInfo> providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference<List<TwoFactorAuthController.TwoFaProviderInfo>>() {}).stream()
.collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v));
@ -395,13 +398,48 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse();
}
private void logInWithPreVerificationToken(String username, String password) throws Exception {
@Test
public void testEnforceTwoFa() throws Exception {
TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig();
totpTwoFaProviderConfig.setIssuerName("tb");
PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings();
twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList()));
twoFaSettings.setMinVerificationCodeSendPeriod(5);
twoFaSettings.setTotalAllowedTimeForVerification(100);
twoFaSettings.setEnforceTwoFa(true);
TenantAdministratorsFilter enforcedUsersFilter = new TenantAdministratorsFilter();
enforcedUsersFilter.setTenantsIds(Set.of(tenantId.getId()));
twoFaSettings.setEnforcedUsersFilter(enforcedUsersFilter);
twoFaSettings = twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
logInWithMfaToken(username, password, Authority.MFA_CONFIGURATION_TOKEN);
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, totpTwoFaProviderConfig.getProviderType());
String secret = UriComponentsBuilder.fromUriString(totpTwoFaAccountConfig.getAuthUrl()).build()
.getQueryParams().getFirst("secret");
String verificationCode = new Totp(secret).now();
readResponse(doPost("/api/2fa/account/config?verificationCode=" + verificationCode, totpTwoFaAccountConfig).andExpect(status().isOk()), JsonNode.class);
JwtPair tokenPair = readResponse(doPost("/api/auth/2fa/login").andExpect(status().isOk()), JwtPair.class);
assertThat(tokenPair.getToken()).isNotEmpty();
assertThat(tokenPair.getRefreshToken()).isNotEmpty();
validateAndSetJwtToken(tokenPair, username);
doGet("/api/user/" + user.getId()).andExpect(status().isOk());
// verifying enforced users filter
createDifferentTenant();
doGet("/api/user/" + savedDifferentTenantUser.getId()).andExpect(status().isOk());
}
private void logInWithMfaToken(String username, String password, Authority expectedScope) throws Exception {
LoginRequest loginRequest = new LoginRequest(username, password);
JwtPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtPair.class);
assertThat(response.getToken()).isNotNull();
assertThat(response.getRefreshToken()).isNull();
assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN);
assertThat(response.getScope()).isEqualTo(expectedScope);
this.token = response.getToken();
}
@ -418,7 +456,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, twoFaSettings);
TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, totpTwoFaAccountConfig);
return totpTwoFaAccountConfig;
}
@ -436,7 +474,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest {
SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig();
smsTwoFaAccountConfig.setPhoneNumber("+38050505050");
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig);
twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user, smsTwoFaAccountConfig);
return smsTwoFaAccountConfig;
}

2
application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java

@ -125,7 +125,7 @@ public class JwtTokenFactoryTest {
public void testCreateAndParsePreVerificationJwtToken() {
SecurityUser securityUser = createSecurityUser();
int tokenLifetime = (int) TimeUnit.MINUTES.toSeconds(30);
JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime);
JwtToken preVerificationToken = tokenFactory.createMfaToken(securityUser, Authority.PRE_VERIFICATION_TOKEN, tokenLifetime);
checkExpirationTime(preVerificationToken, tokenLifetime);
SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(preVerificationToken.getToken());

1
common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TbTenantProfileCache.java

@ -15,7 +15,6 @@
*/
package org.thingsboard.server.dao.tenant;
import org.thingsboard.server.common.data.SystemParams;
import org.thingsboard.server.common.data.TenantProfile;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;

6
common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java

@ -23,6 +23,8 @@ import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.id.UserCredentialsId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.UserCredentials;
@ -109,4 +111,8 @@ public interface UserService extends EntityDaoService {
void removeMobileSession(TenantId tenantId, String mobileToken);
PageData<User> findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink);
boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user);
}

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java

@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.notification.targets.platform;
import lombok.Data;
@Data
public class AllUsersFilter implements UsersFilter {
public class AllUsersFilter implements SystemLevelUsersFilter {
@Override
public UsersFilterType getType() {

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java

@ -18,7 +18,7 @@ package org.thingsboard.server.common.data.notification.targets.platform;
import lombok.Data;
@Data
public class SystemAdministratorsFilter implements UsersFilter {
public class SystemAdministratorsFilter implements SystemLevelUsersFilter {
@Override
public UsersFilterType getType() {

19
common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemLevelUsersFilter.java

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

2
common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java

@ -21,7 +21,7 @@ import java.util.Set;
import java.util.UUID;
@Data
public class TenantAdministratorsFilter implements UsersFilter {
public class TenantAdministratorsFilter implements SystemLevelUsersFilter {
private Set<UUID> tenantsIds;
private Set<UUID> tenantProfilesIds;

4
common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java

@ -20,8 +20,10 @@ public enum Authority {
SYS_ADMIN(0),
TENANT_ADMIN(1),
CUSTOMER_USER(2),
REFRESH_TOKEN(10),
PRE_VERIFICATION_TOKEN(11);
PRE_VERIFICATION_TOKEN(11),
MFA_CONFIGURATION_TOKEN(12);
private int code;

3
common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java

@ -21,6 +21,7 @@ import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
@ -46,6 +47,8 @@ public class PlatformTwoFaSettings {
@Min(value = 60)
private Integer totalAllowedTimeForVerification;
private boolean enforceTwoFa;
private SystemLevelUsersFilter enforcedUsersFilter;
public Optional<TwoFaProviderConfig> getProviderConfig(TwoFaProviderType providerType) {
return Optional.ofNullable(providers)

51
dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotificationTargetService.java

@ -25,17 +25,13 @@ import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.HasId;
import org.thingsboard.server.common.data.id.NotificationTargetId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.id.TenantProfileId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.notification.NotificationRequestStatus;
import org.thingsboard.server.common.data.notification.NotificationType;
import org.thingsboard.server.common.data.notification.info.RuleOriginatedNotificationInfo;
import org.thingsboard.server.common.data.notification.targets.NotificationTarget;
import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig;
import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig;
import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UsersFilterType;
import org.thingsboard.server.common.data.page.PageData;
@ -50,9 +46,6 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import static org.apache.commons.collections4.CollectionUtils.isNotEmpty;
@Service
@Slf4j
@ -115,49 +108,7 @@ public class DefaultNotificationTargetService extends AbstractEntityService impl
@Override
public PageData<User> findRecipientsForNotificationTargetConfig(TenantId tenantId, PlatformUsersNotificationTargetConfig targetConfig, PageLink pageLink) {
UsersFilter usersFilter = targetConfig.getUsersFilter();
switch (usersFilter.getType()) {
case USER_LIST: {
List<User> users = ((UserListFilter) usersFilter).getUsersIds().stream()
.limit(pageLink.getPageSize())
.map(UserId::new).map(userId -> userService.findUserById(tenantId, userId))
.filter(Objects::nonNull).collect(Collectors.toList());
return new PageData<>(users, 1, users.size(), false);
}
case CUSTOMER_USERS: {
if (tenantId.equals(TenantId.SYS_TENANT_ID)) {
throw new IllegalArgumentException("Customer users target is not supported for system administrator");
}
CustomerUsersFilter filter = (CustomerUsersFilter) usersFilter;
return userService.findCustomerUsers(tenantId, new CustomerId(filter.getCustomerId()), pageLink);
}
case TENANT_ADMINISTRATORS: {
TenantAdministratorsFilter filter = (TenantAdministratorsFilter) usersFilter;
if (!tenantId.equals(TenantId.SYS_TENANT_ID)) {
return userService.findTenantAdmins(tenantId, pageLink);
} else {
if (isNotEmpty(filter.getTenantsIds())) {
return userService.findTenantAdminsByTenantsIds(filter.getTenantsIds().stream()
.map(TenantId::fromUUID).collect(Collectors.toList()), pageLink);
} else if (isNotEmpty(filter.getTenantProfilesIds())) {
return userService.findTenantAdminsByTenantProfilesIds(filter.getTenantProfilesIds().stream()
.map(TenantProfileId::new).collect(Collectors.toList()), pageLink);
} else {
return userService.findAllTenantAdmins(pageLink);
}
}
}
case SYSTEM_ADMINISTRATORS:
return userService.findSysAdmins(pageLink);
case ALL_USERS: {
if (!tenantId.equals(TenantId.SYS_TENANT_ID)) {
return userService.findUsersByTenantId(tenantId, pageLink);
} else {
return userService.findAllUsers(pageLink);
}
}
default:
throw new IllegalArgumentException("Recipient type not supported");
}
return userService.findUsersByFilter(tenantId, usersFilter, pageLink);
}
@Override

1
dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java

@ -75,6 +75,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService<TenantId, Ten
@Autowired
private ApiUsageStateService apiUsageStateService;
@Autowired
@Lazy
private NotificationSettingsService notificationSettingsService;
@Autowired
private QrCodeSettingService qrCodeSettingService;

83
dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java

@ -44,6 +44,11 @@ import org.thingsboard.server.common.data.id.UserCredentialsId;
import org.thingsboard.server.common.data.id.UserId;
import org.thingsboard.server.common.data.mobile.MobileSessionInfo;
import org.thingsboard.server.common.data.mobile.UserMobileSessionInfo;
import org.thingsboard.server.common.data.notification.targets.platform.CustomerUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.SystemLevelUsersFilter;
import org.thingsboard.server.common.data.notification.targets.platform.TenantAdministratorsFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UserListFilter;
import org.thingsboard.server.common.data.notification.targets.platform.UsersFilter;
import org.thingsboard.server.common.data.page.PageData;
import org.thingsboard.server.common.data.page.PageLink;
import org.thingsboard.server.common.data.security.Authority;
@ -62,6 +67,7 @@ import org.thingsboard.server.dao.service.DataValidator;
import org.thingsboard.server.dao.service.PaginatedRemover;
import org.thingsboard.server.dao.settings.SecuritySettingsService;
import org.thingsboard.server.dao.sql.JpaExecutorService;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import java.util.ArrayList;
import java.util.Collections;
@ -71,7 +77,9 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.apache.commons.collections4.CollectionUtils.isNotEmpty;
import static org.thingsboard.server.common.data.StringUtils.generateSafeToken;
import static org.thingsboard.server.dao.service.Validator.validateId;
import static org.thingsboard.server.dao.service.Validator.validatePageLink;
@ -97,6 +105,7 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
private final UserSettingsService userSettingsService;
private final UserSettingsDao userSettingsDao;
private final SecuritySettingsService securitySettingsService;
private final TbTenantProfileCache tenantProfileCache;
private final DataValidator<User> userValidator;
private final DataValidator<UserCredentials> userCredentialsValidator;
private final ApplicationEventPublisher eventPublisher;
@ -496,6 +505,80 @@ public class UserServiceImpl extends AbstractCachedEntityService<UserCacheKey, U
return userCredentialsDao.incrementFailedLoginAttempts(tenantId, userId);
}
@Override
public PageData<User> findUsersByFilter(TenantId tenantId, UsersFilter filter, PageLink pageLink) {
switch (filter.getType()) {
case USER_LIST -> {
List<User> users = ((UserListFilter) filter).getUsersIds().stream()
.limit(pageLink.getPageSize())
.map(UserId::new).map(userId -> findUserById(tenantId, userId))
.filter(Objects::nonNull).collect(Collectors.toList());
return new PageData<>(users, 1, users.size(), false);
}
case CUSTOMER_USERS -> {
if (tenantId.equals(TenantId.SYS_TENANT_ID)) {
throw new IllegalArgumentException("Customer users target is not supported for system administrator");
}
CustomerUsersFilter customerUsersFilter = (CustomerUsersFilter) filter;
return findCustomerUsers(tenantId, new CustomerId(customerUsersFilter.getCustomerId()), pageLink);
}
case TENANT_ADMINISTRATORS -> {
TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter;
if (!tenantId.equals(TenantId.SYS_TENANT_ID)) {
return findTenantAdmins(tenantId, pageLink);
} else {
if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) {
return findTenantAdminsByTenantsIds(tenantAdministratorsFilter.getTenantsIds().stream()
.map(TenantId::fromUUID).collect(Collectors.toList()), pageLink);
} else if (isNotEmpty(tenantAdministratorsFilter.getTenantProfilesIds())) {
return findTenantAdminsByTenantProfilesIds(tenantAdministratorsFilter.getTenantProfilesIds().stream()
.map(TenantProfileId::new).collect(Collectors.toList()), pageLink);
} else {
return findAllTenantAdmins(pageLink);
}
}
}
case SYSTEM_ADMINISTRATORS -> {
return findSysAdmins(pageLink);
}
case ALL_USERS -> {
if (!tenantId.equals(TenantId.SYS_TENANT_ID)) {
return findUsersByTenantId(tenantId, pageLink);
} else {
return findAllUsers(pageLink);
}
}
default -> throw new IllegalArgumentException("Recipient type not supported");
}
}
@Override
public boolean matchesFilter(TenantId tenantId, SystemLevelUsersFilter filter, User user) {
switch (filter.getType()) {
case TENANT_ADMINISTRATORS -> {
if (user.isSystemAdmin() || user.isCustomerUser()) {
return false;
}
TenantAdministratorsFilter tenantAdministratorsFilter = (TenantAdministratorsFilter) filter;
if (isNotEmpty(tenantAdministratorsFilter.getTenantsIds())) {
return tenantAdministratorsFilter.getTenantsIds().contains(user.getTenantId().getId());
} else if (isNotEmpty(tenantAdministratorsFilter.getTenantProfilesIds())) {
return tenantAdministratorsFilter.getTenantProfilesIds().contains(tenantProfileCache.get(user.getTenantId()).getUuidId());
} else {
return user.getAuthority() == Authority.TENANT_ADMIN;
}
}
case SYSTEM_ADMINISTRATORS -> {
return user.getAuthority() == Authority.SYS_ADMIN;
}
case ALL_USERS -> {
return true;
}
default -> throw new IllegalArgumentException("Recipient type not supported");
}
}
private void updatePasswordHistory(UserCredentials userCredentials) {
JsonNode additionalInfo = userCredentials.getAdditionalInfo();
if (!(additionalInfo instanceof ObjectNode)) {

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

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

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

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

351
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html

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

7
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss

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

72
ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

6
ui-ngx/src/app/shared/components/phone-input.component.html

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

9
ui-ngx/src/app/shared/components/phone-input.component.ts

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

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

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

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

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

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

@ -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",
@ -3916,7 +3918,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",

Loading…
Cancel
Save