diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index 5d3e3bf116..527c862c95 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -114,7 +114,7 @@ public class TwoFactorAuthConfigController extends BaseController { @GetMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") public TwoFactorAuthSettings getTwoFactorAuthSettings() throws ThingsboardException { - return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId()).orElse(null); + return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId(), false).orElse(null); } @PostMapping("/settings") diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index ff6417a3aa..d858f64899 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -37,14 +37,9 @@ import javax.servlet.http.HttpServletRequest; /* * TODO [viacheslav]: - * - Tests for 2FA * - Swagger documentation - * */ - -/* - * * - * TODO (later): + * TODO [viacheslav] (later): * - 2FA entries should be secured against code injection by code validation * - ability to force users to use 2FA (maybe on log in, do not give them token pair but to give temporary * token to configure 2FA account config); also will need to make users configure 2FA during activation and password setup... diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java index c8117ec792..1012f27d25 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -71,7 +71,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { @Override public void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { - TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) + TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId(), true) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); if (checkLimits) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { @@ -102,7 +102,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { throw new ThingsboardException("User is disabled", ThingsboardErrorCode.AUTHENTICATION); } - TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) + TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId(), true) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); if (checkLimits) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeCheckRateLimit())) { @@ -136,7 +136,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { private TwoFactorAuthProviderConfig getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) throws ThingsboardException { - return configManager.getTwoFaSettings(tenantId) + return configManager.getTwoFaSettings(tenantId, true) .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java index a96d25e520..bd486a6af0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.service.ConstraintValidator; +import org.thingsboard.server.dao.settings.AdminSettingsDao; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; @@ -47,6 +48,7 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan private final UserService userService; private final AdminSettingsService adminSettingsService; + private final AdminSettingsDao adminSettingsDao; private final AttributesService attributesService; protected static final String TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY = "twoFaConfig"; @@ -54,8 +56,8 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan @Override - public boolean isTwoFaEnabled(User user) { - return getTwoFaAccountConfig(user.getTenantId(), user.getId()).isPresent(); + public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) { + return getTwoFaAccountConfig(tenantId, userId).isPresent(); } @Override @@ -96,21 +98,26 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { - return getTwoFaSettings(tenantId) + return getTwoFaSettings(tenantId, true) .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)); } @SneakyThrows({InterruptedException.class, ExecutionException.class}) @Override - public Optional getTwoFaSettings(TenantId tenantId) { + public Optional getTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault) { if (tenantId.equals(TenantId.SYS_TENANT_ID)) { - return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) + return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, TWO_FACTOR_AUTH_SETTINGS_KEY)) .map(adminSettings -> JacksonUtil.treeToValue(adminSettings.getJsonValue(), TwoFactorAuthSettings.class)); } else { - return attributesService.find(TenantId.SYS_TENANT_ID, tenantId, DataConstants.SERVER_SCOPE, TWO_FACTOR_AUTH_SETTINGS_KEY).get() - .map(adminSettingsAttribute -> JacksonUtil.fromString(adminSettingsAttribute.getJsonValue().get(), TwoFactorAuthSettings.class)) - .filter(tenantTwoFactorAuthSettings -> !tenantTwoFactorAuthSettings.isUseSystemTwoFactorAuthSettings()) - .or(() -> getTwoFaSettings(TenantId.SYS_TENANT_ID)); + Optional tenantTwoFaSettings = attributesService.find(TenantId.SYS_TENANT_ID, tenantId, + DataConstants.SERVER_SCOPE, TWO_FACTOR_AUTH_SETTINGS_KEY).get() + .map(adminSettingsAttribute -> JacksonUtil.fromString(adminSettingsAttribute.getJsonValue().get(), TwoFactorAuthSettings.class)); + if (sysadminSettingsAsDefault) { + if (tenantTwoFaSettings.isEmpty() || tenantTwoFaSettings.get().isUseSystemTwoFactorAuthSettings()) { + return getTwoFaSettings(TenantId.SYS_TENANT_ID, false); + } + } + return tenantTwoFaSettings; } } @@ -136,4 +143,16 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan } } + @SneakyThrows({InterruptedException.class, ExecutionException.class}) + @Override + public void deleteTwoFaSettings(TenantId tenantId) { + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) + .ifPresent(adminSettings -> adminSettingsDao.removeById(tenantId, adminSettings.getId().getId())); + } else { + attributesService.removeAll(TenantId.SYS_TENANT_ID, tenantId, DataConstants.SERVER_SCOPE, + Collections.singletonList(TWO_FACTOR_AUTH_SETTINGS_KEY)).get(); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java index 94c18aa999..96189bf584 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java @@ -15,7 +15,6 @@ */ 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; @@ -25,7 +24,7 @@ import java.util.Optional; public interface TwoFactorAuthConfigManager { - boolean isTwoFaEnabled(User user); + boolean isTwoFaEnabled(TenantId tenantId, UserId userId); Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId); @@ -34,8 +33,10 @@ public interface TwoFactorAuthConfigManager { void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId); - Optional getTwoFaSettings(TenantId tenantId); + Optional getTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault); void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings); + void deleteTwoFaSettings(TenantId tenantId); + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java index ae39065e74..72da35e744 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java @@ -34,17 +34,17 @@ public class TwoFactorAuthSettings { private List providers; @ApiModelProperty(example = "1:60 (1 request per minute)") - @Pattern(regexp = "[^0]\\d+:[^0]\\d+", message = "Rate limit configuration is invalid") + @Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code send rate limit configuration is invalid") private String verificationCodeSendRateLimit; @ApiModelProperty(example = "3:900 (3 requests per 15 minutes)") - @Pattern(regexp = "[^0]\\d+:[^0]\\d+", message = "Rate limit configuration is invalid") + @Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code check rate limit configuration is invalid") private String verificationCodeCheckRateLimit; @ApiModelProperty(example = "10") - @Min(0) + @Min(value = 0, message = "maximum number of verification failure before user lockout must be positive") private int maxVerificationFailuresBeforeUserLockout; @ApiModelProperty(value = "in minutes", example = "60") - @Min(1) - private int totalAllowedTimeForVerification; + @Min(value = 1, message = "total amount of time allotted for verification must be greater than 0") + private Integer totalAllowedTimeForVerification; public Optional getProviderConfig(TwoFactorAuthProviderType providerType) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java index 80b832a831..ef090c63b4 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java @@ -15,5 +15,8 @@ */ package org.thingsboard.server.service.security.auth.mfa.config.account; +import lombok.Data; + +@Data public abstract class OtpBasedTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java index 40e94899a7..c921c5aafe 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java @@ -26,8 +26,8 @@ import javax.validation.constraints.Pattern; @Data public class SmsTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { - @NotBlank - @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "Phone number is not of E.164 format") + @NotBlank(message = "phone number cannot be blank") + @Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "phone number is not of E.164 format") private String phoneNumber; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java index 93975d49ad..7c92955043 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.auth.mfa.config.account; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @@ -24,8 +25,9 @@ import javax.validation.constraints.Pattern; @Data public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { - @NotBlank -// @Pattern(regexp = "otpauth://totp/") // FIXME [viacheslav]: validate otp auth url by pattern + @ApiModelProperty(example = "otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII") + @NotBlank(message = "OTP auth URL cannot be blank") + @Pattern(regexp = "otpauth://totp/(\\S+?):(\\S+?)\\?issuer=(\\S+?)&secret=(\\w+?)", message = "OTP auth url is invalid") private String authUrl; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java index fa1e5d7b43..597c3e1e46 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java @@ -23,6 +23,6 @@ import javax.validation.constraints.Min; @Data public abstract class OtpBasedTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { @ApiModelProperty(value = "in seconds", example = "60") - @Min(1) // TODO [viacheslav]: test + @Min(value = 1, message = "verification code lifetime is required") private int verificationCodeLifetime; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java index 3fe9e77ce7..8b34419041 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java @@ -26,7 +26,7 @@ import javax.validation.constraints.Pattern; @Data public class SmsTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig { - @NotBlank + @NotBlank(message = "verification message template is required") @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "template must contain verification code") private String smsVerificationMessageTemplate; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFactorAuthProvider.java index ab6c15e56b..db6653ffe9 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFactorAuthProvider.java @@ -57,7 +57,7 @@ public abstract class OtpBasedTwoFactorAuthProvider Optional.ofNullable(settings.getTotalAllowedTimeForVerification())).orElse(30)); tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); tokenPair.setRefreshToken(null); tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 32d55a71dd..efae835421 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -66,6 +66,7 @@ import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeCon import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.TimePageLink; @@ -124,6 +125,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected String username; protected TenantId tenantId; + protected UserId tenantAdminUserId; @SuppressWarnings("rawtypes") private HttpMessageConverter mappingJackson2HttpMessageConverter; @@ -186,7 +188,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { tenantAdmin.setTenantId(tenantId); tenantAdmin.setEmail(TENANT_ADMIN_EMAIL); - createUserAndLogin(tenantAdmin, TENANT_ADMIN_PASSWORD); + tenantAdmin = createUserAndLogin(tenantAdmin, TENANT_ADMIN_PASSWORD); + tenantAdminUserId = tenantAdmin.getId(); Customer customer = new Customer(); customer.setTitle("Customer"); diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java new file mode 100644 index 0000000000..01e2bc496e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -0,0 +1,525 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import org.jboss.aerogear.security.otp.Totp; +import org.jboss.aerogear.security.otp.api.Base32; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +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.cache.CacheManager; +import org.springframework.web.util.UriComponents; +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.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; +import org.thingsboard.server.service.security.auth.mfa.config.account.SmsTwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.SmsTwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TotpTwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.auth.mfa.provider.impl.OtpBasedTwoFactorAuthProvider; +import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFactorAuthProvider; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { + + @SpyBean + private TotpTwoFactorAuthProvider totpTwoFactorAuthProvider; + @MockBean + private SmsService smsService; + @Autowired + private CacheManager cacheManager; + @Autowired + private TwoFactorAuthConfigManager twoFactorAuthConfigManager; + + @Before + public void beforeEach() throws Exception { + loginSysAdmin(); + } + + @After + public void afterEach() { + twoFactorAuthConfigManager.deleteTwoFaSettings(TenantId.SYS_TENANT_ID); + twoFactorAuthConfigManager.deleteTwoFaSettings(tenantId); + } + + + @Test + public void testSaveTwoFaSettings() throws Exception { + loginSysAdmin(); + testSaveTestTwoFaSettings(); + + loginTenantAdmin(); + testSaveTestTwoFaSettings(); + } + + private void testSaveTestTwoFaSettings() throws Exception { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); + smsTwoFaProviderConfig.setVerificationCodeLifetime(60); + + TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + twoFaSettings.setProviders(List.of(totpTwoFaProviderConfig, smsTwoFaProviderConfig)); + twoFaSettings.setVerificationCodeSendRateLimit("1:60"); + twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + twoFaSettings.setTotalAllowedTimeForVerification(60); + + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + + TwoFactorAuthSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); + + assertThat(savedTwoFaSettings.getProviders()).hasSize(2); + assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig); + } + + @Test + public void testSaveTwoFaSettings_validationError() throws Exception { + loginTenantAdmin(); + + TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + twoFaSettings.setProviders(Collections.emptyList()); + twoFaSettings.setVerificationCodeSendRateLimit("ab:aba"); + twoFaSettings.setVerificationCodeCheckRateLimit("0:12"); + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(-1); + twoFaSettings.setTotalAllowedTimeForVerification(0); + + String errorMessage = getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isBadRequest())); + + assertThat(errorMessage).contains( + "verification code send rate limit configuration is invalid", + "verification code check rate limit configuration is invalid", + "maximum number of verification failure before user lockout must be positive", + "total amount of time allotted for verification must be greater than 0" + ); + + twoFaSettings.setUseSystemTwoFactorAuthSettings(true); + doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isOk()); + + twoFaSettings.setVerificationCodeSendRateLimit(null); + twoFaSettings.setVerificationCodeCheckRateLimit(null); + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(0); + twoFaSettings.setTotalAllowedTimeForVerification(null); + + doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isOk()); + } + + @Test + public void testGetTwoFaSettings_useSysadminSettingsAsDefault() throws Exception { + loginSysAdmin(); + TwoFactorAuthSettings sysadminTwoFaSettings = new TwoFactorAuthSettings(); + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + sysadminTwoFaSettings.setProviders(Collections.singletonList(totpTwoFaProviderConfig)); + sysadminTwoFaSettings.setMaxVerificationFailuresBeforeUserLockout(25); + doPost("/api/2fa/settings", sysadminTwoFaSettings).andExpect(status().isOk()); + + loginTenantAdmin(); + TwoFactorAuthSettings tenantTwoFaSettings = new TwoFactorAuthSettings(); + tenantTwoFaSettings.setUseSystemTwoFactorAuthSettings(true); + tenantTwoFaSettings.setProviders(Collections.emptyList()); + doPost("/api/2fa/settings", tenantTwoFaSettings).andExpect(status().isOk()); + TwoFactorAuthSettings twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); + assertThat(twoFaSettings).isEqualTo(tenantTwoFaSettings); + + doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isOk()); + + tenantTwoFaSettings.setUseSystemTwoFactorAuthSettings(false); + tenantTwoFaSettings.setProviders(Collections.emptyList()); + tenantTwoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + doPost("/api/2fa/settings", tenantTwoFaSettings).andExpect(status().isOk()); + twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); + assertThat(twoFaSettings).isEqualTo(tenantTwoFaSettings); + + assertThat(getErrorMessage(doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isBadRequest()))).containsIgnoringCase("provider is not configured"); + + loginSysAdmin(); + sysadminTwoFaSettings.setProviders(Collections.emptyList()); + doPost("/api/2fa/settings", sysadminTwoFaSettings).andExpect(status().isOk()); + loginTenantAdmin(); + tenantTwoFaSettings.setUseSystemTwoFactorAuthSettings(true); + tenantTwoFaSettings.setProviders(Collections.singletonList(totpTwoFaProviderConfig)); + doPost("/api/2fa/settings", tenantTwoFaSettings).andExpect(status().isOk()); + + assertThat(getErrorMessage(doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isBadRequest()))).containsIgnoringCase("provider is not configured"); + + tenantTwoFaSettings.setUseSystemTwoFactorAuthSettings(false); + doPost("/api/2fa/settings", tenantTwoFaSettings).andExpect(status().isOk()); + + doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isOk()); + + loginSysAdmin(); + twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); + assertThat(twoFaSettings).isEqualTo(sysadminTwoFaSettings); + } + + @Test + public void testSaveTotpTwoFaProviderConfig_validationError() throws Exception { + TotpTwoFactorAuthProviderConfig invalidTotpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + invalidTotpTwoFaProviderConfig.setIssuerName(" "); + + String errorResponse = saveTwoFaSettingsAndGetError(invalidTotpTwoFaProviderConfig); + assertThat(errorResponse).containsIgnoringCase("issuer name must not be blank"); + } + + @Test + public void testSaveSmsTwoFaProviderConfig_validationError() throws Exception { + SmsTwoFactorAuthProviderConfig invalidSmsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + invalidSmsTwoFaProviderConfig.setSmsVerificationMessageTemplate("does not contain verification code"); + invalidSmsTwoFaProviderConfig.setVerificationCodeLifetime(60); + + String errorResponse = saveTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); + assertThat(errorResponse).containsIgnoringCase("must contain verification code"); + + invalidSmsTwoFaProviderConfig.setSmsVerificationMessageTemplate(null); + invalidSmsTwoFaProviderConfig.setVerificationCodeLifetime(0); + errorResponse = saveTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); + assertThat(errorResponse).containsIgnoringCase("verification message template is required"); + assertThat(errorResponse).containsIgnoringCase("verification code lifetime is required"); + } + + private String saveTwoFaSettingsAndGetError(TwoFactorAuthProviderConfig invalidTwoFaProviderConfig) throws Exception { + TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); + + return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) + .andExpect(status().isBadRequest())); + } + + @Test + public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); + + loginTenantAdmin(); + + TwoFactorAuthProviderType notConfiguredProviderType = TwoFactorAuthProviderType.TOTP; + String errorMessage = getErrorMessage(doPost("/api/2fa/account/config/generate?providerType=" + notConfiguredProviderType) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("provider is not configured"); + + TotpTwoFactorAuthAccountConfig notConfiguredProviderAccountConfig = new TotpTwoFactorAuthAccountConfig(); + notConfiguredProviderAccountConfig.setAuthUrl("otpauth://totp/aba:aba?issuer=aba&secret=ABA"); + errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", notConfiguredProviderAccountConfig)); + assertThat(errorMessage).containsIgnoringCase("provider is not configured"); + } + + @Test + public void testGenerateTotpTwoFaAccountConfig() throws Exception { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + + loginTenantAdmin(); + + assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)).isNullOrEmpty(); + generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + } + + @Test + public void testSubmitTotpTwoFaAccountConfig() throws Exception { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + + loginTenantAdmin(); + + TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + doPost("/api/2fa/account/config/submit", generatedTotpTwoFaAccountConfig).andExpect(status().isOk()); + verify(totpTwoFactorAuthProvider).prepareVerificationCode(argThat(user -> user.getEmail().equals(TENANT_ADMIN_EMAIL)), + eq(totpTwoFaProviderConfig), eq(generatedTotpTwoFaAccountConfig)); + } + + @Test + public void testSubmitTotpTwoFaAccountConfig_validationError() throws Exception { + configureTotpTwoFaProvider(); + + loginTenantAdmin(); + + TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = new TotpTwoFactorAuthAccountConfig(); + totpTwoFaAccountConfig.setAuthUrl(null); + + String errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", totpTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("otp auth url cannot be blank"); + + totpTwoFaAccountConfig.setAuthUrl("otpauth://totp/T B: aba"); + errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", totpTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("otp auth url is invalid"); + + totpTwoFaAccountConfig.setAuthUrl("otpauth://totp/ThingsBoard%20(Tenant):tenant@thingsboard.org?issuer=ThingsBoard+%28Tenant%29&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII"); + doPost("/api/2fa/account/config/submit", totpTwoFaAccountConfig) + .andExpect(status().isOk()); + } + + @Test + public void testVerifyAndSaveTotpTwoFaAccountConfig() throws Exception { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + + loginTenantAdmin(); + + TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + + String secret = UriComponentsBuilder.fromUriString(generatedTotpTwoFaAccountConfig.getAuthUrl()).build() + .getQueryParams().getFirst("secret"); + String correctVerificationCode = new Totp(secret).now(); + + doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, generatedTotpTwoFaAccountConfig) + .andExpect(status().isOk()); + + TwoFactorAuthAccountConfig twoFaAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + assertThat(twoFaAccountConfig).isEqualTo(generatedTotpTwoFaAccountConfig); + } + + @Test + public void testVerifyAndSaveTotpTwoFaAccountConfig_incorrectVerificationCode() throws Exception { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + + loginTenantAdmin(); + + TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + + String incorrectVerificationCode = "100000"; + String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=" + incorrectVerificationCode, generatedTotpTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + } + + private TotpTwoFactorAuthAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig) throws Exception { + TwoFactorAuthAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") + .andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFactorAuthAccountConfig.class); + + assertThat(((TotpTwoFactorAuthAccountConfig) 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 (TotpTwoFactorAuthAccountConfig) generatedTwoFaAccountConfig; + } + + @Test + public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { + testVerifyAndSaveTotpTwoFaAccountConfig(); + assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), + TotpTwoFactorAuthAccountConfig.class)).isNotNull(); + + loginSysAdmin(); + + saveProvidersConfigs(); + + assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)) + .isNullOrEmpty(); + } + + @Test + public void testGenerateSmsTwoFaAccountConfig() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); + doPost("/api/2fa/account/config/generate?providerType=SMS") + .andExpect(status().isOk()); + } + + @Test + public void testSubmitSmsTwoFaAccountConfig() throws Exception { + String verificationMessageTemplate = "Here is your verification code: ${verificationCode}"; + configureSmsTwoFaProvider(verificationMessageTemplate); + + loginTenantAdmin(); + + SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + smsTwoFaAccountConfig.setPhoneNumber("+38054159785"); + + doPost("/api/2fa/account/config/submit", smsTwoFaAccountConfig).andExpect(status().isOk()); + + String verificationCode = cacheManager.getCache(CacheConstants.TWO_FA_VERIFICATION_CODES_CACHE) + .get(tenantAdminUserId, OtpBasedTwoFactorAuthProvider.Otp.class).getValue(); + + verify(smsService).sendSms(eq(tenantId), any(), argThat(phoneNumbers -> { + return phoneNumbers[0].equals(smsTwoFaAccountConfig.getPhoneNumber()); + }), eq("Here is your verification code: " + verificationCode)); + } + + @Test + public void testSubmitSmsTwoFaAccountConfig_validationError() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); + + SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + String blankPhoneNumber = ""; + smsTwoFaAccountConfig.setPhoneNumber(blankPhoneNumber); + + String errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", smsTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("phone number cannot be blank"); + + String nonE164PhoneNumber = "8754868"; + smsTwoFaAccountConfig.setPhoneNumber(nonE164PhoneNumber); + + errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", smsTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("phone number is not of E.164 format"); + } + + @Test + public void testVerifyAndSaveSmsTwoFaAccountConfig() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); + + loginTenantAdmin(); + + SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + smsTwoFaAccountConfig.setPhoneNumber("+38051889445"); + + ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); + doPost("/api/2fa/account/config/submit", smsTwoFaAccountConfig).andExpect(status().isOk()); + + verify(smsService).sendSms(eq(tenantId), any(), argThat(phoneNumbers -> { + return phoneNumbers[0].equals(smsTwoFaAccountConfig.getPhoneNumber()); + }), verificationCodeCaptor.capture()); + + String correctVerificationCode = verificationCodeCaptor.getValue(); + doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, smsTwoFaAccountConfig) + .andExpect(status().isOk()); + + TwoFactorAuthAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + assertThat(accountConfig).isEqualTo(smsTwoFaAccountConfig); + } + + @Test + public void testVerifyAndSaveSmsTwoFaAccountConfig_incorrectVerificationCode() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); + + loginTenantAdmin(); + + SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + smsTwoFaAccountConfig.setPhoneNumber("+38051889445"); + + String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=100000", smsTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + } + + @Test + public void testVerifyAndSaveSmsTwoFaAccountConfig_differentAccountConfigs() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); + loginTenantAdmin(); + + SmsTwoFactorAuthAccountConfig initialSmsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + initialSmsTwoFaAccountConfig.setPhoneNumber("+38051889445"); + + ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); + doPost("/api/2fa/account/config/submit", initialSmsTwoFaAccountConfig).andExpect(status().isOk()); + + verify(smsService).sendSms(eq(tenantId), any(), argThat(phoneNumbers -> { + return phoneNumbers[0].equals(initialSmsTwoFaAccountConfig.getPhoneNumber()); + }), verificationCodeCaptor.capture()); + + String correctVerificationCode = verificationCodeCaptor.getValue(); + + SmsTwoFactorAuthAccountConfig anotherSmsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + anotherSmsTwoFaAccountConfig.setPhoneNumber("+38111111111"); + String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, anotherSmsTwoFaAccountConfig) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + + doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, initialSmsTwoFaAccountConfig) + .andExpect(status().isOk()); + TwoFactorAuthAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig); + } + + private TotpTwoFactorAuthProviderConfig configureTotpTwoFaProvider() throws Exception { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + saveProvidersConfigs(totpTwoFaProviderConfig); + return totpTwoFaProviderConfig; + } + + private SmsTwoFactorAuthProviderConfig configureSmsTwoFaProvider(String verificationMessageTemplate) throws Exception { + SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + smsTwoFaProviderConfig.setSmsVerificationMessageTemplate(verificationMessageTemplate); + smsTwoFaProviderConfig.setVerificationCodeLifetime(60); + + saveProvidersConfigs(smsTwoFaProviderConfig); + return smsTwoFaProviderConfig; + } + + private void saveProvidersConfigs(TwoFactorAuthProviderConfig... providerConfigs) throws Exception { + TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + + twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); + doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); + } + + @Test + public void testIsTwoFaEnabled() throws ThingsboardException { + SmsTwoFactorAuthAccountConfig accountConfig = new SmsTwoFactorAuthAccountConfig(); + accountConfig.setPhoneNumber("+380505050"); + twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + + assertThat(twoFactorAuthConfigManager.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); + } + + @Test + public void testDeleteTwoFaAccountConfig() throws Exception { + SmsTwoFactorAuthAccountConfig accountConfig = new SmsTwoFactorAuthAccountConfig(); + accountConfig.setPhoneNumber("+380505050"); + twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + + loginTenantAdmin(); + + TwoFactorAuthAccountConfig savedAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + assertThat(savedAccountConfig).isEqualTo(accountConfig); + + doDelete("/api/2fa/account/config").andExpect(status().isOk()); + + assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)) + .isNullOrEmpty(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index 69bd49f6f5..4a9888ada1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -15,242 +15,15 @@ */ package org.thingsboard.server.controller; -import org.jboss.aerogear.security.otp.Totp; -import org.jboss.aerogear.security.otp.api.Base32; -import org.junit.Before; -import org.junit.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; -import org.springframework.web.util.UriComponents; -import org.springframework.web.util.UriComponentsBuilder; -import org.thingsboard.rule.engine.api.SmsService; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; -import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.SmsTwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.TotpTwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; -import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFactorAuthProvider; - -import java.util.Arrays; -import java.util.Collections; -import java.util.stream.Collectors; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -// TODO [viacheslav]: test validation for all account configs, provider configs and two factor auth settings -// TODO [viacheslav]: test authentication details, log login action, last login ts, rate limiting, user blocking, etc +/* +* TODO [viacheslav] +* check validation of the verification code +* test rate limits +* test code expiration +* test pre-verification token lifetime +* test user blocking +* test log login action, lastLoginTs, and authentication details +* */ public abstract class TwoFactorAuthTest extends AbstractControllerTest { - @SpyBean - private TotpTwoFactorAuthProvider totpTwoFactorAuthProvider; - @MockBean - private SmsService smsService; - - @Before - public void beforeEach() throws Exception { - loginSysAdmin(); - } - - - @Test - public void testSaveTwoFaSettings() throws Exception { - loginSysAdmin(); - testSaveTestTwoFaSettings(); - - loginTenantAdmin(); - testSaveTestTwoFaSettings(); - } - - private void testSaveTestTwoFaSettings() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); - totpTwoFaProviderConfig.setIssuerName("tb"); - SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); - smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); - - saveProvidersConfigs(totpTwoFaProviderConfig, smsTwoFaProviderConfig); - - TwoFactorAuthSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); - - assertThat(savedTwoFaSettings.getProviders()).hasSize(2); - assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig); - } - - @Test - public void testSaveTotpTwoFaProviderConfig_validationError() throws Exception { - TotpTwoFactorAuthProviderConfig invalidTotpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); - invalidTotpTwoFaProviderConfig.setIssuerName(" "); - - String errorResponse = saveTwoFaSettingsAndGetError(invalidTotpTwoFaProviderConfig); - assertThat(errorResponse).containsIgnoringCase("issuer name must not be blank"); - } - - @Test - public void testSaveSmsTwoFaProviderConfig_validationError() throws Exception { - SmsTwoFactorAuthProviderConfig invalidSmsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); - invalidSmsTwoFaProviderConfig.setSmsVerificationMessageTemplate("does not contain verification code"); - - String errorResponse = saveTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); - assertThat(errorResponse).containsIgnoringCase("must contain verification code"); - } - - private String saveTwoFaSettingsAndGetError(TwoFactorAuthProviderConfig invalidTwoFaProviderConfig) throws Exception { - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); - twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); - - return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) - .andExpect(status().isBadRequest())); - } - - @Test - public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception { - configureSmsTwoFaProvider("${verificationCode}"); - - loginTenantAdmin(); - - TwoFactorAuthProviderType notConfiguredProviderType = TwoFactorAuthProviderType.TOTP; - String errorMessage = getErrorMessage(doPost("/api/2fa/account/config/generate?providerType=" + notConfiguredProviderType) - .andExpect(status().isBadRequest())); - assertThat(errorMessage).containsIgnoringCase("provider is not configured"); - - TotpTwoFactorAuthAccountConfig notConfiguredProviderAccountConfig = new TotpTwoFactorAuthAccountConfig(); - notConfiguredProviderAccountConfig.setAuthUrl("aba"); - errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", notConfiguredProviderAccountConfig)); - assertThat(errorMessage).containsIgnoringCase("provider is not configured"); - } - - @Test - public void testGenerateTotpTwoFaAccountConfig() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); - - loginTenantAdmin(); - - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)).isNullOrEmpty(); - generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); - } - - @Test - public void testSubmitTotpTwoFaAccountConfig() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); - - loginTenantAdmin(); - - TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); - doPost("/api/2fa/account/config/submit", generatedTotpTwoFaAccountConfig).andExpect(status().isOk()); - verify(totpTwoFactorAuthProvider).prepareVerificationCode(argThat(user -> user.getEmail().equals(TENANT_ADMIN_EMAIL)), - eq(totpTwoFaProviderConfig), eq(generatedTotpTwoFaAccountConfig)); - } - - @Test - public void testVerifyAndSaveTotpTwoFaAccountConfig() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); - - loginTenantAdmin(); - - TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); - - String secret = UriComponentsBuilder.fromUriString(generatedTotpTwoFaAccountConfig.getAuthUrl()).build() - .getQueryParams().getFirst("secret"); - String correctVerificationCode = new Totp(secret).now(); - - doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, generatedTotpTwoFaAccountConfig) - .andExpect(status().isOk()); - - TwoFactorAuthAccountConfig twoFaAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); - assertThat(twoFaAccountConfig).isEqualTo(generatedTotpTwoFaAccountConfig); - } - - @Test - public void testVerifyAndSaveTotpTwoFaAccountConfig_incorrectVerificationCode() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); - - loginTenantAdmin(); - - TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); - - String incorrectVerificationCode = "100000"; - String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=" + incorrectVerificationCode, generatedTotpTwoFaAccountConfig) - .andExpect(status().isBadRequest())); - - assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); - } - - private TotpTwoFactorAuthAccountConfig generateTotpTwoFaAccountConfig(TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig) throws Exception { - TwoFactorAuthAccountConfig generatedTwoFaAccountConfig = readResponse(doPost("/api/2fa/account/config/generate?providerType=TOTP") - .andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); - assertThat(generatedTwoFaAccountConfig).isInstanceOf(TotpTwoFactorAuthAccountConfig.class); - - assertThat(((TotpTwoFactorAuthAccountConfig) 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 (TotpTwoFactorAuthAccountConfig) generatedTwoFaAccountConfig; - } - - - @Test - public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { - testVerifyAndSaveTotpTwoFaAccountConfig(); - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), - TotpTwoFactorAuthAccountConfig.class)).isNotNull(); - - loginSysAdmin(); - - saveProvidersConfigs(); - - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)) - .isNullOrEmpty(); - } - -// @Test -// public void testSubmitSmsTwoFaAccountConfig() throws Exception { -// String verificationMessageTemplate = "Here is your verification code: ${verificationCode}"; -// SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = configureSmsTwoFaProvider(verificationMessageTemplate); -// -// SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); -// smsTwoFaAccountConfig.setPhoneNumber("+38054159785"); -// -// String verificationCode = ""; ? -// -// verify(smsService).sendSms(eq(tenantId), any(), argThat(phoneNumbers -> { -// return phoneNumbers[0].equals(smsTwoFaAccountConfig.getPhoneNumber()) -// }), eq("Here is your verification code: " + verificationCode)); -// } - - - - private TotpTwoFactorAuthProviderConfig configureTotpTwoFaProvider() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); - totpTwoFaProviderConfig.setIssuerName("tb"); - - saveProvidersConfigs(totpTwoFaProviderConfig); - return totpTwoFaProviderConfig; - } - - private SmsTwoFactorAuthProviderConfig configureSmsTwoFaProvider(String verificationMessageTemplate) throws Exception { - SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); - smsTwoFaProviderConfig.setSmsVerificationMessageTemplate(verificationMessageTemplate); - - saveProvidersConfigs(smsTwoFaProviderConfig); - return smsTwoFaProviderConfig; - } - - private void saveProvidersConfigs(TwoFactorAuthProviderConfig... providerConfigs) throws Exception { - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); - twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); - doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); - } - } diff --git a/application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthConfigSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthConfigSqlTest.java new file mode 100644 index 0000000000..591f995e9d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthConfigSqlTest.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2022 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller.sql; + +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.controller.TwoFactorAuthConfigTest; + +@DaoSqlTest +public class TwoFactorAuthConfigSqlTest extends TwoFactorAuthConfigTest { +}