From e2c9a5ffdf2c86f3267d752398a0e024113ebca7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 10 Mar 2022 17:59:59 +0200 Subject: [PATCH 01/92] TOTP and SMS 2FA providers; 2FA set-up API --- application/pom.xml | 16 ++ .../controller/TwoFactorAuthController.java | 128 +++++++++++++ .../auth/mfa/TwoFactorAuthService.java | 179 ++++++++++++++++++ .../mfa/config/TwoFactorAuthSettings.java | 40 ++++ .../SmsTwoFactorAuthAccountConfig.java | 34 ++++ .../TotpTwoFactorAuthAccountConfig.java | 34 ++++ .../account/TwoFactorAuthAccountConfig.java | 37 ++++ .../SmsTwoFactorAuthProviderConfig.java | 36 ++++ .../TotpTwoFactorAuthProviderConfig.java | 34 ++++ .../provider/TwoFactorAuthProviderConfig.java | 37 ++++ .../mfa/provider/TwoFactorAuthProvider.java | 34 ++++ .../provider/TwoFactorAuthProviderType.java | 21 ++ .../impl/SmsTwoFactorAuthProvider.java | 110 +++++++++++ .../impl/TotpTwoFactorAuthProvider.java | 73 +++++++ .../common/util/TripleFunction.java | 21 ++ .../dao/service/ConstraintValidator.java | 2 +- pom.xml | 6 + .../rule/engine/api/util/TbNodeUtils.java | 8 + 18 files changed, 849 insertions(+), 1 deletion(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java diff --git a/application/pom.xml b/application/pom.xml index efbc35dc6c..303cd6013b 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -337,6 +337,22 @@ Java-WebSocket test + + org.jboss.aerogear + aerogear-otp-java + + + + com.google.zxing + core + 3.3.0 + + + com.google.zxing + javase + 3.3.0 + + diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java new file mode 100644 index 0000000000..ec07ae2058 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -0,0 +1,128 @@ +/** + * 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 com.google.zxing.BarcodeFormat; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +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.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class TwoFactorAuthController extends BaseController { + + private final TwoFactorAuthService twoFactorAuthService; + private final JwtTokenFactory tokenFactory; + + + @GetMapping("/2fa/account/config") + @PreAuthorize("isAuthenticated()") + public TwoFactorAuthAccountConfig getTwoFactorAuthAccountConfig() throws ThingsboardException { + SecurityUser user = getCurrentUser(); + + return twoFactorAuthService.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); + } + + @PostMapping("/2fa/account/config/generate") + @PreAuthorize("isAuthenticated()") + public TwoFactorAuthAccountConfig generateTwoFactorAuthAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + + return twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), providerType, + (provider, providerConfig) -> { + return provider.generateNewAccountConfig(user, providerConfig); + }); + } + + // temporary endpoint for testing purposes + @PostMapping("/2fa/account/config/generate/qr") + @PreAuthorize("isAuthenticated()") + public void generateTwoFactorAuthAccountConfigWithQr(@RequestParam TwoFactorAuthProviderType providerType, HttpServletResponse response) throws Exception { + TwoFactorAuthAccountConfig config = generateTwoFactorAuthAccountConfig(providerType); + if (providerType == TwoFactorAuthProviderType.TOTP) { + BitMatrix qr = new QRCodeWriter().encode(((TotpTwoFactorAuthAccountConfig) config).getAuthUrl(), BarcodeFormat.QR_CODE, 200, 200); + try (ServletOutputStream outputStream = response.getOutputStream()) { + MatrixToImageWriter.writeToStream(qr, "PNG", outputStream); + } + } + response.setHeader("body", JacksonUtil.toString(config)); + } + + @PostMapping("/2fa/account/config/submit") + @PreAuthorize("isAuthenticated()") + public void submitTwoFactorAuthAccountConfig(@RequestBody TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + + twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), + (provider, providerConfig) -> { + provider.prepareVerificationCode(user, providerConfig, accountConfig); + }); + } + + @PostMapping("/2fa/account/config") + @PreAuthorize("isAuthenticated()") + public void verifyAndSaveTwoFactorAuthAccountConfig(@RequestBody TwoFactorAuthAccountConfig accountConfig, + @RequestParam String verificationCode) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + + boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), + (provider, providerConfig) -> { + return provider.checkVerificationCode(user, verificationCode, accountConfig); + }); + + if (verificationSuccess) { + twoFactorAuthService.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + } else { + throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.INVALID_ARGUMENTS); + } + } + + + @GetMapping("/2fa/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public TwoFactorAuthSettings getTwoFactorAuthSettings() throws ThingsboardException { + return twoFactorAuthService.getTwoFaSettings(getTenantId()).orElse(null); + } + + @PostMapping("/2fa/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public void saveTwoFactorAuthSettings(@RequestBody TwoFactorAuthSettings twoFactorAuthSettings) throws ThingsboardException { + twoFactorAuthService.saveTwoFaSettings(getTenantId(), twoFactorAuthSettings); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java new file mode 100644 index 0000000000..51d4af28fd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -0,0 +1,179 @@ +/** + * 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.service.security.auth.mfa; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.TripleFunction; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.User; +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.id.UserId; +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.AdminSettingsService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import java.util.Collection; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; + +@Service +@RequiredArgsConstructor +public class TwoFactorAuthService { + + private final UserService userService; + private final AdminSettingsService adminSettingsService; + private final AttributesService attributesService; + private final Map> providers = new EnumMap<>(TwoFactorAuthProviderType.class); + + protected static final String TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY = "twoFaConfig"; + protected static final String TWO_FACTOR_AUTH_SETTINGS_KEY = "twoFaSettings"; + + + @Autowired + private void setProviders(Collection> providers) { + providers.forEach(provider -> { + this.providers.put(provider.getType(), provider); + }); + } + + private Optional> getTwoFaProvider(TwoFactorAuthProviderType providerType) { + return Optional.of((TwoFactorAuthProvider) providers.get(providerType)); + } + + private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { + return getTwoFaSettings(tenantId) + .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) + .map(providerConfig -> (C) providerConfig); + } + + + public R processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, BiFunction, TwoFactorAuthProviderConfig, R> function) throws ThingsboardException { + TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(tenantId, providerType) + .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); + TwoFactorAuthProvider provider = getTwoFaProvider(providerType) + .orElseThrow(() -> new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.ITEM_NOT_FOUND)); + + return function.apply(provider, providerConfig); + } + + public void processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, BiConsumer, TwoFactorAuthProviderConfig> function) throws ThingsboardException { + processByTwoFaProvider(tenantId, providerType, (provider, providerConfig) -> { + function.accept(provider, providerConfig); + return null; + }); + } + + public R processByTwoFaProvider(TenantId tenantId, UserId userId, TripleFunction, TwoFactorAuthProviderConfig, TwoFactorAuthAccountConfig, R> function) throws ThingsboardException { + TwoFactorAuthAccountConfig accountConfig = getTwoFaAccountConfig(tenantId, userId) + .orElseThrow(() -> new ThingsboardException("2FA is not configured for user", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); + + TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) + .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); + TwoFactorAuthProvider provider = getTwoFaProvider(accountConfig.getProviderType()) + .orElseThrow(() -> new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.ITEM_NOT_FOUND)); + + return function.apply(provider, providerConfig, accountConfig); + } + + + public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { + User user = userService.findUserById(tenantId, userId); + return Optional.ofNullable(user.getAdditionalInfo()) + .flatMap(additionalInfo -> Optional.ofNullable(additionalInfo.get(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY)).filter(jsonNode -> !jsonNode.isNull())) + .map(jsonNode -> JacksonUtil.treeToValue(jsonNode, TwoFactorAuthAccountConfig.class)) + .filter(twoFactorAuthAccountConfig -> { + return getTwoFaProviderConfig(tenantId, twoFactorAuthAccountConfig.getProviderType()).isPresent(); + }); + } + + public void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + ConstraintValidator.validateFields(accountConfig); + getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) + .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); + + User user = userService.findUserById(tenantId, userId); + ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) + .orElseGet(JacksonUtil::newObjectNode); + additionalInfo.set(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY, JacksonUtil.valueToTree(accountConfig)); + user.setAdditionalInfo(additionalInfo); + + userService.saveUser(user); + } + + public void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId) { + User user = userService.findUserById(tenantId, userId); + ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) + .orElseGet(JacksonUtil::newObjectNode); + additionalInfo.remove(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY); + user.setAdditionalInfo(additionalInfo); + + userService.saveUser(user); + } + + + @SneakyThrows + public Optional getTwoFaSettings(TenantId tenantId) { + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, 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)); + } + } + + @SneakyThrows + public void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings) { + ConstraintValidator.validateFields(twoFactorAuthSettings); + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) + .orElseGet(() -> { + AdminSettings newSettings = new AdminSettings(); + newSettings.setKey(TWO_FACTOR_AUTH_SETTINGS_KEY); + return newSettings; + }); + settings.setJsonValue(JacksonUtil.valueToTree(twoFactorAuthSettings)); + adminSettingsService.saveAdminSettings(tenantId, settings); + } else { + attributesService.save(TenantId.SYS_TENANT_ID, tenantId, DataConstants.SERVER_SCOPE, Collections.singletonList( + new BaseAttributeKvEntry(new JsonDataEntry(TWO_FACTOR_AUTH_SETTINGS_KEY, JacksonUtil.toString(twoFactorAuthSettings)), System.currentTimeMillis()) + )).get(); + } + } + +} 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 new file mode 100644 index 0000000000..a0620ec96e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java @@ -0,0 +1,40 @@ +/** + * 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.service.security.auth.mfa.config; + +import lombok.Data; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import javax.validation.Valid; +import java.util.List; +import java.util.Optional; + +@Data +public class TwoFactorAuthSettings { + + private boolean useSystemTwoFactorAuthSettings; + @Valid + private List providers; + + public Optional getProviderConfig(TwoFactorAuthProviderType providerType) { + return Optional.ofNullable(providers) + .flatMap(providersConfigs -> providersConfigs.stream() + .filter(providerConfig -> providerConfig.getProviderType() == providerType) + .findFirst()); + } + +} 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 new file mode 100644 index 0000000000..6f3ef06775 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java @@ -0,0 +1,34 @@ +/** + * 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.service.security.auth.mfa.config.account; + +import lombok.Data; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import javax.validation.constraints.NotBlank; + +@Data +public class SmsTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { + + @NotBlank + private String phoneNumber; + + @Override + public TwoFactorAuthProviderType getProviderType() { + return TwoFactorAuthProviderType.SMS; + } + +} 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 new file mode 100644 index 0000000000..a48cf162a0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java @@ -0,0 +1,34 @@ +/** + * 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.service.security.auth.mfa.config.account; + +import lombok.Data; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import javax.validation.constraints.NotBlank; + +@Data +public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { + + @NotBlank + private String authUrl; + + @Override + public TwoFactorAuthProviderType getProviderType() { + return TwoFactorAuthProviderType.TOTP; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java new file mode 100644 index 0000000000..141535eea8 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java @@ -0,0 +1,37 @@ +/** + * 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.service.security.auth.mfa.config.account; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "providerType") +@JsonSubTypes({ + @JsonSubTypes.Type(value = TotpTwoFactorAuthAccountConfig.class, name = "TOTP"), + @JsonSubTypes.Type(value = SmsTwoFactorAuthAccountConfig.class, name = "SMS"), +}) +public interface TwoFactorAuthAccountConfig { + + @JsonIgnore + TwoFactorAuthProviderType getProviderType(); + +} 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 new file mode 100644 index 0000000000..a036e47574 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java @@ -0,0 +1,36 @@ +/** + * 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.service.security.auth.mfa.config.provider; + +import lombok.Data; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +@Data +public class SmsTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { + + @NotBlank + @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "Template must contain verification code") + private String smsVerificationMessageTemplate; + + @Override + public TwoFactorAuthProviderType getProviderType() { + return TwoFactorAuthProviderType.SMS; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java new file mode 100644 index 0000000000..2d6cc5ddf5 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java @@ -0,0 +1,34 @@ +/** + * 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.service.security.auth.mfa.config.provider; + +import lombok.Data; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import javax.validation.constraints.NotBlank; + +@Data +public class TotpTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { + + @NotBlank(message = "Issuer name must not be blank") + private String issuerName; + + @Override + public TwoFactorAuthProviderType getProviderType() { + return TwoFactorAuthProviderType.TOTP; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java new file mode 100644 index 0000000000..a86bcee222 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java @@ -0,0 +1,37 @@ +/** + * 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.service.security.auth.mfa.config.provider; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "providerType") +@JsonSubTypes({ + @JsonSubTypes.Type(value = TotpTwoFactorAuthProviderConfig.class, name = "TOTP"), + @JsonSubTypes.Type(value = SmsTwoFactorAuthProviderConfig.class, name = "SMS"), +}) +public interface TwoFactorAuthProviderConfig { + + @JsonIgnore + TwoFactorAuthProviderType getProviderType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java new file mode 100644 index 0000000000..82122a9163 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java @@ -0,0 +1,34 @@ +/** + * 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.service.security.auth.mfa.provider; + +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface TwoFactorAuthProvider { + + A generateNewAccountConfig(User user, C providerConfig); + + default void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) {} + + boolean checkVerificationCode(SecurityUser user, String verificationCode, A accountConfig); + + + TwoFactorAuthProviderType getType(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java new file mode 100644 index 0000000000..9a4a3672a7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java @@ -0,0 +1,21 @@ +/** + * 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.service.security.auth.mfa.provider; + +public enum TwoFactorAuthProviderType { + TOTP, + SMS +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java new file mode 100644 index 0000000000..7d4a0b9ab7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java @@ -0,0 +1,110 @@ +/** + * 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.service.security.auth.mfa.provider.impl; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.service.ConstraintValidator; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.config.account.SmsTwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.SmsTwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.Collections; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@TbCoreComponent +public class SmsTwoFactorAuthProvider implements TwoFactorAuthProvider { + + private final SmsService smsService; + private final TimeseriesService timeseriesService; + + @Override + public SmsTwoFactorAuthAccountConfig generateNewAccountConfig(User user, SmsTwoFactorAuthProviderConfig providerConfig) { + return new SmsTwoFactorAuthAccountConfig(); + } + + @Override + @SneakyThrows // fixme + public void prepareVerificationCode(SecurityUser user, SmsTwoFactorAuthProviderConfig providerConfig, SmsTwoFactorAuthAccountConfig accountConfig) { + ConstraintValidator.validateFields(accountConfig); + + String verificationCode = RandomStringUtils.randomNumeric(6); + saveVerificationCode(user, verificationCode); + + String phoneNumber = accountConfig.getPhoneNumber(); + + Map data = Map.of( + "verificationCode", verificationCode, + "userEmail", user.getEmail() + ); + String message = TbNodeUtils.processTemplate(providerConfig.getSmsVerificationMessageTemplate(), data); + + smsService.sendSms(user.getTenantId(), user.getCustomerId(), new String[]{phoneNumber}, message); + } + + @Override + public boolean checkVerificationCode(SecurityUser user, String verificationCode, SmsTwoFactorAuthAccountConfig accountConfig) { + if (verificationCode.equals(getVerificationCode(user))) { + removeVerificationCode(user); + return true; + } else { + return false; + } + } + + + @SneakyThrows + private void saveVerificationCode(SecurityUser user, String verificationCode) { + timeseriesService.save(user.getTenantId(), user.getId(), + new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry("twoFaVerificationCode:" + user.getSessionId(), verificationCode)) + ).get(); + } + + @SneakyThrows + private String getVerificationCode(SecurityUser user) { + return timeseriesService.findLatest(user.getTenantId(), user.getId(), + Collections.singletonList("twoFaVerificationCode:" + user.getSessionId())).get().stream().findFirst() + .map(codeTs -> codeTs.getStrValue().get()) + .orElse(null); + } + + private void removeVerificationCode(SecurityUser user) { + timeseriesService.remove(user.getTenantId(), user.getId(), Collections.singletonList( + new BaseDeleteTsKvQuery("twoFaVerificationCode:" + user.getSessionId(), 0, System.currentTimeMillis()) + )); + } + + + @Override + public TwoFactorAuthProviderType getType() { + return TwoFactorAuthProviderType.SMS; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java new file mode 100644 index 0000000000..d7747521c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java @@ -0,0 +1,73 @@ +/** + * 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.service.security.auth.mfa.provider.impl; + +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.apache.commons.lang3.RandomUtils; +import org.apache.http.client.utils.URIBuilder; +import org.jboss.aerogear.security.otp.Totp; +import org.jboss.aerogear.security.otp.api.Base32; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TotpTwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; + +@Service +@RequiredArgsConstructor +@TbCoreComponent +public class TotpTwoFactorAuthProvider implements TwoFactorAuthProvider { + + @Override + public final TotpTwoFactorAuthAccountConfig generateNewAccountConfig(User user, TotpTwoFactorAuthProviderConfig providerConfig) { + TotpTwoFactorAuthAccountConfig config = new TotpTwoFactorAuthAccountConfig(); + String secretKey = generateSecretKey(); + config.setAuthUrl(getTotpAuthUrl(user, secretKey, providerConfig)); + return config; + } + + @Override + public final boolean checkVerificationCode(SecurityUser user, String verificationCode, TotpTwoFactorAuthAccountConfig accountConfig) { + String secretKey = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build().getQueryParams().getFirst("secret"); + return new Totp(secretKey).verify(verificationCode); + } + + @SneakyThrows + private String getTotpAuthUrl(User user, String secretKey, TotpTwoFactorAuthProviderConfig providerConfig) { + URIBuilder uri = new URIBuilder() + .setScheme("otpauth") + .setHost("totp") + .setParameter("issuer", providerConfig.getIssuerName()) + .setPath("/" + providerConfig.getIssuerName() + ":" + user.getEmail()) + .setParameter("secret", secretKey); + return uri.build().toASCIIString(); + } + + private String generateSecretKey() { + return Base32.encode(RandomUtils.nextBytes(20)); + } + + @Override + public TwoFactorAuthProviderType getType() { + return TwoFactorAuthProviderType.TOTP; + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java b/common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java new file mode 100644 index 0000000000..5134703cd3 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java @@ -0,0 +1,21 @@ +/** + * 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.common.util; + +@FunctionalInterface +public interface TripleFunction { + R apply(A a, B b, C c); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index 0e8b71abee..da7537ae75 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -46,7 +46,7 @@ public class ConstraintValidator { .distinct() .collect(Collectors.toList()); if (!validationErrors.isEmpty()) { - throw new ValidationException("Validation error: " + String.join(", ", validationErrors)); + throw new ValidationException(String.join(", ", validationErrors)); } } diff --git a/pom.xml b/pom.xml index 295e01f2d3..d339c644c9 100755 --- a/pom.xml +++ b/pom.xml @@ -132,6 +132,7 @@ 1.16.0 1.12 + 1.0.0 @@ -1872,6 +1873,11 @@ ${zeroturnaround.version} test + + org.jboss.aerogear + aerogear-otp-java + ${aerogear-otp.version} + diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 9b0f0e9f0a..32ee585820 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -101,6 +101,14 @@ public class TbNodeUtils { return result; } + public static String processTemplate(String template, Map data) { + String result = template; + for (Map.Entry kv : data.entrySet()) { + result = processVar(result, kv.getKey(), kv.getValue()); + } + return result; + } + private static String processVar(String pattern, String key, String val) { return pattern.replace(formatMetadataVarTemplate(key), val); } From 1ad769048cee05f287f1efd7efcea39a74a8bfbe Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 10 Mar 2022 18:15:42 +0200 Subject: [PATCH 02/92] 2FA support for platform authentication --- .../server/config/JwtSettings.java | 42 ++---- .../controller/TwoFactorAuthController.java | 19 +++ .../RefreshTokenAuthenticationProvider.java | 1 + ...RestAwareAuthenticationSuccessHandler.java | 46 ++++--- .../service/security/model/JwtTokenPair.java | 2 + .../service/security/model/SecurityUser.java | 11 ++ .../security/model/token/AccessJwtToken.java | 12 +- .../security/model/token/JwtTokenFactory.java | 128 +++++++++++------- .../src/main/resources/thingsboard.yml | 5 + .../common/data/security/Authority.java | 5 +- 10 files changed, 157 insertions(+), 114 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/config/JwtSettings.java b/application/src/main/java/org/thingsboard/server/config/JwtSettings.java index 31dbfc68e8..1c15c4c150 100644 --- a/application/src/main/java/org/thingsboard/server/config/JwtSettings.java +++ b/application/src/main/java/org/thingsboard/server/config/JwtSettings.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.config; +import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.security.model.JwtToken; -@Configuration +@Component @ConfigurationProperties(prefix = "security.jwt") +@Data public class JwtSettings { /** * {@link JwtToken} will expire after this time. @@ -42,34 +44,10 @@ public class JwtSettings { */ private Integer refreshTokenExpTime; - public Integer getRefreshTokenExpTime() { - return refreshTokenExpTime; - } - - public void setRefreshTokenExpTime(Integer refreshTokenExpTime) { - this.refreshTokenExpTime = refreshTokenExpTime; - } - - public Integer getTokenExpirationTime() { - return tokenExpirationTime; - } - - public void setTokenExpirationTime(Integer tokenExpirationTime) { - this.tokenExpirationTime = tokenExpirationTime; - } - - public String getTokenIssuer() { - return tokenIssuer; - } - public void setTokenIssuer(String tokenIssuer) { - this.tokenIssuer = tokenIssuer; - } - - public String getTokenSigningKey() { - return tokenSigningKey; - } + /** + * Issued when 2FA is being used. + * Valid only for 2FA verification code checking. + * */ + private Integer preVerificationTokenExpirationTime; - public void setTokenSigningKey(String tokenSigningKey) { - this.tokenSigningKey = tokenSigningKey; - } -} \ No newline at end of file +} 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 ec07ae2058..1355337127 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -35,6 +35,7 @@ import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSett 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.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; @@ -125,4 +126,22 @@ public class TwoFactorAuthController extends BaseController { twoFactorAuthService.saveTwoFaSettings(getTenantId(), twoFactorAuthSettings); } + + @PostMapping("/auth/2fa/verification/check") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + + boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), + (provider, providerConfig, accountConfig) -> { + return provider.checkVerificationCode(user, verificationCode, accountConfig); + }); + + if (verificationSuccess) { + return tokenFactory.createTokenPair(user); + } else { + throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); + } + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 8003cfd012..b744adcdaf 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -66,6 +66,7 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide } else { securityUser = authenticateByPublicId(principal.getValue()); } + securityUser.setSessionId(unsafeUser.getSessionId()); if (tokenOutdatingService.isOutdated(rawAccessToken, securityUser.getId())) { throw new CredentialsExpiredException("Token is outdated"); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index 057577fdca..ca2f15953a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -16,15 +16,18 @@ package org.thingsboard.server.service.security.auth.rest; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Autowired; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.web.WebAttributes; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; @@ -33,37 +36,44 @@ import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; -import java.util.HashMap; -import java.util.Map; +import java.util.Optional; @Component(value = "defaultAuthenticationSuccessHandler") +@RequiredArgsConstructor +@Slf4j public class RestAwareAuthenticationSuccessHandler implements AuthenticationSuccessHandler { private final ObjectMapper mapper; private final JwtTokenFactory tokenFactory; - private final RefreshTokenRepository refreshTokenRepository; - - @Autowired - public RestAwareAuthenticationSuccessHandler(final ObjectMapper mapper, final JwtTokenFactory tokenFactory, final RefreshTokenRepository refreshTokenRepository) { - this.mapper = mapper; - this.tokenFactory = tokenFactory; - this.refreshTokenRepository = refreshTokenRepository; - } + private final TwoFactorAuthService twoFactorAuthService; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { SecurityUser securityUser = (SecurityUser) authentication.getPrincipal(); - JwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser); - JwtToken refreshToken = refreshTokenRepository.requestRefreshToken(securityUser); + JwtTokenPair tokenPair; - Map tokenMap = new HashMap(); - tokenMap.put("token", accessToken.getToken()); - tokenMap.put("refreshToken", refreshToken.getToken()); + Optional twoFaAccountConfig = twoFactorAuthService.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()); + if (twoFaAccountConfig.isPresent()) { + try { + twoFactorAuthService.processByTwoFaProvider(securityUser.getTenantId(), twoFaAccountConfig.get().getProviderType(), + (provider, providerConfig) -> { + provider.prepareVerificationCode(securityUser, providerConfig, twoFaAccountConfig.get()); + }); + tokenPair = new JwtTokenPair(); + tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser).getToken()); + } catch (Exception e) { + log.error("Failed to process 2FA for user {}. Falling back to plain auth", securityUser.getId(), e); + tokenPair = tokenFactory.createTokenPair(securityUser); + } + } else { + tokenPair = tokenFactory.createTokenPair(securityUser); + } response.setStatus(HttpStatus.OK.value()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - mapper.writeValue(response.getWriter(), tokenMap); + + mapper.writeValue(response.getWriter(), tokenPair); clearAuthenticationAttributes(request); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java b/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java index 7a9c339fc2..57964570c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java @@ -19,10 +19,12 @@ import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; @ApiModel(value = "JWT Token Pair") @Data @AllArgsConstructor +@NoArgsConstructor public class JwtTokenPair { @ApiModelProperty(position = 1, value = "The JWT Access Token. Used to perform API calls.", example = "AAB254FF67D..") diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java b/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java index 380d6537f2..3698282489 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.UserId; import java.util.Collection; +import java.util.UUID; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -31,6 +32,7 @@ public class SecurityUser extends User { private Collection authorities; private boolean enabled; private UserPrincipal userPrincipal; + private String sessionId; public SecurityUser() { super(); @@ -44,6 +46,7 @@ public class SecurityUser extends User { super(user); this.enabled = enabled; this.userPrincipal = userPrincipal; + this.sessionId = UUID.randomUUID().toString(); } public Collection getAuthorities() { @@ -71,4 +74,12 @@ public class SecurityUser extends User { this.userPrincipal = userPrincipal; } + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java index 639bd2a347..f96e0bfc33 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/AccessJwtToken.java @@ -15,25 +15,17 @@ */ package org.thingsboard.server.service.security.model.token; -import com.fasterxml.jackson.annotation.JsonIgnore; -import io.jsonwebtoken.Claims; import org.thingsboard.server.common.data.security.model.JwtToken; public final class AccessJwtToken implements JwtToken { private final String rawToken; - @JsonIgnore - private transient Claims claims; - protected AccessJwtToken(final String token, Claims claims) { - this.rawToken = token; - this.claims = claims; + public AccessJwtToken(String rawToken) { + this.rawToken = rawToken; } public String getToken() { return this.rawToken; } - public Claims getClaims() { - return claims; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index 24e49f9db0..b2bc3add26 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.security.model.token; import io.jsonwebtoken.Claims; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureAlgorithm; @@ -36,6 +37,7 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.model.JwtToken; import org.thingsboard.server.config.JwtSettings; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; +import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -58,6 +60,7 @@ public class JwtTokenFactory { private static final String IS_PUBLIC = "isPublic"; private static final String TENANT_ID = "tenantId"; private static final String CUSTOMER_ID = "customerId"; + private static final String SESSION_ID = "sessionId"; private final JwtSettings settings; @@ -70,39 +73,28 @@ public class JwtTokenFactory { * Factory method for issuing new JWT Tokens. */ public AccessJwtToken createAccessJwtToken(SecurityUser securityUser) { - if (StringUtils.isBlank(securityUser.getEmail())) - throw new IllegalArgumentException("Cannot create JWT Token without username/email"); - - if (securityUser.getAuthority() == null) + if (securityUser.getAuthority() == null) { throw new IllegalArgumentException("User doesn't have any privileges"); + } UserPrincipal principal = securityUser.getUserPrincipal(); - String subject = principal.getValue(); - Claims claims = Jwts.claims().setSubject(subject); - claims.put(SCOPES, securityUser.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList())); - claims.put(USER_ID, securityUser.getId().getId().toString()); - claims.put(FIRST_NAME, securityUser.getFirstName()); - claims.put(LAST_NAME, securityUser.getLastName()); - claims.put(ENABLED, securityUser.isEnabled()); - claims.put(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID); + + JwtBuilder jwtBuilder = setUpToken(securityUser, securityUser.getAuthorities().stream() + .map(GrantedAuthority::getAuthority).collect(Collectors.toList()), settings.getTokenExpirationTime()); + jwtBuilder.claim(FIRST_NAME, securityUser.getFirstName()) + .claim(LAST_NAME, securityUser.getLastName()) + .claim(ENABLED, securityUser.isEnabled()) + .claim(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID); if (securityUser.getTenantId() != null) { - claims.put(TENANT_ID, securityUser.getTenantId().getId().toString()); + jwtBuilder.claim(TENANT_ID, securityUser.getTenantId().getId().toString()); } if (securityUser.getCustomerId() != null) { - claims.put(CUSTOMER_ID, securityUser.getCustomerId().getId().toString()); + jwtBuilder.claim(CUSTOMER_ID, securityUser.getCustomerId().getId().toString()); } - ZonedDateTime currentTime = ZonedDateTime.now(); + String token = jwtBuilder.compact(); - String token = Jwts.builder() - .setClaims(claims) - .setIssuer(settings.getTokenIssuer()) - .setIssuedAt(Date.from(currentTime.toInstant())) - .setExpiration(Date.from(currentTime.plusSeconds(settings.getTokenExpirationTime()).toInstant())) - .signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey()) - .compact(); - - return new AccessJwtToken(token, claims); + return new AccessJwtToken(token); } public SecurityUser parseAccessJwtToken(RawAccessJwtToken rawAccessToken) { @@ -118,47 +110,40 @@ public class JwtTokenFactory { 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.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); - UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); - securityUser.setUserPrincipal(principal); 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) { + securityUser.setTenantId(TenantId.SYS_TENANT_ID); } - String customerId = claims.get(CUSTOMER_ID, String.class); - if (customerId != null) { - securityUser.setCustomerId(new CustomerId(UUID.fromString(customerId))); + securityUser.setSessionId(claims.get(SESSION_ID, String.class)); + + if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_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); + UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); + securityUser.setUserPrincipal(principal); + String customerId = claims.get(CUSTOMER_ID, String.class); + if (customerId != null) { + securityUser.setCustomerId(new CustomerId(UUID.fromString(customerId))); + } + } else { + securityUser.setUserPrincipal(new UserPrincipal(UserPrincipal.Type.USER_NAME, subject)); } return securityUser; } public JwtToken createRefreshToken(SecurityUser securityUser) { - if (StringUtils.isBlank(securityUser.getEmail())) { - throw new IllegalArgumentException("Cannot create JWT Token without username/email"); - } - - ZonedDateTime currentTime = ZonedDateTime.now(); - UserPrincipal principal = securityUser.getUserPrincipal(); - Claims claims = Jwts.claims().setSubject(principal.getValue()); - claims.put(SCOPES, Collections.singletonList(Authority.REFRESH_TOKEN.name())); - claims.put(USER_ID, securityUser.getId().getId().toString()); - claims.put(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID); - String token = Jwts.builder() - .setClaims(claims) - .setIssuer(settings.getTokenIssuer()) - .setId(UUID.randomUUID().toString()) - .setIssuedAt(Date.from(currentTime.toInstant())) - .setExpiration(Date.from(currentTime.plusSeconds(settings.getRefreshTokenExpTime()).toInstant())) - .signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey()) - .compact(); + String token = setUpToken(securityUser, Collections.singletonList(Authority.REFRESH_TOKEN.name()), settings.getRefreshTokenExpTime()) + .claim(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID) + .setId(UUID.randomUUID().toString()).compact(); - return new AccessJwtToken(token, claims); + return new AccessJwtToken(token); } public SecurityUser parseRefreshToken(RawAccessJwtToken rawAccessToken) { @@ -177,9 +162,41 @@ public class JwtTokenFactory { UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class)))); securityUser.setUserPrincipal(principal); + securityUser.setSessionId(claims.get(SESSION_ID, String.class)); return securityUser; } + public JwtToken createPreVerificationToken(SecurityUser user) { + String token = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), settings.getPreVerificationTokenExpirationTime()) + .claim(TENANT_ID, user.getTenantId().toString()) + .compact(); + return new AccessJwtToken(token); + } + + private JwtBuilder setUpToken(SecurityUser securityUser, List scopes, long expirationTime) { + if (StringUtils.isBlank(securityUser.getEmail())) { + throw new IllegalArgumentException("Cannot create JWT Token without username/email"); + } + + UserPrincipal principal = securityUser.getUserPrincipal(); + + Claims claims = Jwts.claims().setSubject(principal.getValue()); + claims.put(USER_ID, securityUser.getId().getId().toString()); + claims.put(SCOPES, scopes); + if (securityUser.getSessionId() != null) { + claims.put(SESSION_ID, securityUser.getSessionId()); + } + + ZonedDateTime currentTime = ZonedDateTime.now(); + + return Jwts.builder() + .setClaims(claims) + .setIssuer(settings.getTokenIssuer()) + .setIssuedAt(Date.from(currentTime.toInstant())) + .setExpiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant())) + .signWith(SignatureAlgorithm.HS512, settings.getTokenSigningKey()); + } + public Jws parseTokenClaims(JwtToken token) { try { return Jwts.parser() @@ -193,4 +210,11 @@ public class JwtTokenFactory { throw new JwtExpiredTokenException(token, "JWT Token expired", expiredEx); } } + + public JwtTokenPair createTokenPair(SecurityUser securityUser) { + JwtToken accessToken = createAccessJwtToken(securityUser); + JwtToken refreshToken = createRefreshToken(securityUser); + return new JwtTokenPair(accessToken.getToken(), refreshToken.getToken()); + } + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 51494484db..e79d8979ce 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -127,6 +127,8 @@ security: jwt: tokenExpirationTime: "${JWT_TOKEN_EXPIRATION_TIME:9000}" # Number of seconds (2.5 hours) refreshTokenExpTime: "${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800}" # Number of seconds (1 week) + # Number of seconds. Issued when 2FA is being used; valid only for checking 2FA verification code after which usual token pair is issued + preVerificationTokenExpirationTime: "${JWT_PRE_VERIFICATION_TOKEN_EXPIRATION_TIME:30}" tokenIssuer: "${JWT_TOKEN_ISSUER:thingsboard.io}" tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator @@ -425,6 +427,9 @@ caffeine: edges: timeToLiveInMinutes: "${CACHE_SPECS_EDGES_TTL:1440}" maxSize: "${CACHE_SPECS_EDGES_MAX_SIZE:10000}" + twoFaVerificationCodes: + timeToLiveInMinutes: "1" + maxSize: "100000" redis: # standalone or cluster diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java index e30d481118..06efb4240b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/Authority.java @@ -16,11 +16,12 @@ package org.thingsboard.server.common.data.security; public enum Authority { - + SYS_ADMIN(0), TENANT_ADMIN(1), CUSTOMER_USER(2), - REFRESH_TOKEN(10); + REFRESH_TOKEN(10), + PRE_VERIFICATION_TOKEN(11); private int code; From 9eb03950fa78215b3411d6e903568cdfc1b2a8fb Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 17 Mar 2022 18:49:08 +0200 Subject: [PATCH 03/92] 2FA: refactoring, tests --- .../server/controller/BaseController.java | 20 ++ .../controller/TwoFactorAuthController.java | 7 +- .../auth/mfa/TwoFactorAuthService.java | 5 +- .../impl/SmsTwoFactorAuthProvider.java | 59 ++-- ...RestAwareAuthenticationSuccessHandler.java | 3 + .../service/security/model/JwtTokenPair.java | 10 +- .../src/main/resources/thingsboard.yml | 4 +- .../server/controller/AbstractWebTest.java | 4 + .../server/controller/TwoFactorAuthTest.java | 256 ++++++++++++++++++ .../controller/sql/TwoFactorAuthSqlTest.java | 23 ++ .../server/common/data/CacheConstants.java | 1 + .../dao/service/ConstraintValidator.java | 3 +- .../service/BaseOtaPackageServiceTest.java | 2 +- 13 files changed, 352 insertions(+), 45 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthSqlTest.java diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 3ed2d4a47d..6dee3b6114 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -283,11 +283,31 @@ public abstract class BaseController { @Getter protected boolean edgesEnabled; + @ExceptionHandler(Exception.class) + public void handleControllerException(Exception e, HttpServletResponse response) { + ThingsboardException thingsboardException = handleException(e); + handleThingsboardException(thingsboardException, response); + } + @ExceptionHandler(ThingsboardException.class) public void handleThingsboardException(ThingsboardException ex, HttpServletResponse response) { errorResponseHandler.handle(ex, response); } + /** + * @deprecated Exceptions that are not of {@link ThingsboardException} type + * are now caught and mapped to {@link ThingsboardException} by + * {@link ExceptionHandler} {@link BaseController#handleControllerException(Exception, HttpServletResponse)} + * which basically acts like the following boilerplate: + * {@code + * try { + * someExceptionThrowingMethod(); + * } catch (Exception e) { + * throw handleException(e); + * } + * } + * */ + @Deprecated ThingsboardException handleException(Exception exception) { return handleException(exception, true); } 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 1355337127..f0d50c2600 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; @@ -42,6 +43,9 @@ import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; +// FIXME: Swagger documentation +// FIXME: tests for 2FA + @RestController @RequestMapping("/api") @RequiredArgsConstructor @@ -81,7 +85,7 @@ public class TwoFactorAuthController extends BaseController { MatrixToImageWriter.writeToStream(qr, "PNG", outputStream); } } - response.setHeader("body", JacksonUtil.toString(config)); + response.setHeader("config", JacksonUtil.toString(config)); } @PostMapping("/2fa/account/config/submit") @@ -91,6 +95,7 @@ public class TwoFactorAuthController extends BaseController { twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), (provider, providerConfig) -> { + ConstraintValidator.validateFields(accountConfig); provider.prepareVerificationCode(user, providerConfig, accountConfig); }); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index 51d4af28fd..2b44fddeb5 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -46,6 +46,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.function.BiConsumer; import java.util.function.BiFunction; @@ -144,7 +145,7 @@ public class TwoFactorAuthService { } - @SneakyThrows + @SneakyThrows({InterruptedException.class, ExecutionException.class}) public Optional getTwoFaSettings(TenantId tenantId) { if (tenantId.equals(TenantId.SYS_TENANT_ID)) { return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) @@ -157,7 +158,7 @@ public class TwoFactorAuthService { } } - @SneakyThrows + @SneakyThrows({InterruptedException.class, ExecutionException.class}) public void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings) { ConstraintValidator.validateFields(twoFactorAuthSettings); if (tenantId.equals(TenantId.SYS_TENANT_ID)) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java index 7d4a0b9ab7..2dda68899b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java @@ -15,18 +15,16 @@ */ package org.thingsboard.server.service.security.auth.mfa.provider.impl; -import lombok.RequiredArgsConstructor; +import lombok.Data; import lombok.SneakyThrows; import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; -import org.thingsboard.server.common.data.kv.BasicTsKvEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.dao.service.ConstraintValidator; -import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.config.account.SmsTwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.config.provider.SmsTwoFactorAuthProviderConfig; @@ -34,16 +32,20 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; -import java.util.Collections; import java.util.Map; @Service -@RequiredArgsConstructor @TbCoreComponent public class SmsTwoFactorAuthProvider implements TwoFactorAuthProvider { private final SmsService smsService; - private final TimeseriesService timeseriesService; + private final Cache verificationCodesCache; + + public SmsTwoFactorAuthProvider(SmsService smsService, CacheManager cacheManager) { + this.smsService = smsService; + this.verificationCodesCache = cacheManager.getCache(CacheConstants.TWO_FA_VERIFICATION_CODES_CACHE); + } + @Override public SmsTwoFactorAuthAccountConfig generateNewAccountConfig(User user, SmsTwoFactorAuthProviderConfig providerConfig) { @@ -53,10 +55,8 @@ public class SmsTwoFactorAuthProvider implements TwoFactorAuthProvider codeTs.getStrValue().get()) - .orElse(null); - } - - private void removeVerificationCode(SecurityUser user) { - timeseriesService.remove(user.getTenantId(), user.getId(), Collections.singletonList( - new BaseDeleteTsKvQuery("twoFaVerificationCode:" + user.getSessionId(), 0, System.currentTimeMillis()) - )); - } - - @Override public TwoFactorAuthProviderType getType() { return TwoFactorAuthProviderType.SMS; } + + @Data + private static class VerificationCode { + private final long timestamp; + private final String value; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java index ca2f15953a..bb5feb9af6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAwareAuthenticationSuccessHandler.java @@ -24,6 +24,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.WebAttributes; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; @@ -53,6 +54,7 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc JwtTokenPair tokenPair; + // fixme: check if this handler is not called when token is refreshed Optional twoFaAccountConfig = twoFactorAuthService.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()); if (twoFaAccountConfig.isPresent()) { try { @@ -62,6 +64,7 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc }); tokenPair = new JwtTokenPair(); tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser).getToken()); + tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); } catch (Exception e) { log.error("Failed to process 2FA for user {}. Falling back to plain auth", securityUser.getId(), e); tokenPair = tokenFactory.createTokenPair(securityUser); diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java b/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java index 57964570c1..02e28cd885 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/JwtTokenPair.java @@ -20,10 +20,10 @@ import io.swagger.annotations.ApiModelProperty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.security.Authority; @ApiModel(value = "JWT Token Pair") @Data -@AllArgsConstructor @NoArgsConstructor public class JwtTokenPair { @@ -31,4 +31,12 @@ public class JwtTokenPair { private String token; @ApiModelProperty(position = 1, value = "The JWT Refresh Token. Used to get new JWT Access Token if old one has expired.", example = "AAB254FF67D..") private String refreshToken; + + private Authority scope; + + public JwtTokenPair(String token, String refreshToken) { + this.token = token; + this.refreshToken = refreshToken; + } + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index e79d8979ce..ba6dc222ab 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -428,8 +428,8 @@ caffeine: timeToLiveInMinutes: "${CACHE_SPECS_EDGES_TTL:1440}" maxSize: "${CACHE_SPECS_EDGES_MAX_SIZE:10000}" twoFaVerificationCodes: - timeToLiveInMinutes: "1" - maxSize: "100000" + timeToLiveInMinutes: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_TTL:1}" + maxSize: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_MAX_SIZE:100000}" redis: # standalone or cluster 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 6cbf5d82d0..32d55a71dd 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -584,6 +584,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return mapper.readerFor(type).readValue(content); } + protected String getErrorMessage(ResultActions result) throws Exception { + return readResponse(result, JsonNode.class).get("message").asText(); + } + public class IdComparator implements Comparator { @Override public int compare(D o1, D o2) { diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java new file mode 100644 index 0000000000..fcc2c8bcdf --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -0,0 +1,256 @@ +/** + * 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.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.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.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.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 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/TwoFactorAuthSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthSqlTest.java new file mode 100644 index 0000000000..62024fc64e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthSqlTest.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.controller.TwoFactorAuthTest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +@DaoSqlTest +public class TwoFactorAuthSqlTest extends TwoFactorAuthTest { +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 571af34086..49db7befd3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -31,4 +31,5 @@ public class CacheConstants { public static final String TOKEN_OUTDATAGE_TIME_CACHE = "tokensOutdatageTime"; public static final String OTA_PACKAGE_CACHE = "otaPackages"; public static final String OTA_PACKAGE_DATA_CACHE = "otaPackagesData"; + public static final String TWO_FA_VERIFICATION_CODES_CACHE = "twoFaVerificationCodes"; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index da7537ae75..404eeb44cc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -21,6 +21,7 @@ import org.hibernate.validator.HibernateValidatorConfiguration; import org.hibernate.validator.cfg.ConstraintMapping; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +import org.thingsboard.server.dao.exception.DataValidationException; import javax.validation.ConstraintViolation; import javax.validation.Validation; @@ -46,7 +47,7 @@ public class ConstraintValidator { .distinct() .collect(Collectors.toList()); if (!validationErrors.isEmpty()) { - throw new ValidationException(String.join(", ", validationErrors)); + throw new DataValidationException("Validation error: " + String.join(", ", validationErrors)); } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseOtaPackageServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/BaseOtaPackageServiceTest.java index d49594a955..b750d5f1c3 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseOtaPackageServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/BaseOtaPackageServiceTest.java @@ -675,7 +675,7 @@ public abstract class BaseOtaPackageServiceTest extends AbstractServiceTest { firmwareInfo.setUrl(URL); firmwareInfo.setTenantId(tenantId); - thrown.expect(ValidationException.class); + thrown.expect(DataValidationException.class); thrown.expectMessage("length of title must be equal or less than 255"); otaPackageService.saveOtaPackageInfo(firmwareInfo, true); From b5afb32f569c88ec56a77a9359578d2f0bd36a0d Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 18 Mar 2022 11:05:17 +0200 Subject: [PATCH 04/92] 2FA: validation and error handling refactoring --- .../server/controller/BaseController.java | 15 ++++++ .../controller/TwoFactorAuthController.java | 47 ++++++++++++++----- .../auth/mfa/TwoFactorAuthService.java | 23 +++++---- .../TotpTwoFactorAuthAccountConfig.java | 2 + .../SmsTwoFactorAuthProviderConfig.java | 2 +- .../TotpTwoFactorAuthProviderConfig.java | 2 +- .../mfa/provider/TwoFactorAuthProvider.java | 3 +- .../impl/SmsTwoFactorAuthProvider.java | 4 +- .../auth/rest/RestAuthenticationProvider.java | 1 + ...RestAwareAuthenticationSuccessHandler.java | 33 ++++++++----- .../common/util/ThrowingBiConsumer.java | 21 +++++++++ ...eFunction.java => ThrowingBiFunction.java} | 4 +- .../common/util/ThrowingTripleConsumer.java | 21 +++++++++ .../common/util/ThrowingTripleFunction.java | 21 +++++++++ 14 files changed, 159 insertions(+), 40 deletions(-) create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java rename common/util/src/main/java/org/thingsboard/common/util/{TripleFunction.java => ThrowingBiFunction.java} (88%) create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java create mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 6dee3b6114..fdef4cbc0c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -23,9 +23,11 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.Customer; @@ -145,6 +147,7 @@ import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; +import java.util.stream.Collectors; import static org.thingsboard.server.controller.ControllerConstants.DEFAULT_PAGE_SIZE; import static org.thingsboard.server.controller.ControllerConstants.INCORRECT_TENANT_ID; @@ -334,6 +337,18 @@ public abstract class BaseController { } } + /** + * Handles validation error for controller method arguments annotated with @{@link javax.validation.Valid} + * */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public void handleValidationError(MethodArgumentNotValidException e, HttpServletResponse response) { + String errorMessage = "Validation error: " + e.getBindingResult().getAllErrors().stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + ThingsboardException thingsboardException = new ThingsboardException(errorMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + handleThingsboardException(thingsboardException, response); + } + T checkNotNull(T reference) throws ThingsboardException { return checkNotNull(reference, "Requested item wasn't found!"); } 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 f0d50c2600..b6ad9626b1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -30,7 +30,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.dao.service.ConstraintValidator; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; @@ -42,10 +42,25 @@ import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; -// FIXME: Swagger documentation -// FIXME: tests for 2FA - +/* + * + * TODO [viacheslav]: + * - 2FA should be mandatory when logging in and must be rolled out to all existing users when 2FA is activated. + * - Rate limits should be implemented to protect against brute force leaked accounts to prevent SMS cost explosion. + * - Configurable softlock after XX (3) attempts: XX (15) mins + * - Configurable hardlock (user blocking) after a total of XX (10) unsuccessful attempts. + * - The OTP token should only be valid for XX (5) minutes. + * - Disable 2FA only possible after successful 2FA auth - it is possible with simple password resest + * - 2FA entries should be secured against code injection by code validation. + * - Email 2FA provider + * + * FIXME [viacheslav]: + * - Tests for 2FA + * - Swagger documentation + * + * */ @RestController @RequestMapping("/api") @RequiredArgsConstructor @@ -65,7 +80,7 @@ public class TwoFactorAuthController extends BaseController { @PostMapping("/2fa/account/config/generate") @PreAuthorize("isAuthenticated()") - public TwoFactorAuthAccountConfig generateTwoFactorAuthAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws ThingsboardException { + public TwoFactorAuthAccountConfig generateTwoFactorAuthAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); return twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), providerType, @@ -90,20 +105,19 @@ public class TwoFactorAuthController extends BaseController { @PostMapping("/2fa/account/config/submit") @PreAuthorize("isAuthenticated()") - public void submitTwoFactorAuthAccountConfig(@RequestBody TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + public void submitTwoFactorAuthAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), (provider, providerConfig) -> { - ConstraintValidator.validateFields(accountConfig); provider.prepareVerificationCode(user, providerConfig, accountConfig); }); } @PostMapping("/2fa/account/config") @PreAuthorize("isAuthenticated()") - public void verifyAndSaveTwoFactorAuthAccountConfig(@RequestBody TwoFactorAuthAccountConfig accountConfig, - @RequestParam String verificationCode) throws ThingsboardException { + public void verifyAndSaveTwoFactorAuthAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, + @RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), @@ -127,14 +141,14 @@ public class TwoFactorAuthController extends BaseController { @PostMapping("/2fa/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public void saveTwoFactorAuthSettings(@RequestBody TwoFactorAuthSettings twoFactorAuthSettings) throws ThingsboardException { + public void saveTwoFactorAuthSettings(@Valid @RequestBody TwoFactorAuthSettings twoFactorAuthSettings) throws ThingsboardException { twoFactorAuthService.saveTwoFaSettings(getTenantId(), twoFactorAuthSettings); } @PostMapping("/auth/2fa/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode) throws ThingsboardException { + public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), @@ -149,4 +163,15 @@ public class TwoFactorAuthController extends BaseController { } } + @PostMapping("/auth/2fa/verification/resend") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public void resendTwoFaVerificationCode() throws Exception { + SecurityUser user = getCurrentUser(); + + twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), + (provider, providerConfig, accountConfig) -> { + provider.prepareVerificationCode(user, providerConfig, accountConfig); + }); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index 2b44fddeb5..580a12ce44 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -21,7 +21,10 @@ import lombok.SneakyThrows; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.common.util.TripleFunction; +import org.thingsboard.common.util.ThrowingBiConsumer; +import org.thingsboard.common.util.ThrowingBiFunction; +import org.thingsboard.common.util.ThrowingTripleConsumer; +import org.thingsboard.common.util.ThrowingTripleFunction; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.User; @@ -32,7 +35,6 @@ import org.thingsboard.server.common.data.id.UserId; 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.AdminSettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; @@ -47,8 +49,6 @@ import java.util.EnumMap; import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; -import java.util.function.BiConsumer; -import java.util.function.BiFunction; @Service @RequiredArgsConstructor @@ -81,7 +81,7 @@ public class TwoFactorAuthService { } - public R processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, BiFunction, TwoFactorAuthProviderConfig, R> function) throws ThingsboardException { + public R processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, ThrowingBiFunction, TwoFactorAuthProviderConfig, R> function) throws Exception { TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(tenantId, providerType) .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); TwoFactorAuthProvider provider = getTwoFaProvider(providerType) @@ -90,14 +90,14 @@ public class TwoFactorAuthService { return function.apply(provider, providerConfig); } - public void processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, BiConsumer, TwoFactorAuthProviderConfig> function) throws ThingsboardException { + public void processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, ThrowingBiConsumer, TwoFactorAuthProviderConfig> function) throws Exception { processByTwoFaProvider(tenantId, providerType, (provider, providerConfig) -> { function.accept(provider, providerConfig); return null; }); } - public R processByTwoFaProvider(TenantId tenantId, UserId userId, TripleFunction, TwoFactorAuthProviderConfig, TwoFactorAuthAccountConfig, R> function) throws ThingsboardException { + public R processByTwoFaProvider(TenantId tenantId, UserId userId, ThrowingTripleFunction, TwoFactorAuthProviderConfig, TwoFactorAuthAccountConfig, R> function) throws Exception { TwoFactorAuthAccountConfig accountConfig = getTwoFaAccountConfig(tenantId, userId) .orElseThrow(() -> new ThingsboardException("2FA is not configured for user", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); @@ -109,6 +109,13 @@ public class TwoFactorAuthService { return function.apply(provider, providerConfig, accountConfig); } + public void processByTwoFaProvider(TenantId tenantId, UserId userId, ThrowingTripleConsumer, TwoFactorAuthProviderConfig, TwoFactorAuthAccountConfig> function) throws Exception { + processByTwoFaProvider(tenantId, userId, (provider, providerConfig, accountConfig) -> { + function.accept(provider, providerConfig, accountConfig); + return null; + }); + } + public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { User user = userService.findUserById(tenantId, userId); @@ -121,7 +128,6 @@ public class TwoFactorAuthService { } public void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { - ConstraintValidator.validateFields(accountConfig); getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); @@ -160,7 +166,6 @@ public class TwoFactorAuthService { @SneakyThrows({InterruptedException.class, ExecutionException.class}) public void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings) { - ConstraintValidator.validateFields(twoFactorAuthSettings); if (tenantId.equals(TenantId.SYS_TENANT_ID)) { AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) .orElseGet(() -> { 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 a48cf162a0..78ab4cf80b 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 @@ -19,11 +19,13 @@ import lombok.Data; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; @Data public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { @NotBlank +// @Pattern(regexp = ) // TODO [viacheslav]: validate otp auth url by pattern private String authUrl; @Override 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 a036e47574..2d15a07ad1 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 @@ -25,7 +25,7 @@ import javax.validation.constraints.Pattern; public class SmsTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { @NotBlank - @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "Template must contain verification code") + @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "template must contain verification code") private String smsVerificationMessageTemplate; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java index 2d6cc5ddf5..e1604bb618 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java @@ -23,7 +23,7 @@ import javax.validation.constraints.NotBlank; @Data public class TotpTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { - @NotBlank(message = "Issuer name must not be blank") + @NotBlank(message = "issuer name must not be blank") private String issuerName; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java index 82122a9163..011404268c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.security.auth.mfa.provider; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.model.SecurityUser; @@ -24,7 +25,7 @@ public interface TwoFactorAuthProvider twoFaAccountConfig = twoFactorAuthService.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()); if (twoFaAccountConfig.isPresent()) { try { @@ -62,23 +76,16 @@ public class RestAwareAuthenticationSuccessHandler implements AuthenticationSucc (provider, providerConfig) -> { provider.prepareVerificationCode(securityUser, providerConfig, twoFaAccountConfig.get()); }); - tokenPair = new JwtTokenPair(); + JwtTokenPair tokenPair = new JwtTokenPair(); tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser).getToken()); tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); + return tokenPair; } catch (Exception e) { + // TODO [viacheslav]: write audit log log.error("Failed to process 2FA for user {}. Falling back to plain auth", securityUser.getId(), e); - tokenPair = tokenFactory.createTokenPair(securityUser); } - } else { - tokenPair = tokenFactory.createTokenPair(securityUser); } - - response.setStatus(HttpStatus.OK.value()); - response.setContentType(MediaType.APPLICATION_JSON_VALUE); - - mapper.writeValue(response.getWriter(), tokenPair); - - clearAuthenticationAttributes(request); + return tokenFactory.createTokenPair(securityUser); } /** diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java new file mode 100644 index 0000000000..269f9f75cb --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java @@ -0,0 +1,21 @@ +/** + * 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.common.util; + +@FunctionalInterface +public interface ThrowingBiConsumer { + void accept(A a, B b) throws Exception; +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java similarity index 88% rename from common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java rename to common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java index 5134703cd3..32058df26e 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/TripleFunction.java +++ b/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java @@ -16,6 +16,6 @@ package org.thingsboard.common.util; @FunctionalInterface -public interface TripleFunction { - R apply(A a, B b, C c); +public interface ThrowingBiFunction { + R apply(A a, B b) throws Exception; } diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java new file mode 100644 index 0000000000..5230da4863 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java @@ -0,0 +1,21 @@ +/** + * 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.common.util; + +@FunctionalInterface +public interface ThrowingTripleConsumer { + void accept(A a, B b, C c) throws Exception; +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java new file mode 100644 index 0000000000..cade9d1717 --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java @@ -0,0 +1,21 @@ +/** + * 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.common.util; + +@FunctionalInterface +public interface ThrowingTripleFunction { + R apply(A a, B b, C c) throws Exception; +} From 1a006285095750704e9a5e66b40f99f1442f4b51 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 18 Mar 2022 18:08:45 +0200 Subject: [PATCH 05/92] Email 2FA provider, refactoring --- .../controller/TwoFactorAuthController.java | 77 +++++++++++++------ .../security/auth/MfaAuthenticationToken.java | 24 ++++++ .../auth/mfa/TwoFactorAuthService.java | 36 ++++----- .../mfa/config/TwoFactorAuthSettings.java | 15 +++- .../EmailTwoFactorAuthAccountConfig.java | 34 ++++++++ .../OtpBasedTwoFactorAuthAccountConfig.java | 19 +++++ .../SmsTwoFactorAuthAccountConfig.java | 4 +- .../EmailTwoFactorAuthProviderConfig.java | 33 ++++++++ .../OtpBasedTwoFactorAuthProviderConfig.java | 23 ++++++ .../SmsTwoFactorAuthProviderConfig.java | 4 +- .../mfa/provider/TwoFactorAuthProvider.java | 2 +- .../provider/TwoFactorAuthProviderType.java | 3 +- .../impl/EmailTwoFactorAuthProvider.java | 67 ++++++++++++++++ .../impl/OtpBasedTwoFactorAuthProvider.java | 70 +++++++++++++++++ .../impl/SmsTwoFactorAuthProvider.java | 40 ++-------- .../impl/TotpTwoFactorAuthProvider.java | 3 +- .../auth/rest/RestAuthenticationProvider.java | 31 +++++--- ...RestAwareAuthenticationSuccessHandler.java | 42 +++------- .../security/model/token/JwtTokenFactory.java | 4 +- .../server/controller/TwoFactorAuthTest.java | 3 +- 20 files changed, 408 insertions(+), 126 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFactorAuthProvider.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFactorAuthProvider.java 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 b6ad9626b1..bf8f2a7054 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -28,9 +28,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.service.security.auth.TokenOutdatingService; +import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; @@ -43,24 +44,30 @@ import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; +import java.util.HashMap; +import java.util.Map; /* * * TODO [viacheslav]: - * - 2FA should be mandatory when logging in and must be rolled out to all existing users when 2FA is activated. - * - Rate limits should be implemented to protect against brute force leaked accounts to prevent SMS cost explosion. - * - Configurable softlock after XX (3) attempts: XX (15) mins - * - Configurable hardlock (user blocking) after a total of XX (10) unsuccessful attempts. - * - The OTP token should only be valid for XX (5) minutes. - * - Disable 2FA only possible after successful 2FA auth - it is possible with simple password resest - * - 2FA entries should be secured against code injection by code validation. - * - Email 2FA provider + * - Configurable softlock after XX (3) attempts: XX (15) mins - on session level + * - Configurable hardlock (user blocking) after a total of XX (10) unsuccessful attempts - on user level * * FIXME [viacheslav]: * - Tests for 2FA * - Swagger documentation * * */ +// TODO [viacheslav]: maybe get rid of sessionId concept.. + +/* + * + * + * TODO (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... + * */ @RestController @RequestMapping("/api") @RequiredArgsConstructor @@ -122,7 +129,7 @@ public class TwoFactorAuthController extends BaseController { boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), (provider, providerConfig) -> { - return provider.checkVerificationCode(user, verificationCode, accountConfig); + return provider.checkVerificationCode(user, verificationCode, providerConfig, accountConfig); }); if (verificationSuccess) { @@ -146,32 +153,58 @@ public class TwoFactorAuthController extends BaseController { } + private final Map verificationCodeSendRateLimits = new HashMap<>(); + private final Map verificationCodeCheckRateLimits = new HashMap<>(); + + @PostMapping("/auth/2fa/verification/send") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public void sendTwoFaVerificationCode() throws Exception { + SecurityUser user = getCurrentUser(); + + TwoFactorAuthSettings twoFaSettings = twoFactorAuthService.getTwoFaSettings(user.getTenantId()).get(); + if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { + TbRateLimits rateLimits = verificationCodeSendRateLimits.computeIfAbsent(user.getSessionId(), sessionId -> { + return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); + }); + if (!rateLimits.tryConsume()) { + throw new ThingsboardException(ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + } + + twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), + (provider, providerConfig, accountConfig) -> { + provider.prepareVerificationCode(user, providerConfig, accountConfig); + }); + } + @PostMapping("/auth/2fa/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); + + + // FIXME [viacheslav]: rate limits for verification code check boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), (provider, providerConfig, accountConfig) -> { - return provider.checkVerificationCode(user, verificationCode, accountConfig); + return provider.checkVerificationCode(user, verificationCode, providerConfig, accountConfig); }); + if (verificationSuccess) { return tokenFactory.createTokenPair(user); } else { + TwoFactorAuthSettings twoFaSettings = twoFactorAuthService.getTwoFaSettings(user.getTenantId()).get(); + if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { + TbRateLimits rateLimits = verificationCodeSendRateLimits.computeIfAbsent(user.getSessionId(), sessionId -> { + return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); + }); + if (!rateLimits.tryConsume()) { + throw new ThingsboardException(ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + } throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); } } - @PostMapping("/auth/2fa/verification/resend") - @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public void resendTwoFaVerificationCode() throws Exception { - SecurityUser user = getCurrentUser(); - - twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), - (provider, providerConfig, accountConfig) -> { - provider.prepareVerificationCode(user, providerConfig, accountConfig); - }); - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java new file mode 100644 index 0000000000..8c70e69179 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/MfaAuthenticationToken.java @@ -0,0 +1,24 @@ +/** + * 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.service.security.auth; + +import org.thingsboard.server.service.security.model.SecurityUser; + +public class MfaAuthenticationToken extends AbstractJwtAuthenticationToken { + public MfaAuthenticationToken(SecurityUser securityUser) { + super(securityUser); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index 580a12ce44..501517ca88 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -63,24 +63,6 @@ public class TwoFactorAuthService { protected static final String TWO_FACTOR_AUTH_SETTINGS_KEY = "twoFaSettings"; - @Autowired - private void setProviders(Collection> providers) { - providers.forEach(provider -> { - this.providers.put(provider.getType(), provider); - }); - } - - private Optional> getTwoFaProvider(TwoFactorAuthProviderType providerType) { - return Optional.of((TwoFactorAuthProvider) providers.get(providerType)); - } - - private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { - return getTwoFaSettings(tenantId) - .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) - .map(providerConfig -> (C) providerConfig); - } - - public R processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, ThrowingBiFunction, TwoFactorAuthProviderConfig, R> function) throws Exception { TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(tenantId, providerType) .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); @@ -182,4 +164,22 @@ public class TwoFactorAuthService { } } + + private Optional> getTwoFaProvider(TwoFactorAuthProviderType providerType) { + return Optional.of((TwoFactorAuthProvider) providers.get(providerType)); + } + + private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { + return getTwoFaSettings(tenantId) + .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) + .map(providerConfig -> (C) providerConfig); + } + + @Autowired + private void setProviders(Collection> providers) { + providers.forEach(provider -> { + this.providers.put(provider.getType(), provider); + }); + } + } 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 a0620ec96e..8b8927f721 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 @@ -16,20 +16,33 @@ package org.thingsboard.server.service.security.auth.mfa.config; import lombok.Data; +import org.checkerframework.checker.index.qual.NonNegative; import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.Valid; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; import java.util.List; import java.util.Optional; @Data public class TwoFactorAuthSettings { - private boolean useSystemTwoFactorAuthSettings; + @NotNull + private Boolean useSystemTwoFactorAuthSettings; @Valid private List providers; + @Pattern(regexp = "\\d+:\\d+") + private String verificationCodeSendRateLimit; // 1:60 - one time in a minute + @Pattern(regexp = "\\d+:\\d+") + private String verificationCodeCheckRateLimit; // soft lockout, on session level + @Min(0) + private Integer maxVerificationCodeSubmitAttemptsBeforeUserBlocking; + + public Optional getProviderConfig(TwoFactorAuthProviderType providerType) { return Optional.ofNullable(providers) .flatMap(providersConfigs -> providersConfigs.stream() diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java new file mode 100644 index 0000000000..c18e4c41c1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java @@ -0,0 +1,34 @@ +/** + * 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.service.security.auth.mfa.config.account; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +@EqualsAndHashCode(callSuper = true) +@Data +public class EmailTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { + + private boolean useAccountEmail; // TODO [viacheslav]: validate + private String email; + + @Override + public TwoFactorAuthProviderType getProviderType() { + return TwoFactorAuthProviderType.EMAIL; + } + +} 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 new file mode 100644 index 0000000000..80b832a831 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java @@ -0,0 +1,19 @@ +/** + * 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.service.security.auth.mfa.config.account; + +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 6f3ef06775..82e3760e35 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 @@ -16,12 +16,14 @@ package org.thingsboard.server.service.security.auth.mfa.config.account; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; +@EqualsAndHashCode(callSuper = true) @Data -public class SmsTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { +public class SmsTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { @NotBlank private String phoneNumber; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java new file mode 100644 index 0000000000..a782f73a68 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java @@ -0,0 +1,33 @@ +/** + * 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.service.security.auth.mfa.config.provider; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +@EqualsAndHashCode(callSuper = true) +@Data +public class EmailTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig{ + + private String emailVerificationMessageTemplate; // FIXME [viacheslav]: + + @Override + public TwoFactorAuthProviderType getProviderType() { + return TwoFactorAuthProviderType.EMAIL; + } + +} 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 new file mode 100644 index 0000000000..c7169def48 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.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.service.security.auth.mfa.config.provider; + +import lombok.Data; + +@Data +public abstract class OtpBasedTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { + private Integer verificationCodeLifetime; // seconds +} 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 2d15a07ad1..3fe9e77ce7 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 @@ -16,13 +16,15 @@ package org.thingsboard.server.service.security.auth.mfa.config.provider; import lombok.Data; +import lombok.EqualsAndHashCode; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; +@EqualsAndHashCode(callSuper = true) @Data -public class SmsTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { +public class SmsTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig { @NotBlank @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "template must contain verification code") diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java index 011404268c..858b5995f7 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java @@ -27,7 +27,7 @@ public interface TwoFactorAuthProvider { + + private final MailService mailService; + + protected EmailTwoFactorAuthProvider(CacheManager cacheManager, MailService mailService) { + super(cacheManager); + this.mailService = mailService; + } + + + @Override + public EmailTwoFactorAuthAccountConfig generateNewAccountConfig(User user, EmailTwoFactorAuthProviderConfig providerConfig) { + EmailTwoFactorAuthAccountConfig accountConfig = new EmailTwoFactorAuthAccountConfig(); + accountConfig.setUseAccountEmail(true); + return accountConfig; + } + + @Override + protected void sendVerificationCode(SecurityUser user, String verificationCode, EmailTwoFactorAuthProviderConfig providerConfig, EmailTwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + String email; + if (accountConfig.isUseAccountEmail()) { + email = user.getEmail(); + } else { + email = accountConfig.getEmail(); + } + + // FIXME [viacheslav]: mail template for 2FA verification + mailService.sendEmail(user.getTenantId(), email, "subject", ""); + } + + + @Override + public TwoFactorAuthProviderType getType() { + return TwoFactorAuthProviderType.EMAIL; + } + +} 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 new file mode 100644 index 0000000000..f8d07aedb2 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFactorAuthProvider.java @@ -0,0 +1,70 @@ +/** + * 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.service.security.auth.mfa.provider.impl; + +import lombok.Data; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.service.security.auth.mfa.config.account.OtpBasedTwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.OtpBasedTwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; +import org.thingsboard.server.service.security.model.SecurityUser; + +import java.util.concurrent.TimeUnit; + +public abstract class OtpBasedTwoFactorAuthProvider implements TwoFactorAuthProvider { + + private final Cache verificationCodesCache; + + protected OtpBasedTwoFactorAuthProvider(CacheManager cacheManager) { + this.verificationCodesCache = cacheManager.getCache(CacheConstants.TWO_FA_VERIFICATION_CODES_CACHE); + } + + + @Override + public final void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) throws ThingsboardException { + String verificationCode = RandomStringUtils.randomNumeric(6); + verificationCodesCache.put(user.getSessionId(), new Otp(System.currentTimeMillis(), verificationCode)); + + sendVerificationCode(user, verificationCode, providerConfig, accountConfig); + } + + protected abstract void sendVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig) throws ThingsboardException; + + + @Override + public final boolean checkVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig) { + Otp correctVerificationCode = verificationCodesCache.get(user.getSessionId(), Otp.class); + if (correctVerificationCode != null && verificationCode.equals(correctVerificationCode.getValue())) { + if (System.currentTimeMillis() - correctVerificationCode.getTimestamp() <= TimeUnit.SECONDS.toMillis(providerConfig.getVerificationCodeLifetime())) { + verificationCodesCache.evict(user.getSessionId()); + return true; + } + } + return false; + } + + + @Data + private static class Otp { + private final long timestamp; + private final String value; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java index c4b51ab16a..e633a0afd1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java @@ -15,21 +15,15 @@ */ package org.thingsboard.server.service.security.auth.mfa.provider.impl; -import lombok.Data; -import lombok.SneakyThrows; -import org.apache.commons.lang3.RandomStringUtils; -import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Service; import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.util.TbNodeUtils; -import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.config.account.SmsTwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.config.provider.SmsTwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; @@ -37,14 +31,13 @@ import java.util.Map; @Service @TbCoreComponent -public class SmsTwoFactorAuthProvider implements TwoFactorAuthProvider { +public class SmsTwoFactorAuthProvider extends OtpBasedTwoFactorAuthProvider { private final SmsService smsService; - private final Cache verificationCodesCache; public SmsTwoFactorAuthProvider(SmsService smsService, CacheManager cacheManager) { + super(cacheManager); this.smsService = smsService; - this.verificationCodesCache = cacheManager.getCache(CacheConstants.TWO_FA_VERIFICATION_CODES_CACHE); } @@ -54,42 +47,21 @@ public class SmsTwoFactorAuthProvider implements TwoFactorAuthProvider data = Map.of( + protected void sendVerificationCode(SecurityUser user, String verificationCode, SmsTwoFactorAuthProviderConfig providerConfig, SmsTwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + Map messageData = Map.of( "verificationCode", verificationCode, "userEmail", user.getEmail() ); - String message = TbNodeUtils.processTemplate(providerConfig.getSmsVerificationMessageTemplate(), data); + String message = TbNodeUtils.processTemplate(providerConfig.getSmsVerificationMessageTemplate(), messageData); + String phoneNumber = accountConfig.getPhoneNumber(); smsService.sendSms(user.getTenantId(), user.getCustomerId(), new String[]{phoneNumber}, message); } - @Override - public boolean checkVerificationCode(SecurityUser user, String verificationCode, SmsTwoFactorAuthAccountConfig accountConfig) { - VerificationCode correctVerificationCode = verificationCodesCache.get(user.getSessionId(), VerificationCode.class); - if (correctVerificationCode != null && verificationCode.equals(correctVerificationCode.getValue())) { - verificationCodesCache.evict(user.getSessionId()); - return true; - } else { - return false; - } - } @Override public TwoFactorAuthProviderType getType() { return TwoFactorAuthProviderType.SMS; } - - @Data - private static class VerificationCode { - private final long timestamp; - private final String value; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java index d7747521c1..65574fc760 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java @@ -45,7 +45,7 @@ public class TotpTwoFactorAuthProvider implements TwoFactorAuthProvider twoFaAccountConfig = twoFactorAuthService.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()); - if (twoFaAccountConfig.isPresent()) { - try { - twoFactorAuthService.processByTwoFaProvider(securityUser.getTenantId(), twoFaAccountConfig.get().getProviderType(), - (provider, providerConfig) -> { - provider.prepareVerificationCode(securityUser, providerConfig, twoFaAccountConfig.get()); - }); - JwtTokenPair tokenPair = new JwtTokenPair(); - tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser).getToken()); - tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); - return tokenPair; - } catch (Exception e) { - // TODO [viacheslav]: write audit log - log.error("Failed to process 2FA for user {}. Falling back to plain auth", securityUser.getId(), e); - } - } - return tokenFactory.createTokenPair(securityUser); - } - /** * Removes temporary authentication-related data which may have been stored * in the session during the authentication process.. diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index b2bc3add26..36f64b3566 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -166,8 +166,8 @@ public class JwtTokenFactory { return securityUser; } - public JwtToken createPreVerificationToken(SecurityUser user) { - String token = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), settings.getPreVerificationTokenExpirationTime()) + public JwtToken createTwoFaPreVerificationToken(SecurityUser user, Integer expirationTime) { + String token = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime) .claim(TENANT_ID, user.getTenantId().toString()) .compact(); return new AccessJwtToken(token); 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 fcc2c8bcdf..a12c7ec0e8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -25,7 +25,6 @@ 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.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; @@ -40,12 +39,12 @@ 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; +// TODO [viacheslav]: test sessionId public abstract class TwoFactorAuthTest extends AbstractControllerTest { @SpyBean From 0c36d4809c0e4d4cf7c680d839b42f5ed4f8d42b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Sat, 19 Mar 2022 19:34:45 +0200 Subject: [PATCH 06/92] 2FA: rate limiting, validation, refactoring --- .../config/RateLimitProcessingFilter.java | 3 + .../TwoFactorAuthConfigController.java | 124 +++++++++++++ .../controller/TwoFactorAuthController.java | 142 +-------------- .../auth/mfa/DefaultTwoFactorAuthService.java | 151 ++++++++++++++++ .../auth/mfa/TwoFactorAuthService.java | 163 +----------------- .../DefaultTwoFactorAuthConfigManager.java | 139 +++++++++++++++ .../config/TwoFactorAuthConfigManager.java | 41 +++++ .../mfa/config/TwoFactorAuthSettings.java | 20 ++- .../EmailTwoFactorAuthAccountConfig.java | 13 +- .../SmsTwoFactorAuthAccountConfig.java | 2 + .../account/TwoFactorAuthAccountConfig.java | 6 +- .../OtpBasedTwoFactorAuthProviderConfig.java | 7 +- .../provider/TwoFactorAuthProviderConfig.java | 7 +- .../impl/OtpBasedTwoFactorAuthProvider.java | 14 +- .../auth/rest/RestAuthenticationProvider.java | 10 +- ...RestAwareAuthenticationSuccessHandler.java | 10 +- .../system/DefaultSystemSecurityService.java | 54 ++++-- .../system/SystemSecurityService.java | 4 + .../server/controller/TwoFactorAuthTest.java | 1 + .../server/dao/user/UserServiceImpl.java | 2 +- 20 files changed, 583 insertions(+), 330 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java diff --git a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java index e2e6f8d982..2756b9a647 100644 --- a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java @@ -15,8 +15,11 @@ */ package org.thingsboard.server.config; +import io.github.bucket4j.Bucket4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.Cache; +import org.springframework.cache.jcache.JCacheCacheManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java new file mode 100644 index 0000000000..12f0824ff1 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -0,0 +1,124 @@ +/** + * 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 com.google.zxing.BarcodeFormat; +import com.google.zxing.client.j2se.MatrixToImageWriter; +import com.google.zxing.common.BitMatrix; +import com.google.zxing.qrcode.QRCodeWriter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +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.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/2fa") +@RequiredArgsConstructor +public class TwoFactorAuthConfigController extends BaseController { + + private final TwoFactorAuthConfigManager twoFactorAuthConfigManager; + private final TwoFactorAuthService twoFactorAuthService; + + + @GetMapping("/account/config") + @PreAuthorize("isAuthenticated()") + public TwoFactorAuthAccountConfig getTwoFaAccountConfig() throws ThingsboardException { + SecurityUser user = getCurrentUser(); + return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); + } + + @PostMapping("/account/config/generate") + @PreAuthorize("isAuthenticated()") + public TwoFactorAuthAccountConfig generateTwoFaAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws Exception { + SecurityUser user = getCurrentUser(); + return twoFactorAuthService.generateNewAccountConfig(user, providerType); + } + + /* TMP */ + @PostMapping("/account/config/generate/qr") + @PreAuthorize("isAuthenticated()") + public void generateTwoFaAccountConfigWithQr(@RequestParam TwoFactorAuthProviderType providerType, HttpServletResponse response) throws Exception { + TwoFactorAuthAccountConfig config = generateTwoFaAccountConfig(providerType); + if (providerType == TwoFactorAuthProviderType.TOTP) { + BitMatrix qr = new QRCodeWriter().encode(((TotpTwoFactorAuthAccountConfig) config).getAuthUrl(), BarcodeFormat.QR_CODE, 200, 200); + try (ServletOutputStream outputStream = response.getOutputStream()) { + MatrixToImageWriter.writeToStream(qr, "PNG", outputStream); + } + } + response.setHeader("config", JacksonUtil.toString(config)); + } + /* TMP */ + + @PostMapping("/account/config/submit") + @PreAuthorize("isAuthenticated()") + public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { + SecurityUser user = getCurrentUser(); + twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); + } + + @PostMapping("/account/config") + @PreAuthorize("isAuthenticated()") + public void verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, + @RequestParam String verificationCode) throws Exception { + SecurityUser user = getCurrentUser(); + boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false); + if (verificationSuccess) { + twoFactorAuthConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + } else { + throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.INVALID_ARGUMENTS); + } + } + + @DeleteMapping("/account/config") + @PreAuthorize("isAuthenticated()") + public void deleteTwoFactorAuthAccountConfig() throws ThingsboardException { + SecurityUser user = getCurrentUser(); + twoFactorAuthConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId()); + } + + + @GetMapping("/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public TwoFactorAuthSettings getTwoFactorAuthSettings() throws ThingsboardException { + return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId()).orElse(null); + } + + @PostMapping("/settings") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public void saveTwoFactorAuthSettings(@RequestBody TwoFactorAuthSettings twoFactorAuthSettings) throws ThingsboardException { + twoFactorAuthConfigManager.saveTwoFaSettings(getTenantId(), twoFactorAuthSettings); + } + +} 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 bf8f2a7054..58a7af3d7a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -15,42 +15,22 @@ */ package org.thingsboard.server.controller; -import com.google.zxing.BarcodeFormat; -import com.google.zxing.client.j2se.MatrixToImageWriter; -import com.google.zxing.common.BitMatrix; -import com.google.zxing.qrcode.QRCodeWriter; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; -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.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; -import javax.servlet.ServletOutputStream; -import javax.servlet.http.HttpServletResponse; -import javax.validation.Valid; -import java.util.HashMap; -import java.util.Map; - /* * * TODO [viacheslav]: - * - Configurable softlock after XX (3) attempts: XX (15) mins - on session level * - Configurable hardlock (user blocking) after a total of XX (10) unsuccessful attempts - on user level * * FIXME [viacheslav]: @@ -69,7 +49,7 @@ import java.util.Map; * token to configure 2FA account config); also will need to make users configure 2FA during activation and password setup... * */ @RestController -@RequestMapping("/api") +@RequestMapping("/api/auth/2fa") @RequiredArgsConstructor public class TwoFactorAuthController extends BaseController { @@ -77,132 +57,22 @@ public class TwoFactorAuthController extends BaseController { private final JwtTokenFactory tokenFactory; - @GetMapping("/2fa/account/config") - @PreAuthorize("isAuthenticated()") - public TwoFactorAuthAccountConfig getTwoFactorAuthAccountConfig() throws ThingsboardException { - SecurityUser user = getCurrentUser(); - - return twoFactorAuthService.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); - } - - @PostMapping("/2fa/account/config/generate") - @PreAuthorize("isAuthenticated()") - public TwoFactorAuthAccountConfig generateTwoFactorAuthAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws Exception { - SecurityUser user = getCurrentUser(); - - return twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), providerType, - (provider, providerConfig) -> { - return provider.generateNewAccountConfig(user, providerConfig); - }); - } - - // temporary endpoint for testing purposes - @PostMapping("/2fa/account/config/generate/qr") - @PreAuthorize("isAuthenticated()") - public void generateTwoFactorAuthAccountConfigWithQr(@RequestParam TwoFactorAuthProviderType providerType, HttpServletResponse response) throws Exception { - TwoFactorAuthAccountConfig config = generateTwoFactorAuthAccountConfig(providerType); - if (providerType == TwoFactorAuthProviderType.TOTP) { - BitMatrix qr = new QRCodeWriter().encode(((TotpTwoFactorAuthAccountConfig) config).getAuthUrl(), BarcodeFormat.QR_CODE, 200, 200); - try (ServletOutputStream outputStream = response.getOutputStream()) { - MatrixToImageWriter.writeToStream(qr, "PNG", outputStream); - } - } - response.setHeader("config", JacksonUtil.toString(config)); - } - - @PostMapping("/2fa/account/config/submit") - @PreAuthorize("isAuthenticated()") - public void submitTwoFactorAuthAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { - SecurityUser user = getCurrentUser(); - - twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), - (provider, providerConfig) -> { - provider.prepareVerificationCode(user, providerConfig, accountConfig); - }); - } - - @PostMapping("/2fa/account/config") - @PreAuthorize("isAuthenticated()") - public void verifyAndSaveTwoFactorAuthAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, - @RequestParam String verificationCode) throws Exception { - SecurityUser user = getCurrentUser(); - - boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), accountConfig.getProviderType(), - (provider, providerConfig) -> { - return provider.checkVerificationCode(user, verificationCode, providerConfig, accountConfig); - }); - - if (verificationSuccess) { - twoFactorAuthService.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); - } else { - throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.INVALID_ARGUMENTS); - } - } - - - @GetMapping("/2fa/settings") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public TwoFactorAuthSettings getTwoFactorAuthSettings() throws ThingsboardException { - return twoFactorAuthService.getTwoFaSettings(getTenantId()).orElse(null); - } - - @PostMapping("/2fa/settings") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public void saveTwoFactorAuthSettings(@Valid @RequestBody TwoFactorAuthSettings twoFactorAuthSettings) throws ThingsboardException { - twoFactorAuthService.saveTwoFaSettings(getTenantId(), twoFactorAuthSettings); - } - - - private final Map verificationCodeSendRateLimits = new HashMap<>(); - private final Map verificationCodeCheckRateLimits = new HashMap<>(); - - @PostMapping("/auth/2fa/verification/send") + @PostMapping("/verification/send") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public void sendTwoFaVerificationCode() throws Exception { SecurityUser user = getCurrentUser(); - - TwoFactorAuthSettings twoFaSettings = twoFactorAuthService.getTwoFaSettings(user.getTenantId()).get(); - if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { - TbRateLimits rateLimits = verificationCodeSendRateLimits.computeIfAbsent(user.getSessionId(), sessionId -> { - return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); - }); - if (!rateLimits.tryConsume()) { - throw new ThingsboardException(ThingsboardErrorCode.TOO_MANY_REQUESTS); - } - } - - twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), - (provider, providerConfig, accountConfig) -> { - provider.prepareVerificationCode(user, providerConfig, accountConfig); - }); + twoFactorAuthService.prepareVerificationCode(user, true); } - @PostMapping("/auth/2fa/verification/check") + @PostMapping("/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); - - - - // FIXME [viacheslav]: rate limits for verification code check - boolean verificationSuccess = twoFactorAuthService.processByTwoFaProvider(user.getTenantId(), user.getId(), - (provider, providerConfig, accountConfig) -> { - return provider.checkVerificationCode(user, verificationCode, providerConfig, accountConfig); - }); - - + boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, true); if (verificationSuccess) { + // FIXME [viacheslav]: log login action return tokenFactory.createTokenPair(user); } else { - TwoFactorAuthSettings twoFaSettings = twoFactorAuthService.getTwoFaSettings(user.getTenantId()).get(); - if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { - TbRateLimits rateLimits = verificationCodeSendRateLimits.computeIfAbsent(user.getSessionId(), sessionId -> { - return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); - }); - if (!rateLimits.tryConsume()) { - throw new ThingsboardException(ThingsboardErrorCode.TOO_MANY_REQUESTS); - } - } throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); } } 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 new file mode 100644 index 0000000000..e54a39951f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/DefaultTwoFactorAuthService.java @@ -0,0 +1,151 @@ +/** + * 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.service.security.auth.mfa; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.User; +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.msg.tools.TbRateLimits; +import org.thingsboard.server.dao.user.UserService; +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.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import java.util.Collection; +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +@Service +@RequiredArgsConstructor +public class DefaultTwoFactorAuthService implements TwoFactorAuthService { + + private final TwoFactorAuthConfigManager configManager; + private final SystemSecurityService systemSecurityService; + private final UserService userService; + private final Map> providers = new EnumMap<>(TwoFactorAuthProviderType.class); + + // FIXME [viacheslav]: remove from the map + // TODO [viacheslav]: these rate limits are local, and will work bad in the cluster + private final ConcurrentMap verificationCodeSendingRateLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap verificationCodeCheckingRateLimits = new ConcurrentHashMap<>(); + + private static final ThingsboardException ACCOUNT_NOT_CONFIGURED = new ThingsboardException("2FA is not configured for account", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + private static final ThingsboardException PROVIDER_NOT_CONFIGURED = new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + private static final ThingsboardException PROVIDER_NOT_AVAILABLE = new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.GENERAL); + + + @Override + public void prepareVerificationCode(SecurityUser securityUser, boolean rateLimit) throws Exception { + TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) + .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED); + prepareVerificationCode(securityUser, accountConfig, rateLimit); + } + + @Override + public void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException { + TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + if (rateLimit) { + if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { + TbRateLimits rateLimits = verificationCodeSendingRateLimits.computeIfAbsent(securityUser.getSessionId(), sessionId -> { + return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); + }); + if (!rateLimits.tryConsume()) { + throw new ThingsboardException("Too many verification code sending requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + } + } + + TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + getTwoFaProvider(accountConfig.getProviderType()).prepareVerificationCode(securityUser, providerConfig, accountConfig); + } + + @Override + public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean rateLimit) throws ThingsboardException { + TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) + .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED); + return checkVerificationCode(securityUser, verificationCode, accountConfig, rateLimit); + } + + @Override + public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException { + if (!userService.findUserCredentialsByUserId(securityUser.getTenantId(), securityUser.getId()).isEnabled()) { + throw new ThingsboardException("User is disabled", ThingsboardErrorCode.AUTHENTICATION); + } + + TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + if (rateLimit) { + if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeCheckRateLimit())) { + TbRateLimits rateLimits = verificationCodeCheckingRateLimits.computeIfAbsent(securityUser.getSessionId(), sessionId -> { + return new TbRateLimits(twoFaSettings.getVerificationCodeCheckRateLimit()); + }); + if (!rateLimits.tryConsume()) { + throw new ThingsboardException("Too many verification code checking requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + } + } + + TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + boolean verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(securityUser, verificationCode, providerConfig, accountConfig); + if (rateLimit) { + systemSecurityService.validateTwoFaVerification(securityUser.getTenantId(), securityUser.getId(), verificationSuccess, twoFaSettings); + } + return verificationSuccess; + } + + @Override + public TwoFactorAuthAccountConfig generateNewAccountConfig(User user, TwoFactorAuthProviderType providerType) throws ThingsboardException { + TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(user.getTenantId(), providerType); + return getTwoFaProvider(providerType).generateNewAccountConfig(user, providerConfig); + } + + + private TwoFactorAuthProviderConfig getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) throws ThingsboardException { + return configManager.getTwoFaSettings(tenantId) + .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + } + + private TwoFactorAuthProvider getTwoFaProvider(TwoFactorAuthProviderType providerType) throws ThingsboardException { + return Optional.ofNullable(providers.get(providerType)) + .orElseThrow(() -> PROVIDER_NOT_AVAILABLE); + } + + @Autowired + private void setProviders(Collection> providers) { + providers.forEach(provider -> { + this.providers.put(provider.getType(), provider); + }); + } + +} + diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index 501517ca88..ec2511e62a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -15,171 +15,22 @@ */ package org.thingsboard.server.service.security.auth.mfa; -import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.RequiredArgsConstructor; -import lombok.SneakyThrows; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Service; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.common.util.ThrowingBiConsumer; -import org.thingsboard.common.util.ThrowingBiFunction; -import org.thingsboard.common.util.ThrowingTripleConsumer; -import org.thingsboard.common.util.ThrowingTripleFunction; -import org.thingsboard.server.common.data.AdminSettings; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.User; -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.id.UserId; -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.settings.AdminSettingsService; -import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.model.SecurityUser; -import java.util.Collection; -import java.util.Collections; -import java.util.EnumMap; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ExecutionException; +public interface TwoFactorAuthService { -@Service -@RequiredArgsConstructor -public class TwoFactorAuthService { + void prepareVerificationCode(SecurityUser securityUser, boolean rateLimit) throws Exception; - private final UserService userService; - private final AdminSettingsService adminSettingsService; - private final AttributesService attributesService; - private final Map> providers = new EnumMap<>(TwoFactorAuthProviderType.class); + void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException; - protected static final String TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY = "twoFaConfig"; - protected static final String TWO_FACTOR_AUTH_SETTINGS_KEY = "twoFaSettings"; + boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean rateLimit) throws ThingsboardException; + boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException; - public R processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, ThrowingBiFunction, TwoFactorAuthProviderConfig, R> function) throws Exception { - TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(tenantId, providerType) - .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); - TwoFactorAuthProvider provider = getTwoFaProvider(providerType) - .orElseThrow(() -> new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.ITEM_NOT_FOUND)); - - return function.apply(provider, providerConfig); - } - - public void processByTwoFaProvider(TenantId tenantId, TwoFactorAuthProviderType providerType, ThrowingBiConsumer, TwoFactorAuthProviderConfig> function) throws Exception { - processByTwoFaProvider(tenantId, providerType, (provider, providerConfig) -> { - function.accept(provider, providerConfig); - return null; - }); - } - - public R processByTwoFaProvider(TenantId tenantId, UserId userId, ThrowingTripleFunction, TwoFactorAuthProviderConfig, TwoFactorAuthAccountConfig, R> function) throws Exception { - TwoFactorAuthAccountConfig accountConfig = getTwoFaAccountConfig(tenantId, userId) - .orElseThrow(() -> new ThingsboardException("2FA is not configured for user", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); - - TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) - .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); - TwoFactorAuthProvider provider = getTwoFaProvider(accountConfig.getProviderType()) - .orElseThrow(() -> new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.ITEM_NOT_FOUND)); - - return function.apply(provider, providerConfig, accountConfig); - } - - public void processByTwoFaProvider(TenantId tenantId, UserId userId, ThrowingTripleConsumer, TwoFactorAuthProviderConfig, TwoFactorAuthAccountConfig> function) throws Exception { - processByTwoFaProvider(tenantId, userId, (provider, providerConfig, accountConfig) -> { - function.accept(provider, providerConfig, accountConfig); - return null; - }); - } - - - public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { - User user = userService.findUserById(tenantId, userId); - return Optional.ofNullable(user.getAdditionalInfo()) - .flatMap(additionalInfo -> Optional.ofNullable(additionalInfo.get(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY)).filter(jsonNode -> !jsonNode.isNull())) - .map(jsonNode -> JacksonUtil.treeToValue(jsonNode, TwoFactorAuthAccountConfig.class)) - .filter(twoFactorAuthAccountConfig -> { - return getTwoFaProviderConfig(tenantId, twoFactorAuthAccountConfig.getProviderType()).isPresent(); - }); - } - - public void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { - getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) - .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); - - User user = userService.findUserById(tenantId, userId); - ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) - .orElseGet(JacksonUtil::newObjectNode); - additionalInfo.set(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY, JacksonUtil.valueToTree(accountConfig)); - user.setAdditionalInfo(additionalInfo); - - userService.saveUser(user); - } - - public void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId) { - User user = userService.findUserById(tenantId, userId); - ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) - .orElseGet(JacksonUtil::newObjectNode); - additionalInfo.remove(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY); - user.setAdditionalInfo(additionalInfo); - - userService.saveUser(user); - } - - - @SneakyThrows({InterruptedException.class, ExecutionException.class}) - public Optional getTwoFaSettings(TenantId tenantId) { - if (tenantId.equals(TenantId.SYS_TENANT_ID)) { - return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, 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)); - } - } - - @SneakyThrows({InterruptedException.class, ExecutionException.class}) - public void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings) { - if (tenantId.equals(TenantId.SYS_TENANT_ID)) { - AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) - .orElseGet(() -> { - AdminSettings newSettings = new AdminSettings(); - newSettings.setKey(TWO_FACTOR_AUTH_SETTINGS_KEY); - return newSettings; - }); - settings.setJsonValue(JacksonUtil.valueToTree(twoFactorAuthSettings)); - adminSettingsService.saveAdminSettings(tenantId, settings); - } else { - attributesService.save(TenantId.SYS_TENANT_ID, tenantId, DataConstants.SERVER_SCOPE, Collections.singletonList( - new BaseAttributeKvEntry(new JsonDataEntry(TWO_FACTOR_AUTH_SETTINGS_KEY, JacksonUtil.toString(twoFactorAuthSettings)), System.currentTimeMillis()) - )).get(); - } - } - - - private Optional> getTwoFaProvider(TwoFactorAuthProviderType providerType) { - return Optional.of((TwoFactorAuthProvider) providers.get(providerType)); - } - - private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { - return getTwoFaSettings(tenantId) - .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) - .map(providerConfig -> (C) providerConfig); - } - - @Autowired - private void setProviders(Collection> providers) { - providers.forEach(provider -> { - this.providers.put(provider.getType(), provider); - }); - } + TwoFactorAuthAccountConfig generateNewAccountConfig(User user, TwoFactorAuthProviderType providerType) throws ThingsboardException; } 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 new file mode 100644 index 0000000000..a96d25e520 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java @@ -0,0 +1,139 @@ +/** + * 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.service.security.auth.mfa.config; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.RequiredArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.User; +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.id.UserId; +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.AdminSettingsService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; + +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +@Service +@RequiredArgsConstructor +public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigManager { + + private final UserService userService; + private final AdminSettingsService adminSettingsService; + private final AttributesService attributesService; + + protected static final String TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY = "twoFaConfig"; + protected static final String TWO_FACTOR_AUTH_SETTINGS_KEY = "twoFaSettings"; + + + @Override + public boolean isTwoFaEnabled(User user) { + return getTwoFaAccountConfig(user.getTenantId(), user.getId()).isPresent(); + } + + @Override + public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { + User user = userService.findUserById(tenantId, userId); + return Optional.ofNullable(user.getAdditionalInfo()) + .flatMap(additionalInfo -> Optional.ofNullable(additionalInfo.get(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY)).filter(jsonNode -> !jsonNode.isNull())) + .map(jsonNode -> JacksonUtil.treeToValue(jsonNode, TwoFactorAuthAccountConfig.class)) + .filter(twoFactorAuthAccountConfig -> { + return getTwoFaProviderConfig(tenantId, twoFactorAuthAccountConfig.getProviderType()).isPresent(); + }); + } + + @Override + public void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) + .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); + + User user = userService.findUserById(tenantId, userId); + ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) + .orElseGet(JacksonUtil::newObjectNode); + additionalInfo.set(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY, JacksonUtil.valueToTree(accountConfig)); + user.setAdditionalInfo(additionalInfo); + + userService.saveUser(user); + } + + @Override + public void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId) { + User user = userService.findUserById(tenantId, userId); + ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) + .orElseGet(JacksonUtil::newObjectNode); + additionalInfo.remove(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY); + user.setAdditionalInfo(additionalInfo); + + userService.saveUser(user); + } + + + private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { + return getTwoFaSettings(tenantId) + .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)); + } + + @SneakyThrows({InterruptedException.class, ExecutionException.class}) + @Override + public Optional getTwoFaSettings(TenantId tenantId) { + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, 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)); + } + } + + @SneakyThrows({InterruptedException.class, ExecutionException.class}) + @Override + public void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings) { + if (tenantId.equals(TenantId.SYS_TENANT_ID) || !twoFactorAuthSettings.isUseSystemTwoFactorAuthSettings()) { + ConstraintValidator.validateFields(twoFactorAuthSettings); + } + if (tenantId.equals(TenantId.SYS_TENANT_ID)) { + AdminSettings settings = Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(tenantId, TWO_FACTOR_AUTH_SETTINGS_KEY)) + .orElseGet(() -> { + AdminSettings newSettings = new AdminSettings(); + newSettings.setKey(TWO_FACTOR_AUTH_SETTINGS_KEY); + return newSettings; + }); + settings.setJsonValue(JacksonUtil.valueToTree(twoFactorAuthSettings)); + adminSettingsService.saveAdminSettings(tenantId, settings); + } else { + attributesService.save(TenantId.SYS_TENANT_ID, tenantId, DataConstants.SERVER_SCOPE, Collections.singletonList( + new BaseAttributeKvEntry(new JsonDataEntry(TWO_FACTOR_AUTH_SETTINGS_KEY, JacksonUtil.toString(twoFactorAuthSettings)), System.currentTimeMillis()) + )).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 new file mode 100644 index 0000000000..94c18aa999 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java @@ -0,0 +1,41 @@ +/** + * 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.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.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; + +import java.util.Optional; + +public interface TwoFactorAuthConfigManager { + + boolean isTwoFaEnabled(User user); + + Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId); + + void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException; + + void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId); + + + Optional getTwoFaSettings(TenantId tenantId); + + void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings); + +} 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 8b8927f721..e70742267b 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 @@ -15,12 +15,14 @@ */ package org.thingsboard.server.service.security.auth.mfa.config; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; import org.checkerframework.checker.index.qual.NonNegative; import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.Valid; +import javax.validation.constraints.AssertTrue; import javax.validation.constraints.Min; import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; @@ -30,17 +32,21 @@ import java.util.Optional; @Data public class TwoFactorAuthSettings { - @NotNull - private Boolean useSystemTwoFactorAuthSettings; + private boolean useSystemTwoFactorAuthSettings; @Valid private List providers; - @Pattern(regexp = "\\d+:\\d+") - private String verificationCodeSendRateLimit; // 1:60 - one time in a minute - @Pattern(regexp = "\\d+:\\d+") - private String verificationCodeCheckRateLimit; // soft lockout, on session level + @ApiModelProperty(example = "1:60 (1 request per minute)") + @Pattern(regexp = "[^0]\\d+:[^0]\\d+", message = "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") + private String verificationCodeCheckRateLimit; @Min(0) - private Integer maxVerificationCodeSubmitAttemptsBeforeUserBlocking; + private int maxCodeVerificationFailuresBeforeUserLockout; + @ApiModelProperty(value = "in seconds") + @Min(1) + private int totalAllowedTimeForVerification; public Optional getProviderConfig(TwoFactorAuthProviderType providerType) { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java index c18e4c41c1..dfbba22113 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java @@ -17,13 +17,18 @@ package org.thingsboard.server.service.security.auth.mfa.config.account; import lombok.Data; import lombok.EqualsAndHashCode; +import org.apache.commons.lang3.StringUtils; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import javax.validation.constraints.AssertTrue; +import javax.validation.constraints.Email; + @EqualsAndHashCode(callSuper = true) @Data public class EmailTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { - private boolean useAccountEmail; // TODO [viacheslav]: validate + private boolean useAccountEmail; + @Email(message = "Email is not valid") private String email; @Override @@ -31,4 +36,10 @@ public class EmailTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccoun return TwoFactorAuthProviderType.EMAIL; } + + @AssertTrue(message = "Email must be specified") // TODO [viacheslav]: test ! + private boolean isValid() { + return useAccountEmail || StringUtils.isNotEmpty(email); + } + } 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 82e3760e35..40e94899a7 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 @@ -20,12 +20,14 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { @NotBlank + @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/TwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java index 141535eea8..44774bb2a3 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.security.auth.mfa.config.account; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @@ -26,8 +27,9 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr use = JsonTypeInfo.Id.NAME, property = "providerType") @JsonSubTypes({ - @JsonSubTypes.Type(value = TotpTwoFactorAuthAccountConfig.class, name = "TOTP"), - @JsonSubTypes.Type(value = SmsTwoFactorAuthAccountConfig.class, name = "SMS"), + @Type(name = "TOTP", value = TotpTwoFactorAuthAccountConfig.class ), + @Type(name = "SMS", value = SmsTwoFactorAuthAccountConfig.class), + @Type(name = "EMAIL", value = EmailTwoFactorAuthAccountConfig.class) }) public interface TwoFactorAuthAccountConfig { 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 c7169def48..fa1e5d7b43 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 @@ -15,9 +15,14 @@ */ package org.thingsboard.server.service.security.auth.mfa.config.provider; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; +import javax.validation.constraints.Min; + @Data public abstract class OtpBasedTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { - private Integer verificationCodeLifetime; // seconds + @ApiModelProperty(value = "in seconds", example = "60") + @Min(1) // TODO [viacheslav]: test + private int verificationCodeLifetime; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java index a86bcee222..c94c403fd8 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java @@ -18,7 +18,9 @@ package org.thingsboard.server.service.security.auth.mfa.config.provider; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import org.thingsboard.server.service.security.auth.mfa.config.account.EmailTwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @JsonIgnoreProperties(ignoreUnknown = true) @@ -26,8 +28,9 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr use = JsonTypeInfo.Id.NAME, property = "providerType") @JsonSubTypes({ - @JsonSubTypes.Type(value = TotpTwoFactorAuthProviderConfig.class, name = "TOTP"), - @JsonSubTypes.Type(value = SmsTwoFactorAuthProviderConfig.class, name = "SMS"), + @Type(name = "TOTP", value = TotpTwoFactorAuthProviderConfig.class), + @Type(name = "SMS", value = SmsTwoFactorAuthProviderConfig.class), + @Type(name = "EMAIL", value = EmailTwoFactorAuthAccountConfig.class) }) public interface TwoFactorAuthProviderConfig { 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 f8d07aedb2..e13e0925d5 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 @@ -40,8 +40,7 @@ public abstract class OtpBasedTwoFactorAuthProvider TimeUnit.SECONDS.toMillis(providerConfig.getVerificationCodeLifetime())) { + verificationCodesCache.evict(user.getSessionId()); + return false; + } + if (verificationCode.equals(correctVerificationCode.getValue()) + && correctVerificationCode.getConfig().equals(accountConfig)) { verificationCodesCache.evict(user.getSessionId()); return true; } @@ -65,6 +70,7 @@ public abstract class OtpBasedTwoFactorAuthProvider 0) { if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { - userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userCredentials.getUserId(), false); - if (StringUtils.isNoneBlank(securitySettings.getUserLockoutNotificationEmail())) { - try { - mailService.sendAccountLockoutEmail(username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); - } catch (ThingsboardException e) { - log.warn("Can't send email regarding user account [{}] lockout to provided email [{}]", username, securitySettings.getUserLockoutNotificationEmail(), e); - } - } + lockAccount(userCredentials.getUserId(), username, securitySettings); throw new LockedException("Authentication Failed. Username was locked due to security policy."); } } @@ -143,6 +139,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { throw new DisabledException("User is not active"); } + // FIXME [viacheslav]: don't do that in case of 2FA. maybe just move underlying setLastLoginTs to logLoginAction ? userService.onUserLoginSuccessful(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = self.getSecuritySettings(tenantId); @@ -156,6 +153,43 @@ public class DefaultSystemSecurityService implements SystemSecurityService { } } + @Override + public void validateTwoFaVerification(TenantId tenantId, UserId userId, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings) { + User user = userService.findUserById(tenantId, userId); + ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) + .filter(jsonNode -> jsonNode instanceof ObjectNode) + .orElseGet(JacksonUtil::newObjectNode); + // TODO [viacheslav]: test ! + int failedVerificationAttempts = Optional.ofNullable(additionalInfo.get("failedTwoFaVerificationAttempts")) + .map(JsonNode::asInt).orElse(0); + + if (!verificationSuccess) { + failedVerificationAttempts++; + // TODO [viacheslav]: maybe use userService.onUserLoginIncorrectCredentials() + } else { + failedVerificationAttempts = 0; + // and set last login ts + } + + if (twoFaSettings.getMaxCodeVerificationFailuresBeforeUserLockout() > 0 + && failedVerificationAttempts >= twoFaSettings.getMaxCodeVerificationFailuresBeforeUserLockout()) { + userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + lockAccount(userId, user.getEmail(), self.getSecuritySettings(tenantId)); + throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); + } + } + + private void lockAccount(UserId userId, String username, SecuritySettings securitySettings) { + userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + if (StringUtils.isNoneBlank(securitySettings.getUserLockoutNotificationEmail())) { + try { + mailService.sendAccountLockoutEmail(username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); + } catch (ThingsboardException e) { + log.warn("Can't send email regarding user account [{}] lockout to provided email [{}]", username, securitySettings.getUserLockoutNotificationEmail(), e); + } + } + } + @Override public void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException { SecuritySettings securitySettings = self.getSecuritySettings(tenantId); diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 2cc19ccac6..453251bf03 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -18,9 +18,11 @@ package org.thingsboard.server.service.security.system; import org.springframework.security.core.AuthenticationException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; import javax.servlet.http.HttpServletRequest; @@ -32,6 +34,8 @@ public interface SystemSecurityService { void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException; + void validateTwoFaVerification(TenantId tenantId, UserId userId, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings); + void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException; String getBaseUrl(TenantId tenantId, CustomerId customerId, HttpServletRequest httpServletRequest); 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 a12c7ec0e8..b338d0d99d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -45,6 +45,7 @@ import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; // TODO [viacheslav]: test sessionId +// TODO [viacheslav]: test validation for all account configs, provider configs and two factor auth settings public abstract class TwoFactorAuthTest extends AbstractControllerTest { @SpyBean diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index d48520648e..1ee78d4068 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -301,7 +301,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic public void onUserLoginSuccessful(TenantId tenantId, UserId userId) { log.trace("Executing onUserLoginSuccessful [{}]", userId); User user = findUserById(tenantId, userId); - setLastLoginTs(user); + setLastLoginTs(user); // FIXME [viacheslav]: move to logLoginAction ? resetFailedLoginAttempts(user); saveUser(user); } From 052068d7f48cd1639355fc6bb89bece6d0b9b7ee Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Sun, 20 Mar 2022 09:21:41 +0200 Subject: [PATCH 07/92] Remove 2FA with email message --- .../controller/TwoFactorAuthController.java | 4 -- .../auth/mfa/DefaultTwoFactorAuthService.java | 3 +- .../EmailTwoFactorAuthAccountConfig.java | 45 ------------- .../account/TwoFactorAuthAccountConfig.java | 3 +- .../EmailTwoFactorAuthProviderConfig.java | 33 --------- .../provider/TwoFactorAuthProviderConfig.java | 4 +- .../impl/EmailTwoFactorAuthProvider.java | 67 ------------------- 7 files changed, 3 insertions(+), 156 deletions(-) delete mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFactorAuthProvider.java 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 58a7af3d7a..76cc8f2f14 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -29,11 +29,7 @@ import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; /* - * * TODO [viacheslav]: - * - Configurable hardlock (user blocking) after a total of XX (10) unsuccessful attempts - on user level - * - * FIXME [viacheslav]: * - Tests for 2FA * - Swagger documentation * 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 e54a39951f..98cbeb0786 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 @@ -141,11 +141,10 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { } @Autowired - private void setProviders(Collection> providers) { + private void setProviders(Collection providers) { providers.forEach(provider -> { this.providers.put(provider.getType(), provider); }); } } - diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java deleted file mode 100644 index dfbba22113..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/EmailTwoFactorAuthAccountConfig.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 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.service.security.auth.mfa.config.account; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import org.apache.commons.lang3.StringUtils; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; - -import javax.validation.constraints.AssertTrue; -import javax.validation.constraints.Email; - -@EqualsAndHashCode(callSuper = true) -@Data -public class EmailTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { - - private boolean useAccountEmail; - @Email(message = "Email is not valid") - private String email; - - @Override - public TwoFactorAuthProviderType getProviderType() { - return TwoFactorAuthProviderType.EMAIL; - } - - - @AssertTrue(message = "Email must be specified") // TODO [viacheslav]: test ! - private boolean isValid() { - return useAccountEmail || StringUtils.isNotEmpty(email); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java index 44774bb2a3..d1947a72be 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java @@ -28,8 +28,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr property = "providerType") @JsonSubTypes({ @Type(name = "TOTP", value = TotpTwoFactorAuthAccountConfig.class ), - @Type(name = "SMS", value = SmsTwoFactorAuthAccountConfig.class), - @Type(name = "EMAIL", value = EmailTwoFactorAuthAccountConfig.class) + @Type(name = "SMS", value = SmsTwoFactorAuthAccountConfig.class) }) public interface TwoFactorAuthAccountConfig { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java deleted file mode 100644 index a782f73a68..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/EmailTwoFactorAuthProviderConfig.java +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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.service.security.auth.mfa.config.provider; - -import lombok.Data; -import lombok.EqualsAndHashCode; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; - -@EqualsAndHashCode(callSuper = true) -@Data -public class EmailTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig{ - - private String emailVerificationMessageTemplate; // FIXME [viacheslav]: - - @Override - public TwoFactorAuthProviderType getProviderType() { - return TwoFactorAuthProviderType.EMAIL; - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java index c94c403fd8..f912f43144 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java @@ -20,7 +20,6 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.thingsboard.server.service.security.auth.mfa.config.account.EmailTwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @JsonIgnoreProperties(ignoreUnknown = true) @@ -29,8 +28,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr property = "providerType") @JsonSubTypes({ @Type(name = "TOTP", value = TotpTwoFactorAuthProviderConfig.class), - @Type(name = "SMS", value = SmsTwoFactorAuthProviderConfig.class), - @Type(name = "EMAIL", value = EmailTwoFactorAuthAccountConfig.class) + @Type(name = "SMS", value = SmsTwoFactorAuthProviderConfig.class) }) public interface TwoFactorAuthProviderConfig { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFactorAuthProvider.java deleted file mode 100644 index aa34d5b2d6..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFactorAuthProvider.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * 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.service.security.auth.mfa.provider.impl; - -import org.springframework.cache.CacheManager; -import org.springframework.stereotype.Service; -import org.thingsboard.rule.engine.api.MailService; -import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.security.auth.mfa.config.account.EmailTwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.EmailTwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; -import org.thingsboard.server.service.security.model.SecurityUser; - -@Service -@TbCoreComponent -public class EmailTwoFactorAuthProvider extends OtpBasedTwoFactorAuthProvider { - - private final MailService mailService; - - protected EmailTwoFactorAuthProvider(CacheManager cacheManager, MailService mailService) { - super(cacheManager); - this.mailService = mailService; - } - - - @Override - public EmailTwoFactorAuthAccountConfig generateNewAccountConfig(User user, EmailTwoFactorAuthProviderConfig providerConfig) { - EmailTwoFactorAuthAccountConfig accountConfig = new EmailTwoFactorAuthAccountConfig(); - accountConfig.setUseAccountEmail(true); - return accountConfig; - } - - @Override - protected void sendVerificationCode(SecurityUser user, String verificationCode, EmailTwoFactorAuthProviderConfig providerConfig, EmailTwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { - String email; - if (accountConfig.isUseAccountEmail()) { - email = user.getEmail(); - } else { - email = accountConfig.getEmail(); - } - - // FIXME [viacheslav]: mail template for 2FA verification - mailService.sendEmail(user.getTenantId(), email, "subject", ""); - } - - - @Override - public TwoFactorAuthProviderType getType() { - return TwoFactorAuthProviderType.EMAIL; - } - -} From 20a4f3cc4c3f15a3d9114cb3cb60613bade4ce36 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Sun, 20 Mar 2022 14:13:54 +0200 Subject: [PATCH 08/92] 2FA: lock account after X unsuccessful verification attempts; refactoring --- .../controller/TwoFactorAuthController.java | 15 ++-- .../RefreshTokenAuthenticationProvider.java | 3 +- .../auth/mfa/DefaultTwoFactorAuthService.java | 38 ++++---- .../mfa/config/TwoFactorAuthSettings.java | 6 +- .../mfa/provider/TwoFactorAuthProvider.java | 4 +- .../impl/OtpBasedTwoFactorAuthProvider.java | 14 +-- .../impl/TotpTwoFactorAuthProvider.java | 2 +- .../auth/rest/RestAuthenticationProvider.java | 60 +------------ ...RestAwareAuthenticationSuccessHandler.java | 5 +- .../service/security/model/SecurityUser.java | 11 --- .../security/model/token/JwtTokenFactory.java | 6 -- .../system/DefaultSystemSecurityService.java | 88 +++++++++++++++---- .../system/SystemSecurityService.java | 11 ++- .../server/dao/user/UserService.java | 11 ++- .../server/dao/user/UserServiceImpl.java | 15 ++-- 15 files changed, 145 insertions(+), 144 deletions(-) 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 76cc8f2f14..125febeb6a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -17,24 +17,25 @@ package org.thingsboard.server.controller; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +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.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; /* * TODO [viacheslav]: * - Tests for 2FA * - Swagger documentation - * * */ -// TODO [viacheslav]: maybe get rid of sessionId concept.. /* * @@ -51,6 +52,7 @@ public class TwoFactorAuthController extends BaseController { private final TwoFactorAuthService twoFactorAuthService; private final JwtTokenFactory tokenFactory; + private final SystemSecurityService systemSecurityService; @PostMapping("/verification/send") @@ -62,14 +64,17 @@ public class TwoFactorAuthController extends BaseController { @PostMapping("/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode) throws Exception { + public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode, Authentication authentication) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, true); if (verificationSuccess) { - // FIXME [viacheslav]: log login action + systemSecurityService.logLoginAction(user, authentication, ActionType.LOGIN, null); return tokenFactory.createTokenPair(user); } else { - throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); + ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); + // FIXME [viacheslav]: log login action: no authentication details + systemSecurityService.logLoginAction(user, authentication, ActionType.LOGIN, error); + throw error; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index b744adcdaf..665451b61f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -33,11 +33,11 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; @@ -66,7 +66,6 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide } else { securityUser = authenticateByPublicId(principal.getValue()); } - securityUser.setSessionId(unsafeUser.getSessionId()); if (tokenOutdatingService.isOutdated(rawAccessToken, securityUser.getId())) { throw new CredentialsExpiredException("Token is outdated"); 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 98cbeb0786..a06184ff6c 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 @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.User; 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.id.UserId; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; @@ -50,30 +51,29 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { private final UserService userService; private final Map> providers = new EnumMap<>(TwoFactorAuthProviderType.class); - // FIXME [viacheslav]: remove from the map // TODO [viacheslav]: these rate limits are local, and will work bad in the cluster - private final ConcurrentMap verificationCodeSendingRateLimits = new ConcurrentHashMap<>(); - private final ConcurrentMap verificationCodeCheckingRateLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap verificationCodeSendingRateLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap verificationCodeCheckingRateLimits = new ConcurrentHashMap<>(); - private static final ThingsboardException ACCOUNT_NOT_CONFIGURED = new ThingsboardException("2FA is not configured for account", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - private static final ThingsboardException PROVIDER_NOT_CONFIGURED = new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS); - private static final ThingsboardException PROVIDER_NOT_AVAILABLE = new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.GENERAL); + private static final ThingsboardException ACCOUNT_NOT_CONFIGURED_ERROR = new ThingsboardException("2FA is not configured for account", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + private static final ThingsboardException PROVIDER_NOT_CONFIGURED_ERROR = new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS); + private static final ThingsboardException PROVIDER_NOT_AVAILABLE_ERROR = new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.GENERAL); @Override public void prepareVerificationCode(SecurityUser securityUser, boolean rateLimit) throws Exception { TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) - .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED); + .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); prepareVerificationCode(securityUser, accountConfig, rateLimit); } @Override public void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException { TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) - .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); if (rateLimit) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { - TbRateLimits rateLimits = verificationCodeSendingRateLimits.computeIfAbsent(securityUser.getSessionId(), sessionId -> { + TbRateLimits rateLimits = verificationCodeSendingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); }); if (!rateLimits.tryConsume()) { @@ -83,14 +83,14 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { } TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) - .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); getTwoFaProvider(accountConfig.getProviderType()).prepareVerificationCode(securityUser, providerConfig, accountConfig); } @Override public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean rateLimit) throws ThingsboardException { TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) - .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED); + .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); return checkVerificationCode(securityUser, verificationCode, accountConfig, rateLimit); } @@ -101,10 +101,10 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { } TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) - .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); if (rateLimit) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeCheckRateLimit())) { - TbRateLimits rateLimits = verificationCodeCheckingRateLimits.computeIfAbsent(securityUser.getSessionId(), sessionId -> { + TbRateLimits rateLimits = verificationCodeCheckingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { return new TbRateLimits(twoFaSettings.getVerificationCodeCheckRateLimit()); }); if (!rateLimits.tryConsume()) { @@ -114,10 +114,14 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { } TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) - .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); boolean verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(securityUser, verificationCode, providerConfig, accountConfig); if (rateLimit) { - systemSecurityService.validateTwoFaVerification(securityUser.getTenantId(), securityUser.getId(), verificationSuccess, twoFaSettings); + systemSecurityService.validateTwoFaVerification(securityUser, verificationSuccess, twoFaSettings); + if (verificationSuccess) { + verificationCodeCheckingRateLimits.remove(securityUser.getId()); + verificationCodeSendingRateLimits.remove(securityUser.getId()); + } } return verificationSuccess; } @@ -132,12 +136,12 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { private TwoFactorAuthProviderConfig getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) throws ThingsboardException { return configManager.getTwoFaSettings(tenantId) .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) - .orElseThrow(() -> PROVIDER_NOT_CONFIGURED); + .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); } private TwoFactorAuthProvider getTwoFaProvider(TwoFactorAuthProviderType providerType) throws ThingsboardException { return Optional.ofNullable(providers.get(providerType)) - .orElseThrow(() -> PROVIDER_NOT_AVAILABLE); + .orElseThrow(() -> PROVIDER_NOT_AVAILABLE_ERROR); } @Autowired 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 e70742267b..0866aafbe1 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 @@ -17,14 +17,11 @@ package org.thingsboard.server.service.security.auth.mfa.config; import io.swagger.annotations.ApiModelProperty; import lombok.Data; -import org.checkerframework.checker.index.qual.NonNegative; import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.Valid; -import javax.validation.constraints.AssertTrue; import javax.validation.constraints.Min; -import javax.validation.constraints.NotNull; import javax.validation.constraints.Pattern; import java.util.List; import java.util.Optional; @@ -42,9 +39,10 @@ public class TwoFactorAuthSettings { @ApiModelProperty(example = "3:900 (3 requests per 15 minutes)") @Pattern(regexp = "[^0]\\d+:[^0]\\d+", message = "Rate limit configuration is invalid") private String verificationCodeCheckRateLimit; + @ApiModelProperty(example = "10") @Min(0) private int maxCodeVerificationFailuresBeforeUserLockout; - @ApiModelProperty(value = "in seconds") + @ApiModelProperty(value = "in minutes", example = "60") @Min(1) private int totalAllowedTimeForVerification; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java index 858b5995f7..030482ac6d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java @@ -25,9 +25,9 @@ public interface TwoFactorAuthProvider TimeUnit.SECONDS.toMillis(providerConfig.getVerificationCodeLifetime())) { - verificationCodesCache.evict(user.getSessionId()); + verificationCodesCache.evict(securityUser.getId()); return false; } if (verificationCode.equals(correctVerificationCode.getValue()) && correctVerificationCode.getConfig().equals(accountConfig)) { - verificationCodesCache.evict(user.getSessionId()); + verificationCodesCache.evict(securityUser.getId()); return true; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java index 65574fc760..83767ee3d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java @@ -45,7 +45,7 @@ public class TotpTwoFactorAuthProvider implements TwoFactorAuthProvider authorities; private boolean enabled; private UserPrincipal userPrincipal; - private String sessionId; public SecurityUser() { super(); @@ -46,7 +44,6 @@ public class SecurityUser extends User { super(user); this.enabled = enabled; this.userPrincipal = userPrincipal; - this.sessionId = UUID.randomUUID().toString(); } public Collection getAuthorities() { @@ -74,12 +71,4 @@ public class SecurityUser extends User { this.userPrincipal = userPrincipal; } - public String getSessionId() { - return sessionId; - } - - public void setSessionId(String sessionId) { - this.sessionId = sessionId; - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index 36f64b3566..8121ebf834 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -60,7 +60,6 @@ public class JwtTokenFactory { private static final String IS_PUBLIC = "isPublic"; private static final String TENANT_ID = "tenantId"; private static final String CUSTOMER_ID = "customerId"; - private static final String SESSION_ID = "sessionId"; private final JwtSettings settings; @@ -116,7 +115,6 @@ public class JwtTokenFactory { } else if (securityUser.getAuthority() == Authority.SYS_ADMIN) { securityUser.setTenantId(TenantId.SYS_TENANT_ID); } - securityUser.setSessionId(claims.get(SESSION_ID, String.class)); if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_TOKEN) { securityUser.setFirstName(claims.get(FIRST_NAME, String.class)); @@ -162,7 +160,6 @@ public class JwtTokenFactory { UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class)))); securityUser.setUserPrincipal(principal); - securityUser.setSessionId(claims.get(SESSION_ID, String.class)); return securityUser; } @@ -183,9 +180,6 @@ public class JwtTokenFactory { Claims claims = Jwts.claims().setSubject(principal.getValue()); claims.put(USER_ID, securityUser.getId().getId().toString()); claims.put(SCOPES, scopes); - if (securityUser.getSessionId() != null) { - claims.put(SESSION_ID, securityUser.getSessionId()); - } ZonedDateTime currentTime = ZonedDateTime.now(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 646562f9ed..6e6815ff5e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -34,6 +34,7 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -41,6 +42,7 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; @@ -48,20 +50,23 @@ import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserServiceImpl; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; +import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.utils.MiscUtils; +import ua_parser.Client; import javax.annotation.Resource; import javax.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.concurrent.TimeUnit; import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE; @@ -82,6 +87,9 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Autowired private MailService mailService; + @Autowired + private AuditLogService auditLogService; + @Resource private SystemSecurityService self; @@ -124,7 +132,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Override public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { if (!encoder.matches(password, userCredentials.getPassword())) { - int failedLoginAttempts = userService.onUserLoginIncorrectCredentials(tenantId, userCredentials.getUserId()); + int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = self.getSecuritySettings(tenantId); if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { @@ -139,8 +147,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { throw new DisabledException("User is not active"); } - // FIXME [viacheslav]: don't do that in case of 2FA. maybe just move underlying setLastLoginTs to logLoginAction ? - userService.onUserLoginSuccessful(tenantId, userCredentials.getUserId()); + userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = self.getSecuritySettings(tenantId); if (isPositiveInteger(securitySettings.getPasswordPolicy().getPasswordExpirationPeriodDays())) { @@ -154,27 +161,21 @@ public class DefaultSystemSecurityService implements SystemSecurityService { } @Override - public void validateTwoFaVerification(TenantId tenantId, UserId userId, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings) { - User user = userService.findUserById(tenantId, userId); - ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) - .filter(jsonNode -> jsonNode instanceof ObjectNode) - .orElseGet(JacksonUtil::newObjectNode); - // TODO [viacheslav]: test ! - int failedVerificationAttempts = Optional.ofNullable(additionalInfo.get("failedTwoFaVerificationAttempts")) - .map(JsonNode::asInt).orElse(0); + public void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings) { + TenantId tenantId = securityUser.getTenantId(); + UserId userId = securityUser.getId(); + int failedVerificationAttempts = 0; if (!verificationSuccess) { - failedVerificationAttempts++; - // TODO [viacheslav]: maybe use userService.onUserLoginIncorrectCredentials() + failedVerificationAttempts = userService.increaseFailedLoginAttempts(tenantId, userId); } else { - failedVerificationAttempts = 0; - // and set last login ts + userService.resetFailedLoginAttempts(tenantId, userId); } if (twoFaSettings.getMaxCodeVerificationFailuresBeforeUserLockout() > 0 && failedVerificationAttempts >= twoFaSettings.getMaxCodeVerificationFailuresBeforeUserLockout()) { userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); - lockAccount(userId, user.getEmail(), self.getSecuritySettings(tenantId)); + lockAccount(userId, securityUser.getEmail(), self.getSecuritySettings(tenantId)); throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); } } @@ -255,6 +256,59 @@ public class DefaultSystemSecurityService implements SystemSecurityService { return baseUrl; } + @Override + public void logLoginAction(User user, Authentication authentication, ActionType actionType, Exception e) { + String clientAddress = "Unknown"; + String browser = "Unknown"; + String os = "Unknown"; + String device = "Unknown"; + if (authentication != null && authentication.getDetails() != null) { + if (authentication.getDetails() instanceof RestAuthenticationDetails) { + RestAuthenticationDetails details = (RestAuthenticationDetails) authentication.getDetails(); + clientAddress = details.getClientAddress(); + if (details.getUserAgent() != null) { + Client userAgent = details.getUserAgent(); + if (userAgent.userAgent != null) { + browser = userAgent.userAgent.family; + if (userAgent.userAgent.major != null) { + browser += " " + userAgent.userAgent.major; + if (userAgent.userAgent.minor != null) { + browser += "." + userAgent.userAgent.minor; + if (userAgent.userAgent.patch != null) { + browser += "." + userAgent.userAgent.patch; + } + } + } + } + if (userAgent.os != null) { + os = userAgent.os.family; + if (userAgent.os.major != null) { + os += " " + userAgent.os.major; + if (userAgent.os.minor != null) { + os += "." + userAgent.os.minor; + if (userAgent.os.patch != null) { + os += "." + userAgent.os.patch; + if (userAgent.os.patchMinor != null) { + os += "." + userAgent.os.patchMinor; + } + } + } + } + } + if (userAgent.device != null) { + device = userAgent.device.family; + } + } + } + } + if (actionType == ActionType.LOGIN && e == null) { + userService.setLastLoginTs(user.getTenantId(), user.getId()); + } + auditLogService.logEntityAction( + user.getTenantId(), user.getCustomerId(), user.getId(), + user.getName(), user.getId(), null, actionType, e, clientAddress, browser, os, device); + } + private static boolean isPositiveInteger(Integer val) { return val != null && val.intValue() > 0; } diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 453251bf03..f6b401da4e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.service.security.system; +import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; -import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; +import org.thingsboard.server.service.security.model.SecurityUser; import javax.servlet.http.HttpServletRequest; @@ -34,10 +37,12 @@ public interface SystemSecurityService { void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException; - void validateTwoFaVerification(TenantId tenantId, UserId userId, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings); + void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings); void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException; String getBaseUrl(TenantId tenantId, CustomerId customerId, HttpServletRequest httpServletRequest); + void logLoginAction(User user, Authentication authentication, ActionType actionType, Exception e); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index e8ddce587e..d5d29efa40 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -56,16 +56,19 @@ public interface UserService { PageData findUsersByTenantId(TenantId tenantId, PageLink pageLink); PageData findTenantAdmins(TenantId tenantId, PageLink pageLink); - + void deleteTenantAdmins(TenantId tenantId); PageData findCustomerUsers(TenantId tenantId, CustomerId customerId, PageLink pageLink); - + void deleteCustomerUsers(TenantId tenantId, CustomerId customerId); void setUserCredentialsEnabled(TenantId tenantId, UserId userId, boolean enabled); - void onUserLoginSuccessful(TenantId tenantId, UserId userId); + void resetFailedLoginAttempts(TenantId tenantId, UserId userId); + + int increaseFailedLoginAttempts(TenantId tenantId, UserId userId); + + void setLastLoginTs(TenantId tenantId, UserId userId); - int onUserLoginIncorrectCredentials(TenantId tenantId, UserId userId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 1ee78d4068..b8c7b8e9f9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -298,34 +298,35 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic @Override - public void onUserLoginSuccessful(TenantId tenantId, UserId userId) { + public void resetFailedLoginAttempts(TenantId tenantId, UserId userId) { log.trace("Executing onUserLoginSuccessful [{}]", userId); User user = findUserById(tenantId, userId); - setLastLoginTs(user); // FIXME [viacheslav]: move to logLoginAction ? resetFailedLoginAttempts(user); saveUser(user); } - private void setLastLoginTs(User user) { + private void resetFailedLoginAttempts(User user) { JsonNode additionalInfo = user.getAdditionalInfo(); if (!(additionalInfo instanceof ObjectNode)) { additionalInfo = JacksonUtil.newObjectNode(); } - ((ObjectNode) additionalInfo).put(LAST_LOGIN_TS, System.currentTimeMillis()); + ((ObjectNode) additionalInfo).put(FAILED_LOGIN_ATTEMPTS, 0); user.setAdditionalInfo(additionalInfo); } - private void resetFailedLoginAttempts(User user) { + @Override + public void setLastLoginTs(TenantId tenantId, UserId userId) { + User user = findUserById(tenantId, userId); JsonNode additionalInfo = user.getAdditionalInfo(); if (!(additionalInfo instanceof ObjectNode)) { additionalInfo = JacksonUtil.newObjectNode(); } - ((ObjectNode) additionalInfo).put(FAILED_LOGIN_ATTEMPTS, 0); + ((ObjectNode) additionalInfo).put(LAST_LOGIN_TS, System.currentTimeMillis()); user.setAdditionalInfo(additionalInfo); } @Override - public int onUserLoginIncorrectCredentials(TenantId tenantId, UserId userId) { + public int increaseFailedLoginAttempts(TenantId tenantId, UserId userId) { log.trace("Executing onUserLoginIncorrectCredentials [{}]", userId); User user = findUserById(tenantId, userId); int failedLoginAttempts = increaseFailedLoginAttempts(user); From ea7f559e23bd8429f6d10835b99318dd77f17c33 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Sun, 20 Mar 2022 14:59:25 +0200 Subject: [PATCH 09/92] 2FA: log login action, fix user lockout --- .../controller/TwoFactorAuthController.java | 10 +++-- .../mfa/config/TwoFactorAuthSettings.java | 2 +- .../TotpTwoFactorAuthAccountConfig.java | 2 +- .../auth/rest/RestAuthenticationProvider.java | 6 +-- .../system/DefaultSystemSecurityService.java | 44 +++++++++---------- .../system/SystemSecurityService.java | 3 +- .../resources/templates/account.lockout.ftl | 2 +- .../server/controller/TwoFactorAuthTest.java | 2 +- 8 files changed, 35 insertions(+), 36 deletions(-) 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 125febeb6a..1b1983cdf8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -26,11 +26,14 @@ 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.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; import org.thingsboard.server.service.security.system.SystemSecurityService; +import javax.servlet.http.HttpServletRequest; + /* * TODO [viacheslav]: * - Tests for 2FA @@ -64,16 +67,15 @@ public class TwoFactorAuthController extends BaseController { @PostMapping("/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode, Authentication authentication) throws Exception { + public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, true); if (verificationSuccess) { - systemSecurityService.logLoginAction(user, authentication, ActionType.LOGIN, null); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, null); return tokenFactory.createTokenPair(user); } else { ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); - // FIXME [viacheslav]: log login action: no authentication details - systemSecurityService.logLoginAction(user, authentication, ActionType.LOGIN, error); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); throw error; } } 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 0866aafbe1..ae39065e74 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 @@ -41,7 +41,7 @@ public class TwoFactorAuthSettings { private String verificationCodeCheckRateLimit; @ApiModelProperty(example = "10") @Min(0) - private int maxCodeVerificationFailuresBeforeUserLockout; + private int maxVerificationFailuresBeforeUserLockout; @ApiModelProperty(value = "in minutes", example = "60") @Min(1) private int totalAllowedTimeForVerification; 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 78ab4cf80b..93975d49ad 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 @@ -25,7 +25,7 @@ import javax.validation.constraints.Pattern; public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { @NotBlank -// @Pattern(regexp = ) // TODO [viacheslav]: validate otp auth url by pattern +// @Pattern(regexp = "otpauth://totp/") // FIXME [viacheslav]: validate otp auth url by pattern private String authUrl; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java index 8429c70506..71d16d46d0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java @@ -85,7 +85,7 @@ public class RestAuthenticationProvider implements AuthenticationProvider { if (twoFactorAuthConfigManager.isTwoFaEnabled(securityUser)) { return new MfaAuthenticationToken(securityUser); } else { - systemSecurityService.logLoginAction((User) authentication.getPrincipal(), authentication, ActionType.LOGIN, null); + systemSecurityService.logLoginAction((User) authentication.getPrincipal(), authentication.getDetails(), ActionType.LOGIN, null); } } else { String publicId = userPrincipal.getValue(); @@ -111,7 +111,7 @@ public class RestAuthenticationProvider implements AuthenticationProvider { try { systemSecurityService.validateUserCredentials(user.getTenantId(), userCredentials, username, password); } catch (LockedException e) { - systemSecurityService.logLoginAction(user, authentication, ActionType.LOCKOUT, null); + systemSecurityService.logLoginAction(user, authentication.getDetails(), ActionType.LOCKOUT, null); throw e; } @@ -120,7 +120,7 @@ public class RestAuthenticationProvider implements AuthenticationProvider { return new SecurityUser(user, userCredentials.isEnabled(), userPrincipal); } catch (Exception e) { - systemSecurityService.logLoginAction(user, authentication, ActionType.LOGIN, e); + systemSecurityService.logLoginAction(user, authentication.getDetails(), ActionType.LOGIN, e); throw e; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 6e6815ff5e..1f19b9c19b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -34,7 +34,6 @@ import org.springframework.cache.annotation.Cacheable; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.LockedException; -import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @@ -136,7 +135,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { SecuritySettings securitySettings = self.getSecuritySettings(tenantId); if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { - lockAccount(userCredentials.getUserId(), username, securitySettings); + lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); throw new LockedException("Authentication Failed. Username was locked due to security policy."); } } @@ -172,21 +171,22 @@ public class DefaultSystemSecurityService implements SystemSecurityService { userService.resetFailedLoginAttempts(tenantId, userId); } - if (twoFaSettings.getMaxCodeVerificationFailuresBeforeUserLockout() > 0 - && failedVerificationAttempts >= twoFaSettings.getMaxCodeVerificationFailuresBeforeUserLockout()) { + if (twoFaSettings.getMaxVerificationFailuresBeforeUserLockout() > 0 + && failedVerificationAttempts >= twoFaSettings.getMaxVerificationFailuresBeforeUserLockout()) { userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); - lockAccount(userId, securityUser.getEmail(), self.getSecuritySettings(tenantId)); + SecuritySettings securitySettings = self.getSecuritySettings(tenantId); + lockAccount(userId, securityUser.getEmail(), securitySettings.getUserLockoutNotificationEmail(), twoFaSettings.getMaxVerificationFailuresBeforeUserLockout()); throw new LockedException("User account was locked due to exceeded 2FA verification attempts"); } } - private void lockAccount(UserId userId, String username, SecuritySettings securitySettings) { + private void lockAccount(UserId userId, String username, String userLockoutNotificationEmail, Integer maxFailedLoginAttempts) { userService.setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); - if (StringUtils.isNoneBlank(securitySettings.getUserLockoutNotificationEmail())) { + if (StringUtils.isNotBlank(userLockoutNotificationEmail)) { try { - mailService.sendAccountLockoutEmail(username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); + mailService.sendAccountLockoutEmail(username, userLockoutNotificationEmail, maxFailedLoginAttempts); } catch (ThingsboardException e) { - log.warn("Can't send email regarding user account [{}] lockout to provided email [{}]", username, securitySettings.getUserLockoutNotificationEmail(), e); + log.warn("Can't send email regarding user account [{}] lockout to provided email [{}]", username, userLockoutNotificationEmail, e); } } } @@ -257,23 +257,22 @@ public class DefaultSystemSecurityService implements SystemSecurityService { } @Override - public void logLoginAction(User user, Authentication authentication, ActionType actionType, Exception e) { + public void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e) { String clientAddress = "Unknown"; String browser = "Unknown"; String os = "Unknown"; String device = "Unknown"; - if (authentication != null && authentication.getDetails() != null) { - if (authentication.getDetails() instanceof RestAuthenticationDetails) { - RestAuthenticationDetails details = (RestAuthenticationDetails) authentication.getDetails(); - clientAddress = details.getClientAddress(); - if (details.getUserAgent() != null) { - Client userAgent = details.getUserAgent(); - if (userAgent.userAgent != null) { - browser = userAgent.userAgent.family; - if (userAgent.userAgent.major != null) { - browser += " " + userAgent.userAgent.major; - if (userAgent.userAgent.minor != null) { - browser += "." + userAgent.userAgent.minor; + if (authenticationDetails instanceof RestAuthenticationDetails) { + RestAuthenticationDetails details = (RestAuthenticationDetails) authenticationDetails; + clientAddress = details.getClientAddress(); + if (details.getUserAgent() != null) { + Client userAgent = details.getUserAgent(); + if (userAgent.userAgent != null) { + browser = userAgent.userAgent.family; + if (userAgent.userAgent.major != null) { + browser += " " + userAgent.userAgent.major; + if (userAgent.userAgent.minor != null) { + browser += "." + userAgent.userAgent.minor; if (userAgent.userAgent.patch != null) { browser += "." + userAgent.userAgent.patch; } @@ -300,7 +299,6 @@ public class DefaultSystemSecurityService implements SystemSecurityService { } } } - } if (actionType == ActionType.LOGIN && e == null) { userService.setLastLoginTs(user.getTenantId(), user.getId()); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index f6b401da4e..4c14e45ae6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.service.security.system; -import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -43,6 +42,6 @@ public interface SystemSecurityService { String getBaseUrl(TenantId tenantId, CustomerId customerId, HttpServletRequest httpServletRequest); - void logLoginAction(User user, Authentication authentication, ActionType actionType, Exception e); + void logLoginAction(User user, Object authenticationDetails, ActionType actionType, Exception e); } diff --git a/application/src/main/resources/templates/account.lockout.ftl b/application/src/main/resources/templates/account.lockout.ftl index 9832f8323d..fa21653cb9 100644 --- a/application/src/main/resources/templates/account.lockout.ftl +++ b/application/src/main/resources/templates/account.lockout.ftl @@ -88,7 +88,7 @@ background-color: #f6f6f6; - Thingsboard user account ${lockoutAccount} has been lockout due to failed credentials were provided more than ${maxFailedLoginAttempts} times. + Thingsboard user account ${lockoutAccount} has been locked out due to multiple authentication failures (more than ${maxFailedLoginAttempts}). 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 b338d0d99d..69bd49f6f5 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -44,8 +44,8 @@ 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 sessionId // 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 public abstract class TwoFactorAuthTest extends AbstractControllerTest { @SpyBean From 062af3af810f47f10dc719f7be883e420968e800 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Sun, 20 Mar 2022 15:43:38 +0200 Subject: [PATCH 10/92] 2FA: cleanup code --- .../server/config/JwtSettings.java | 6 ------ .../config/RateLimitProcessingFilter.java | 3 --- .../TwoFactorAuthConfigController.java | 2 ++ .../controller/TwoFactorAuthController.java | 2 ++ .../RefreshTokenAuthenticationProvider.java | 2 +- .../auth/mfa/DefaultTwoFactorAuthService.java | 20 ++++++++++-------- .../auth/mfa/TwoFactorAuthService.java | 8 +++---- .../provider/TwoFactorAuthProviderType.java | 3 +-- .../impl/OtpBasedTwoFactorAuthProvider.java | 5 +++-- .../auth/rest/RestAuthenticationProvider.java | 2 +- ...RestAwareAuthenticationSuccessHandler.java | 6 +++--- .../security/model/token/JwtTokenFactory.java | 2 +- .../system/DefaultSystemSecurityService.java | 3 ++- .../src/main/resources/thingsboard.yml | 4 +--- .../common/util/ThrowingBiConsumer.java | 21 ------------------- .../common/util/ThrowingBiFunction.java | 21 ------------------- .../common/util/ThrowingTripleConsumer.java | 21 ------------------- .../common/util/ThrowingTripleFunction.java | 21 ------------------- .../rule/engine/api/util/TbNodeUtils.java | 6 +----- 19 files changed, 33 insertions(+), 125 deletions(-) delete mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java delete mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java delete mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java delete mode 100644 common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java diff --git a/application/src/main/java/org/thingsboard/server/config/JwtSettings.java b/application/src/main/java/org/thingsboard/server/config/JwtSettings.java index 1c15c4c150..95e510612a 100644 --- a/application/src/main/java/org/thingsboard/server/config/JwtSettings.java +++ b/application/src/main/java/org/thingsboard/server/config/JwtSettings.java @@ -44,10 +44,4 @@ public class JwtSettings { */ private Integer refreshTokenExpTime; - /** - * Issued when 2FA is being used. - * Valid only for 2FA verification code checking. - * */ - private Integer preVerificationTokenExpirationTime; - } diff --git a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java index 2756b9a647..e2e6f8d982 100644 --- a/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java +++ b/application/src/main/java/org/thingsboard/server/config/RateLimitProcessingFilter.java @@ -15,11 +15,8 @@ */ package org.thingsboard.server.config; -import io.github.bucket4j.Bucket4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.cache.Cache; -import org.springframework.cache.jcache.JCacheCacheManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; 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 12f0824ff1..5d3e3bf116 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -31,6 +31,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.queue.util.TbCoreComponent; 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.TotpTwoFactorAuthAccountConfig; @@ -45,6 +46,7 @@ import javax.validation.Valid; @RestController @RequestMapping("/api/2fa") +@TbCoreComponent @RequiredArgsConstructor public class TwoFactorAuthConfigController extends BaseController { 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 1b1983cdf8..ff6417a3aa 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -25,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; 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.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.model.JwtTokenPair; @@ -50,6 +51,7 @@ import javax.servlet.http.HttpServletRequest; * */ @RestController @RequestMapping("/api/auth/2fa") +@TbCoreComponent @RequiredArgsConstructor public class TwoFactorAuthController extends BaseController { diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java index 665451b61f..8003cfd012 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java @@ -33,11 +33,11 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken; -import org.thingsboard.server.service.security.auth.TokenOutdatingService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; import org.thingsboard.server.service.security.model.token.JwtTokenFactory; 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 a06184ff6c..c8117ec792 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 @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.queue.util.TbCoreComponent; 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.TwoFactorAuthAccountConfig; @@ -44,6 +45,7 @@ import java.util.concurrent.ConcurrentMap; @Service @RequiredArgsConstructor +@TbCoreComponent public class DefaultTwoFactorAuthService implements TwoFactorAuthService { private final TwoFactorAuthConfigManager configManager; @@ -61,17 +63,17 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { @Override - public void prepareVerificationCode(SecurityUser securityUser, boolean rateLimit) throws Exception { + public void prepareVerificationCode(SecurityUser securityUser, boolean checkLimits) throws Exception { TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); - prepareVerificationCode(securityUser, accountConfig, rateLimit); + prepareVerificationCode(securityUser, accountConfig, checkLimits); } @Override - public void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException { + public void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); - if (rateLimit) { + if (checkLimits) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { TbRateLimits rateLimits = verificationCodeSendingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); @@ -88,21 +90,21 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { } @Override - public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean rateLimit) throws ThingsboardException { + public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean checkLimits) throws ThingsboardException { TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); - return checkVerificationCode(securityUser, verificationCode, accountConfig, rateLimit); + return checkVerificationCode(securityUser, verificationCode, accountConfig, checkLimits); } @Override - public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException { + public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { if (!userService.findUserCredentialsByUserId(securityUser.getTenantId(), securityUser.getId()).isEnabled()) { throw new ThingsboardException("User is disabled", ThingsboardErrorCode.AUTHENTICATION); } TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId()) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); - if (rateLimit) { + if (checkLimits) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeCheckRateLimit())) { TbRateLimits rateLimits = verificationCodeCheckingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { return new TbRateLimits(twoFaSettings.getVerificationCodeCheckRateLimit()); @@ -116,7 +118,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); boolean verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(securityUser, verificationCode, providerConfig, accountConfig); - if (rateLimit) { + if (checkLimits) { systemSecurityService.validateTwoFaVerification(securityUser, verificationSuccess, twoFaSettings); if (verificationSuccess) { verificationCodeCheckingRateLimits.remove(securityUser.getId()); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index ec2511e62a..e07ac2b2fa 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -23,13 +23,13 @@ import org.thingsboard.server.service.security.model.SecurityUser; public interface TwoFactorAuthService { - void prepareVerificationCode(SecurityUser securityUser, boolean rateLimit) throws Exception; + void prepareVerificationCode(SecurityUser securityUser, boolean checkLimits) throws Exception; - void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException; + void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; - boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean rateLimit) throws ThingsboardException; + boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean checkLimits) throws ThingsboardException; - boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean rateLimit) throws ThingsboardException; + boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; TwoFactorAuthAccountConfig generateNewAccountConfig(User user, TwoFactorAuthProviderType providerType) throws ThingsboardException; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java index 4ddaf836e1..9a4a3672a7 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java @@ -17,6 +17,5 @@ package org.thingsboard.server.service.security.auth.mfa.provider; public enum TwoFactorAuthProviderType { TOTP, - SMS, - EMAIL + SMS } 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 0438c53e15..ab6c15e56b 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 0 diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index ba6dc222ab..188d2bfe26 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -127,8 +127,6 @@ security: jwt: tokenExpirationTime: "${JWT_TOKEN_EXPIRATION_TIME:9000}" # Number of seconds (2.5 hours) refreshTokenExpTime: "${JWT_REFRESH_TOKEN_EXPIRATION_TIME:604800}" # Number of seconds (1 week) - # Number of seconds. Issued when 2FA is being used; valid only for checking 2FA verification code after which usual token pair is issued - preVerificationTokenExpirationTime: "${JWT_PRE_VERIFICATION_TOKEN_EXPIRATION_TIME:30}" tokenIssuer: "${JWT_TOKEN_ISSUER:thingsboard.io}" tokenSigningKey: "${JWT_TOKEN_SIGNING_KEY:thingsboardDefaultSigningKey}" # Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator @@ -428,7 +426,7 @@ caffeine: timeToLiveInMinutes: "${CACHE_SPECS_EDGES_TTL:1440}" maxSize: "${CACHE_SPECS_EDGES_MAX_SIZE:10000}" twoFaVerificationCodes: - timeToLiveInMinutes: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_TTL:1}" + timeToLiveInMinutes: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_TTL:60}" maxSize: "${CACHE_SPECS_TWO_FA_VERIFICATION_CODES_MAX_SIZE:100000}" redis: diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java deleted file mode 100644 index 269f9f75cb..0000000000 --- a/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiConsumer.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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.common.util; - -@FunctionalInterface -public interface ThrowingBiConsumer { - void accept(A a, B b) throws Exception; -} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java deleted file mode 100644 index 32058df26e..0000000000 --- a/common/util/src/main/java/org/thingsboard/common/util/ThrowingBiFunction.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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.common.util; - -@FunctionalInterface -public interface ThrowingBiFunction { - R apply(A a, B b) throws Exception; -} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java deleted file mode 100644 index 5230da4863..0000000000 --- a/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleConsumer.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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.common.util; - -@FunctionalInterface -public interface ThrowingTripleConsumer { - void accept(A a, B b, C c) throws Exception; -} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java b/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java deleted file mode 100644 index cade9d1717..0000000000 --- a/common/util/src/main/java/org/thingsboard/common/util/ThrowingTripleFunction.java +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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.common.util; - -@FunctionalInterface -public interface ThrowingTripleFunction { - R apply(A a, B b, C c) throws Exception; -} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 32ee585820..bc38db7fd6 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -94,11 +94,7 @@ public class TbNodeUtils { } public static String processPattern(String pattern, TbMsgMetaData metaData) { - String result = pattern; - for (Map.Entry keyVal : metaData.values().entrySet()) { - result = processVar(result, keyVal.getKey(), keyVal.getValue()); - } - return result; + return processTemplate(pattern, metaData.values()); } public static String processTemplate(String template, Map data) { From 1250fa2ba077c57329a30f20b8a8f7c4691a18d7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 21 Mar 2022 14:09:51 +0200 Subject: [PATCH 11/92] Tests for 2FA config management; refactoring --- .../TwoFactorAuthConfigController.java | 2 +- .../controller/TwoFactorAuthController.java | 7 +- .../auth/mfa/DefaultTwoFactorAuthService.java | 6 +- .../DefaultTwoFactorAuthConfigManager.java | 37 +- .../config/TwoFactorAuthConfigManager.java | 7 +- .../mfa/config/TwoFactorAuthSettings.java | 10 +- .../OtpBasedTwoFactorAuthAccountConfig.java | 3 + .../SmsTwoFactorAuthAccountConfig.java | 4 +- .../TotpTwoFactorAuthAccountConfig.java | 6 +- .../OtpBasedTwoFactorAuthProviderConfig.java | 2 +- .../SmsTwoFactorAuthProviderConfig.java | 2 +- .../impl/OtpBasedTwoFactorAuthProvider.java | 6 +- .../auth/rest/RestAuthenticationProvider.java | 2 +- ...RestAwareAuthenticationSuccessHandler.java | 6 +- .../server/controller/AbstractWebTest.java | 5 +- .../controller/TwoFactorAuthConfigTest.java | 525 ++++++++++++++++++ .../server/controller/TwoFactorAuthTest.java | 245 +------- .../sql/TwoFactorAuthConfigSqlTest.java | 23 + 18 files changed, 621 insertions(+), 277 deletions(-) create mode 100644 application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java create mode 100644 application/src/test/java/org/thingsboard/server/controller/sql/TwoFactorAuthConfigSqlTest.java 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 { +} From bc6c38c36cfc269ac3a8d0b98d48337e661a1fa0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 22 Mar 2022 15:46:30 +0200 Subject: [PATCH 12/92] Change `isAuthenticated()` check to `hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')` in controllers --- .../server/controller/AuthController.java | 6 +++--- .../server/controller/DashboardController.java | 4 ++-- .../controller/TwoFactorAuthConfigController.java | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 4887bc205d..72be55a135 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -82,7 +82,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Get current User (getUser)", notes = "Get the information about the User which credentials are used to perform this REST API call.") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/auth/user", method = RequestMethod.GET) public @ResponseBody User getUser() throws ThingsboardException { @@ -96,7 +96,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Logout (logout)", notes = "Special API call to record the 'logout' of the user to the Audit Logs. Since platform uses [JWT](https://jwt.io/), the actual logout is the procedure of clearing the [JWT](https://jwt.io/) token on the client side. ") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/auth/logout", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) public void logout(HttpServletRequest request) throws ThingsboardException { @@ -105,7 +105,7 @@ public class AuthController extends BaseController { @ApiOperation(value = "Change password for current User (changePassword)", notes = "Change the password for the User which credentials are used to perform this REST API call. Be aware that previously generated [JWT](https://jwt.io/) tokens will be still valid until they expire.") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/auth/changePassword", method = RequestMethod.POST) @ResponseStatus(value = HttpStatus.OK) public ObjectNode changePassword( diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 49c33045a5..72b0aaa76e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -671,7 +671,7 @@ public class DashboardController extends BaseController { "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/home", method = RequestMethod.GET) @ResponseBody public HomeDashboard getHomeDashboard() throws ThingsboardException { @@ -708,7 +708,7 @@ public class DashboardController extends BaseController { "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, produces = MediaType.APPLICATION_JSON_VALUE) - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/home/info", method = RequestMethod.GET) @ResponseBody public HomeDashboardInfo getHomeDashboardInfo() throws ThingsboardException { 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 527c862c95..85991aab32 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -32,12 +32,12 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; 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.TotpTwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; -import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.model.SecurityUser; import javax.servlet.ServletOutputStream; @@ -55,14 +55,14 @@ public class TwoFactorAuthConfigController extends BaseController { @GetMapping("/account/config") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public TwoFactorAuthAccountConfig getTwoFaAccountConfig() throws ThingsboardException { SecurityUser user = getCurrentUser(); return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); } @PostMapping("/account/config/generate") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public TwoFactorAuthAccountConfig generateTwoFaAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); return twoFactorAuthService.generateNewAccountConfig(user, providerType); @@ -70,7 +70,7 @@ public class TwoFactorAuthConfigController extends BaseController { /* TMP */ @PostMapping("/account/config/generate/qr") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public void generateTwoFaAccountConfigWithQr(@RequestParam TwoFactorAuthProviderType providerType, HttpServletResponse response) throws Exception { TwoFactorAuthAccountConfig config = generateTwoFaAccountConfig(providerType); if (providerType == TwoFactorAuthProviderType.TOTP) { @@ -84,14 +84,14 @@ public class TwoFactorAuthConfigController extends BaseController { /* TMP */ @PostMapping("/account/config/submit") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); } @PostMapping("/account/config") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public void verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, @RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); @@ -104,7 +104,7 @@ public class TwoFactorAuthConfigController extends BaseController { } @DeleteMapping("/account/config") - @PreAuthorize("isAuthenticated()") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public void deleteTwoFactorAuthAccountConfig() throws ThingsboardException { SecurityUser user = getCurrentUser(); twoFactorAuthConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId()); From 8bbe6bafd8f1127bfd047f16cc9b349de59eabd2 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 22 Mar 2022 15:49:57 +0200 Subject: [PATCH 13/92] Refactor 2FA; add refilling setting to TbRateLimits --- .../controller/TwoFactorAuthController.java | 6 +++-- .../auth/mfa/DefaultTwoFactorAuthService.java | 24 +++++++++++++----- .../mfa/config/TwoFactorAuthSettings.java | 2 +- .../impl/OtpBasedTwoFactorAuthProvider.java | 1 - .../impl/SmsTwoFactorAuthProvider.java | 2 +- ...RestAwareAuthenticationSuccessHandler.java | 4 +-- .../security/model/token/JwtTokenFactory.java | 25 +++++++++++-------- .../server/common/msg/tools/TbRateLimits.java | 10 +++++--- .../server/dao/user/UserServiceImpl.java | 1 + 9 files changed, 48 insertions(+), 27 deletions(-) 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 d858f64899..b95e1d6099 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -17,7 +17,6 @@ package org.thingsboard.server.controller; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -25,6 +24,7 @@ import org.springframework.web.bind.annotation.RestController; 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.dao.user.UserService; 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; @@ -53,6 +53,7 @@ public class TwoFactorAuthController extends BaseController { private final TwoFactorAuthService twoFactorAuthService; private final JwtTokenFactory tokenFactory; private final SystemSecurityService systemSecurityService; + private final UserService userService; @PostMapping("/verification/send") @@ -69,9 +70,10 @@ public class TwoFactorAuthController extends BaseController { boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, 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); } else { - ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.AUTHENTICATION); + ThingsboardException error = new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.BAD_REQUEST_PARAMS); systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(servletRequest), ActionType.LOGIN, error); throw error; } 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 1012f27d25..ee9e252831 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 @@ -16,9 +16,10 @@ package org.thingsboard.server.service.security.auth.mfa; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.LockedException; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -76,7 +77,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { if (checkLimits) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { TbRateLimits rateLimits = verificationCodeSendingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { - return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit()); + return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit(), true); }); if (!rateLimits.tryConsume()) { throw new ThingsboardException("Too many verification code sending requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); @@ -107,19 +108,30 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService { if (checkLimits) { if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeCheckRateLimit())) { TbRateLimits rateLimits = verificationCodeCheckingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { - return new TbRateLimits(twoFaSettings.getVerificationCodeCheckRateLimit()); + return new TbRateLimits(twoFaSettings.getVerificationCodeCheckRateLimit(), true); }); if (!rateLimits.tryConsume()) { throw new ThingsboardException("Too many verification code checking requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); } } } - TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); - boolean verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(securityUser, verificationCode, providerConfig, accountConfig); + + boolean verificationSuccess; + if (StringUtils.isNumeric(verificationCode) && verificationCode.length() == 6) { + verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(securityUser, verificationCode, providerConfig, accountConfig); + } else { + verificationSuccess = false; + } if (checkLimits) { - systemSecurityService.validateTwoFaVerification(securityUser, verificationSuccess, twoFaSettings); + try { + systemSecurityService.validateTwoFaVerification(securityUser, verificationSuccess, twoFaSettings); + } catch (LockedException e) { + verificationCodeCheckingRateLimits.remove(securityUser.getId()); + verificationCodeSendingRateLimits.remove(securityUser.getId()); + throw new ThingsboardException(e.getMessage(), ThingsboardErrorCode.AUTHENTICATION); + } if (verificationSuccess) { verificationCodeCheckingRateLimits.remove(securityUser.getId()); verificationCodeSendingRateLimits.remove(securityUser.getId()); 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 72da35e744..65e7ce2c04 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 @@ -42,7 +42,7 @@ public class TwoFactorAuthSettings { @ApiModelProperty(example = "10") @Min(value = 0, message = "maximum number of verification failure before user lockout must be positive") private int maxVerificationFailuresBeforeUserLockout; - @ApiModelProperty(value = "in minutes", example = "60") + @ApiModelProperty(value = "in seconds", example = "3600 (60 minutes)") @Min(value = 1, message = "total amount of time allotted for verification must be greater than 0") private Integer totalAllowedTimeForVerification; 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 db6653ffe9..edeaf7ba3d 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 @@ -65,7 +65,6 @@ public abstract class OtpBasedTwoFactorAuthProvider Optional.ofNullable(settings.getTotalAllowedTimeForVerification())).orElse(30)); + int preVerificationTokenLifetime = twoFactorAuthConfigManager.getTwoFaSettings(securityUser.getTenantId(), true) + .flatMap(settings -> Optional.ofNullable(settings.getTotalAllowedTimeForVerification())).orElse((int) TimeUnit.MINUTES.toSeconds(30)); tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); tokenPair.setRefreshToken(null); tokenPair.setScope(Authority.PRE_VERIFICATION_TOKEN); diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java index 5cfb461b44..a8e6f9cb5c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java @@ -115,21 +115,22 @@ public class JwtTokenFactory { } else if (securityUser.getAuthority() == Authority.SYS_ADMIN) { securityUser.setTenantId(TenantId.SYS_TENANT_ID); } + String customerId = claims.get(CUSTOMER_ID, String.class); + if (customerId != null) { + securityUser.setCustomerId(new CustomerId(UUID.fromString(customerId))); + } + UserPrincipal principal; if (securityUser.getAuthority() != Authority.PRE_VERIFICATION_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); - UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); - securityUser.setUserPrincipal(principal); - String customerId = claims.get(CUSTOMER_ID, String.class); - if (customerId != null) { - securityUser.setCustomerId(new CustomerId(UUID.fromString(customerId))); - } + principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject); } else { - securityUser.setUserPrincipal(new UserPrincipal(UserPrincipal.Type.USER_NAME, subject)); + principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, subject); } + securityUser.setUserPrincipal(principal); return securityUser; } @@ -164,10 +165,12 @@ public class JwtTokenFactory { } public JwtToken createPreVerificationToken(SecurityUser user, Integer expirationTime) { - String token = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime) - .claim(TENANT_ID, user.getTenantId().toString()) - .compact(); - return new AccessJwtToken(token); + JwtBuilder jwtBuilder = setUpToken(user, Collections.singletonList(Authority.PRE_VERIFICATION_TOKEN.name()), expirationTime) + .claim(TENANT_ID, user.getTenantId().toString()); + if (user.getCustomerId() != null) { + jwtBuilder.claim(CUSTOMER_ID, user.getCustomerId().toString()); + } + return new AccessJwtToken(jwtBuilder.compact()); } private JwtBuilder setUpToken(SecurityUser securityUser, List scopes, long expirationTime) { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java index afcbe1b3b9..f7a75381c9 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.msg.tools; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.Refill; import io.github.bucket4j.local.LocalBucket; import io.github.bucket4j.local.LocalBucketBuilder; @@ -29,12 +30,17 @@ public class TbRateLimits { private final LocalBucket bucket; public TbRateLimits(String limitsConfiguration) { + this(limitsConfiguration, false); + } + + public TbRateLimits(String limitsConfiguration, boolean refillIntervally) { LocalBucketBuilder builder = Bucket4j.builder(); boolean initialized = false; for (String limitSrc : limitsConfiguration.split(",")) { long capacity = Long.parseLong(limitSrc.split(":")[0]); long duration = Long.parseLong(limitSrc.split(":")[1]); - builder.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(duration))); + Refill refill = refillIntervally ? Refill.intervally(capacity, Duration.ofSeconds(duration)) : Refill.greedy(capacity, Duration.ofSeconds(duration)); + builder.addLimit(Bandwidth.classic(capacity, refill)); initialized = true; } if (initialized) { @@ -42,8 +48,6 @@ public class TbRateLimits { } else { throw new IllegalArgumentException("Failed to parse rate limits configuration: " + limitsConfiguration); } - - } public boolean tryConsume() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index b8c7b8e9f9..869a4b4700 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -323,6 +323,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic } ((ObjectNode) additionalInfo).put(LAST_LOGIN_TS, System.currentTimeMillis()); user.setAdditionalInfo(additionalInfo); + saveUser(user); } @Override From 5ed306881f3d934b57d43cea9b0ffd4bd4e1032e Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 22 Mar 2022 15:54:11 +0200 Subject: [PATCH 14/92] Tests for 2FA --- .../controller/TwoFactorAuthController.java | 8 +- .../controller/TwoFactorAuthConfigTest.java | 14 +- .../server/controller/TwoFactorAuthTest.java | 375 +++++++++++++++++- 3 files changed, 375 insertions(+), 22 deletions(-) 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 b95e1d6099..b93df6e751 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -36,13 +36,7 @@ import org.thingsboard.server.service.security.system.SystemSecurityService; import javax.servlet.http.HttpServletRequest; /* - * TODO [viacheslav]: - * - Swagger documentation - * - * 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... + * FIXME [viacheslav]: Swagger documentation * */ @RestController @RequestMapping("/api/auth/2fa") diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 01e2bc496e..38ff2e0702 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -29,7 +29,6 @@ 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; @@ -100,7 +99,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaSettings.setVerificationCodeSendRateLimit("1:60"); twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); - twoFaSettings.setTotalAllowedTimeForVerification(60); + twoFaSettings.setTotalAllowedTimeForVerification(3600); doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); @@ -497,9 +496,10 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { } @Test - public void testIsTwoFaEnabled() throws ThingsboardException { + public void testIsTwoFaEnabled() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); SmsTwoFactorAuthAccountConfig accountConfig = new SmsTwoFactorAuthAccountConfig(); - accountConfig.setPhoneNumber("+380505050"); + accountConfig.setPhoneNumber("+38050505050"); twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); assertThat(twoFactorAuthConfigManager.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); @@ -507,12 +507,14 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testDeleteTwoFaAccountConfig() throws Exception { + configureSmsTwoFaProvider("${verificationCode}"); SmsTwoFactorAuthAccountConfig accountConfig = new SmsTwoFactorAuthAccountConfig(); - accountConfig.setPhoneNumber("+380505050"); - twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + accountConfig.setPhoneNumber("+38050505050"); loginTenantAdmin(); + twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + TwoFactorAuthAccountConfig savedAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); assertThat(savedAccountConfig).isEqualTo(accountConfig); 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 4a9888ada1..09d1208df3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -15,15 +15,372 @@ */ package org.thingsboard.server.controller; -/* -* 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 -* */ +import com.fasterxml.jackson.databind.JsonNode; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.commons.lang3.StringUtils; +import org.jboss.aerogear.security.otp.Totp; +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.thingsboard.rule.engine.api.SmsService; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.audit.ActionStatus; +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.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +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.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.rest.LoginRequest; +import org.thingsboard.server.service.security.model.JwtTokenPair; + +import java.time.Duration; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.fail; +import static org.mockito.ArgumentMatchers.any; +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 TwoFactorAuthTest extends AbstractControllerTest { + @Autowired + private TwoFactorAuthConfigManager twoFactorAuthConfigManager; + @Autowired + private TwoFactorAuthService twoFactorAuthService; + @MockBean + private SmsService smsService; + @Autowired + private AuditLogService auditLogService; + @Autowired + private UserService userService; + + private User user; + private String username; + private String password; + + @Before + public void beforeEach() throws Exception { + username = "mfa@tb.io"; + password = "psswrd"; + + user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail(username); + user.setTenantId(tenantId); + + loginSysAdmin(); + user = createUser(user, password); + } + + @After + public void afterEach() { + twoFactorAuthConfigManager.deleteTwoFaSettings(tenantId); + twoFactorAuthConfigManager.deleteTwoFaSettings(TenantId.SYS_TENANT_ID); + } + + @Test + public void testTwoFa_totp() throws Exception { + TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); + + logInWithPreVerificationToken(); + + doPost("/api/auth/2fa/verification/send") + .andExpect(status().isOk()); + + String correctVerificationCode = getCorrectTotp(totpTwoFaAccountConfig); + + JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) + .andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenPair, username); + + User currentUser = readResponse(doGet("/api/auth/user") + .andExpect(status().isOk()), User.class); + assertThat(currentUser.getId()).isEqualTo(user.getId()); + } + + @Test + public void testTwoFa_sms() throws Exception { + configureSmsTwoFa(); + + logInWithPreVerificationToken(); + + doPost("/api/auth/2fa/verification/send") + .andExpect(status().isOk()); + + ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); + verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); + String correctVerificationCode = verificationCodeCaptor.getValue(); + + JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) + .andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenPair, username); + + User currentUser = readResponse(doGet("/api/auth/user") + .andExpect(status().isOk()), User.class); + assertThat(currentUser.getId()).isEqualTo(user.getId()); + } + + @Test + public void testTwoFaPreVerificationTokenLifetime() throws Exception { + configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setTotalAllowedTimeForVerification(5); + }); + + logInWithPreVerificationToken(); + + await("expiration of the pre-verification token") + .atLeast(Duration.ofSeconds(3).plusMillis(500)) + .atMost(Duration.ofSeconds(6)) + .untilAsserted(() -> { + doPost("/api/auth/2fa/verification/send") + .andExpect(status().isUnauthorized()); + }); + } + + @Test + public void testCheckVerificationCode_userBlocked() throws Exception { + configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + }); + + logInWithPreVerificationToken(); + + Stream.generate(() -> RandomStringUtils.randomNumeric(6)) + .limit(9) + .forEach(incorrectVerificationCode -> { + try { + String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + incorrectVerificationCode) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + } catch (Exception e) { + fail(); + } + }); + + String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) + .andExpect(status().isUnauthorized())); + assertThat(errorMessage).containsIgnoringCase("account was locked due to exceeded 2fa verification attempts"); + + errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) + .andExpect(status().isUnauthorized())); + assertThat(errorMessage).containsIgnoringCase("user is disabled"); + } + + @Test + public void testSendVerificationCode_rateLimit() throws Exception { + configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setVerificationCodeSendRateLimit("3:10"); + }); + + logInWithPreVerificationToken(); + + for (int i = 0; i < 3; i++) { + doPost("/api/auth/2fa/verification/send") + .andExpect(status().isOk()); + } + + String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/send") + .andExpect(status().isTooManyRequests())); + assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code sending requests"); + + await("verification code sending rate limit resetting") + .atLeast(Duration.ofSeconds(8)) + .atMost(Duration.ofSeconds(12)) + .untilAsserted(() -> { + doPost("/api/auth/2fa/verification/send") + .andExpect(status().isOk()); + }); + } + + @Test + public void testCheckVerificationCode_rateLimit() throws Exception { + TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); + }); + + logInWithPreVerificationToken(); + + for (int i = 0; i < 3; i++) { + String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") + .andExpect(status().isBadRequest())); + assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); + } + + String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") + .andExpect(status().isTooManyRequests())); + assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code checking requests"); + + await("verification code checking rate limit resetting") + .atLeast(Duration.ofSeconds(8)) + .atMost(Duration.ofSeconds(12)) + .untilAsserted(() -> { + String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") + .andExpect(status().isBadRequest())); + assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); + }); + + doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) + .andExpect(status().isOk()); + } + + @Test + public void testCheckVerificationCode_invalidVerificationCode() throws Exception { + configureTotpTwoFa(); + logInWithPreVerificationToken(); + + for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { + String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + invalidVerificationCode) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + } + } + + @Test + public void testCheckVerificationCode_codeExpiration() throws Exception { + configureSmsTwoFa(smsTwoFaProviderConfig -> { + smsTwoFaProviderConfig.setVerificationCodeLifetime(10); + }); + + logInWithPreVerificationToken(); + + ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); + doPost("/api/auth/2fa/verification/send").andExpect(status().isOk()); + verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); + + String correctVerificationCode = verificationCodeCaptor.getValue(); + + await("verification code expiration") + .pollDelay(10, TimeUnit.SECONDS) + .atLeast(10, TimeUnit.SECONDS) + .atMost(12, TimeUnit.SECONDS) + .untilAsserted(() -> { + String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) + .andExpect(status().isBadRequest())); + assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); + }); + } + + @Test + public void testTwoFa_logLoginAction() throws Exception { + TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); + + logInWithPreVerificationToken(); + await("async audit log saving").during(1, TimeUnit.SECONDS); + assertThat(getLogInAuditLogs()).isEmpty(); + assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() + .get("lastLoginTs")).isNull(); + + doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") + .andExpect(status().isBadRequest()); + + await("async audit log saving").atMost(1, TimeUnit.SECONDS) + .until(() -> getLogInAuditLogs().size() == 1); + assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { + assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); + assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); + assertThat(failedLogInAuditLog.getUserName()).isEqualTo(username); + }); + + doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) + .andExpect(status().isOk()); + await("async audit log saving").atMost(1, TimeUnit.SECONDS) + .until(() -> getLogInAuditLogs().size() == 2); + assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { + assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); + assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); + }); + assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() + .get("lastLoginTs").asLong()) + .isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(3)); + } + + private List getLogInAuditLogs() { + return auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, user.getId(), List.of(ActionType.LOGIN), + new TimePageLink(new PageLink(10, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC)), 0L, System.currentTimeMillis())).getData(); + } + + @Test + public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { + configureTotpTwoFa(); + twoFactorAuthConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId()); + + assertDoesNotThrow(() -> { + login(username, password); + }); + } + + private void logInWithPreVerificationToken() throws Exception { + LoginRequest loginRequest = new LoginRequest(username, password); + + JwtTokenPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtTokenPair.class); + assertThat(response.getToken()).isNotNull(); + assertThat(response.getRefreshToken()).isNull(); + assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); + + this.token = response.getToken(); + } + + private TotpTwoFactorAuthAccountConfig configureTotpTwoFa(Consumer... customizer) throws ThingsboardException { + TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + twoFaSettings.setUseSystemTwoFactorAuthSettings(false); + twoFaSettings.setProviders(Arrays.stream(new TwoFactorAuthProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + Arrays.stream(customizer).forEach(c -> c.accept(twoFaSettings)); + twoFactorAuthConfigManager.saveTwoFaSettings(tenantId, twoFaSettings); + + TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = (TotpTwoFactorAuthAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFactorAuthProviderType.TOTP); + twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); + return totpTwoFaAccountConfig; + } + + private SmsTwoFactorAuthAccountConfig configureSmsTwoFa(Consumer... customizer) throws ThingsboardException { + SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + smsTwoFaProviderConfig.setVerificationCodeLifetime(60); + smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); + Arrays.stream(customizer).forEach(c -> c.accept(smsTwoFaProviderConfig)); + + TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + twoFaSettings.setUseSystemTwoFactorAuthSettings(false); + twoFaSettings.setProviders(Arrays.stream(new TwoFactorAuthProviderConfig[]{smsTwoFaProviderConfig}).collect(Collectors.toList())); + twoFactorAuthConfigManager.saveTwoFaSettings(tenantId, twoFaSettings); + + SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); + twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); + return smsTwoFaAccountConfig; + } + + private String getCorrectTotp(TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig) { + String secret = StringUtils.substringAfterLast(totpTwoFaAccountConfig.getAuthUrl(), "secret="); + return new Totp(secret).now(); + } + } From 472edc8409d5edc2f488dc7d8e627f807719e5c1 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 23 Mar 2022 12:07:52 +0200 Subject: [PATCH 15/92] Refactor controller exceptions handling --- .../org/thingsboard/server/controller/BaseController.java | 8 +++++++- .../server/dao/service/ConstraintValidator.java | 1 - 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index f33767d9db..02810cc0d9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -285,7 +285,13 @@ public abstract class BaseController { @ExceptionHandler(Exception.class) public void handleControllerException(Exception e, HttpServletResponse response) { ThingsboardException thingsboardException = handleException(e); - handleThingsboardException(thingsboardException, response); + if (thingsboardException.getErrorCode() == ThingsboardErrorCode.GENERAL && thingsboardException.getCause() instanceof Exception + && StringUtils.equals(thingsboardException.getCause().getMessage(), thingsboardException.getMessage())) { + e = (Exception) thingsboardException.getCause(); + } else { + e = thingsboardException; + } + errorResponseHandler.handle(e, response); } @ExceptionHandler(ThingsboardException.class) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java index 404eeb44cc..5faa605863 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/ConstraintValidator.java @@ -25,7 +25,6 @@ import org.thingsboard.server.dao.exception.DataValidationException; import javax.validation.ConstraintViolation; import javax.validation.Validation; -import javax.validation.ValidationException; import javax.validation.Validator; import java.util.List; import java.util.Set; From 6eb8a41f9a2607469b5adbd39f9fd1e046cc76ca Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 23 Mar 2022 19:38:44 +0200 Subject: [PATCH 16/92] Swagger docs for TwoFactorAuthConfigController and config classes --- .../TwoFactorAuthConfigController.java | 78 +++++++++++++++++-- .../mfa/config/TwoFactorAuthSettings.java | 16 +++- .../SmsTwoFactorAuthAccountConfig.java | 4 + .../TotpTwoFactorAuthAccountConfig.java | 5 +- .../account/TwoFactorAuthAccountConfig.java | 2 +- .../OtpBasedTwoFactorAuthProviderConfig.java | 3 +- .../SmsTwoFactorAuthProviderConfig.java | 5 ++ .../TotpTwoFactorAuthProviderConfig.java | 5 ++ 8 files changed, 104 insertions(+), 14 deletions(-) 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 85991aab32..29df21bca4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -19,6 +19,8 @@ import com.google.zxing.BarcodeFormat; import com.google.zxing.client.j2se.MatrixToImageWriter; import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; @@ -44,6 +46,8 @@ import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; + @RestController @RequestMapping("/api/2fa") @TbCoreComponent @@ -54,6 +58,20 @@ public class TwoFactorAuthConfigController extends BaseController { private final TwoFactorAuthService twoFactorAuthService; + @ApiOperation(value = "Get 2FA account config (getTwoFaAccountConfig)", + notes = "Get user's account 2FA configuration. Returns empty result if user did not configured 2FA, " + + "or if a provider for previously set up account config is not now configured." + NEW_LINE + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE + + "Response example for TOTP 2FA: " + NEW_LINE + + "{\n" + + " \"providerType\": \"TOTP\",\n" + + " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + + "}" + NEW_LINE + + "Response example for SMS 2FA: " + NEW_LINE + + "{\n" + + " \"providerType\": \"SMS\",\n" + + " \"phoneNumber\": \"+380505005050\"\n" + + "}") @GetMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public TwoFactorAuthAccountConfig getTwoFaAccountConfig() throws ThingsboardException { @@ -61,9 +79,29 @@ public class TwoFactorAuthConfigController extends BaseController { return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); } + @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", + notes = "Generate new 2FA account config for specified provider type. " + + "This method is only useful for TOTP 2FA, as there is nothing to generate for other provider types. " + + "For TOTP, this will return a corresponding account config template " + + "with a generated OTP auth URL (with new random secret key for each API call) that can be then " + + "converted to a QR code to scan with an authenticator app. " + + "For other provider types, this method will return an empty config. " + NEW_LINE + + "Will throw an error (Bad Request) if the provider is not configured for usage. " + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE + + "Example of a generated account config for TOTP 2FA: " + NEW_LINE + + "{\n" + + " \"providerType\": \"TOTP\",\n" + + " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + + "}" + NEW_LINE + + "For SMS provider type it will return something like: " + NEW_LINE + + "{\n" + + " \"providerType\": \"SMS\",\n" + + " \"phoneNumber\": null\n" + + "}") @PostMapping("/account/config/generate") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public TwoFactorAuthAccountConfig generateTwoFaAccountConfig(@RequestParam TwoFactorAuthProviderType providerType) throws Exception { + public TwoFactorAuthAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true) + @RequestParam TwoFactorAuthProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); return twoFactorAuthService.generateNewAccountConfig(user, providerType); } @@ -83,16 +121,32 @@ public class TwoFactorAuthConfigController extends BaseController { } /* TMP */ + @ApiOperation(value = "Submit 2FA account config (submitTwoFaAccountConfig)", + notes = "Submit 2FA account config to prepare for a future verification. " + + "Basically, this method will send a verification code for a given account config, if this has " + + "sense for a chosen 2FA provider. This code is needed to then verify and save the account config." + NEW_LINE + + "Will throw an error (Bad Request) if submitted account config is not valid, " + + "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')") - public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { + public void submitTwoFaAccountConfig(@ApiParam(value = "2FA account config value. For TOTP 2FA config, authUrl value must not be blank and must match specific pattern. " + + "For SMS 2FA, phoneNumber property must not be blank and must be of E.164 phone number format.", required = true) + @Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); } + @ApiOperation(value = "Verify and save 2FA account config (verifyAndSaveTwoFaAccountConfig)", + notes = "Checks the verification code for submitted config, and if it is correct, saves the provided account config. " + + "The config is stored in the user's additionalInfo. " + NEW_LINE + + "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')") - public void verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, + public void verifyAndSaveTwoFaAccountConfig(@ApiParam(value = "2FA account config to save. Validation rules are the same as in submitTwoFaAccountConfig API method", required = true) + @Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, + @ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS message in case of SMS 2FA", required = true) @RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false); @@ -103,24 +157,34 @@ public class TwoFactorAuthConfigController extends BaseController { } } + @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", + notes = "Delete user's 2FA config. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @DeleteMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void deleteTwoFactorAuthAccountConfig() throws ThingsboardException { + public void deleteTwoFaAccountConfig() throws ThingsboardException { SecurityUser user = getCurrentUser(); twoFactorAuthConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId()); } + @ApiOperation(value = "Get 2FA settings (getTwoFaSettings)", + notes = "Get settings for 2FA. If 2FA is not configured, then an empty response will be returned." + + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @GetMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public TwoFactorAuthSettings getTwoFactorAuthSettings() throws ThingsboardException { + public TwoFactorAuthSettings getTwoFaSettings() throws ThingsboardException { return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId(), false).orElse(null); } + @ApiOperation(value = "Save 2FA settings (saveTwoFaSettings)", + notes = "Save settings for 2FA. If a user is sysadmin - the settings are saved as AdminSettings; " + + "if it is a tenant admin - as a tenant attribute." + + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PostMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public void saveTwoFactorAuthSettings(@RequestBody TwoFactorAuthSettings twoFactorAuthSettings) throws ThingsboardException { - twoFactorAuthConfigManager.saveTwoFaSettings(getTenantId(), twoFactorAuthSettings); + public void saveTwoFaSettings(@ApiParam(value = "Settings value", required = true) + @RequestBody TwoFactorAuthSettings twoFaSettings) throws ThingsboardException { + twoFactorAuthConfigManager.saveTwoFaSettings(getTenantId(), twoFaSettings); } } 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 65e7ce2c04..a39cfd1e37 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.auth.mfa.config; +import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; @@ -27,22 +28,29 @@ import java.util.List; import java.util.Optional; @Data +@ApiModel public class TwoFactorAuthSettings { + @ApiModelProperty(value = "Option for tenant admins to use 2FA settings configured by sysadmin. " + + "If this param is set to true, then the settings will not be validated for constraints " + + "(if it is a tenant admin; for sysadmin this param is ignored)") private boolean useSystemTwoFactorAuthSettings; + @ApiModelProperty(value = "The list of 2FA providers' configs. Users will only be allowed to use 2FA providers from this list.") @Valid private List providers; - @ApiModelProperty(example = "1:60 (1 request per minute)") + @ApiModelProperty(value = "Rate limit configuration for verification code sending. The format is standard: 'amountOfRequests:periodInSeconds'. " + + "The value of '1:60' would limit verification code sending requests to one per minute.", example = "1:60", required = false) @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)") + @ApiModelProperty(value = "Rate limit configuration for verification code checking.", example = "3:900", required = false) @Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code check rate limit configuration is invalid") private String verificationCodeCheckRateLimit; - @ApiModelProperty(example = "10") + @ApiModelProperty(value = "Maximum number of verification failures before a user gets disabled.", example = "10", required = false) @Min(value = 0, message = "maximum number of verification failure before user lockout must be positive") private int maxVerificationFailuresBeforeUserLockout; - @ApiModelProperty(value = "in seconds", example = "3600 (60 minutes)") + @ApiModelProperty(value = "Total amount of time in seconds allotted for verification. " + + "Basically, this property sets a lifetime for pre-verification token. If not set, default value of 30 minutes is used.", example = "3600", required = false) @Min(value = 1, message = "total amount of time allotted for verification must be greater than 0") private Integer totalAllowedTimeForVerification; 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 c921c5aafe..ece6b780a4 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 @@ -15,6 +15,8 @@ */ package org.thingsboard.server.service.security.auth.mfa.config.account; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @@ -22,10 +24,12 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; +@ApiModel @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { + @ApiModelProperty(value = "Phone number to use for 2FA. Must no be blank and must be of E.164 number format.", required = true) @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; 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 7c92955043..c67b1ee345 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.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @@ -22,10 +23,12 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; +@ApiModel @Data public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { - @ApiModelProperty(example = "otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII") + @ApiModelProperty(value = "OTP auth URL used to generate a QR code to scan with an authenticator app. Must not be blank and must follow specific pattern.", + example = "otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII", required = true) @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; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java index d1947a72be..31ee1e2807 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java @@ -27,7 +27,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr use = JsonTypeInfo.Id.NAME, property = "providerType") @JsonSubTypes({ - @Type(name = "TOTP", value = TotpTwoFactorAuthAccountConfig.class ), + @Type(name = "TOTP", value = TotpTwoFactorAuthAccountConfig.class), @Type(name = "SMS", value = SmsTwoFactorAuthAccountConfig.class) }) public interface TwoFactorAuthAccountConfig { 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 597c3e1e46..6b4ef92cda 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 @@ -22,7 +22,8 @@ import javax.validation.constraints.Min; @Data public abstract class OtpBasedTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { - @ApiModelProperty(value = "in seconds", example = "60") + @ApiModelProperty(value = "Verification code lifetime in seconds. Verification codes with a lifetime bigger than this param " + + "will be considered incorrect", example = "60", required = true) @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 8b34419041..a589905292 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 @@ -15,6 +15,8 @@ */ package org.thingsboard.server.service.security.auth.mfa.config.provider; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @@ -22,10 +24,13 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; +@ApiModel(parent = OtpBasedTwoFactorAuthProviderConfig.class) @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig { + @ApiModelProperty(value = "SMS verification message template. Available template variables are ${verificationCode} and ${userEmail}. " + + "It must not be blank and must contain verification code variable.", required = true) @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/config/provider/TotpTwoFactorAuthProviderConfig.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java index e1604bb618..8c0c324ae0 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java @@ -15,14 +15,19 @@ */ package org.thingsboard.server.service.security.auth.mfa.config.provider; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; import lombok.Data; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; +@ApiModel @Data public class TotpTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { + @ApiModelProperty(value = "Issuer name that will be displayed in an authenticator app near a username. " + + "Must not be blank.", example = "ThingsBoard", required = true) @NotBlank(message = "issuer name must not be blank") private String issuerName; From 0915113a24c02262accfda8c2c63faae30be0019 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 23 Mar 2022 19:57:18 +0200 Subject: [PATCH 17/92] Swagger docs for TwoFactorAuthController --- .../controller/TwoFactorAuthController.java | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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 b93df6e751..5339c7a6a8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.controller; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiParam; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; @@ -35,9 +37,8 @@ import org.thingsboard.server.service.security.system.SystemSecurityService; import javax.servlet.http.HttpServletRequest; -/* - * FIXME [viacheslav]: Swagger documentation - * */ +import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; + @RestController @RequestMapping("/api/auth/2fa") @TbCoreComponent @@ -50,16 +51,30 @@ public class TwoFactorAuthController extends BaseController { 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, " + + "which is issued on username/password auth if 2FA is enabled." + NEW_LINE + + "The API method is rate limited (using rate limit config from TwoFactorAuthSettings). " + + "Will return a Bad Request error if provider is not configured for usage, " + + "and Too Many Requests error if rate limits are exceeded.") @PostMapping("/verification/send") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public void sendTwoFaVerificationCode() throws Exception { + public void requestTwoFaVerificationCode() throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, true); } + @ApiOperation(value = "Check 2FA verification code (checkTwoFaVerificationCode)", + notes = "Checks 2FA verification code, and if it is correct the method returns a regular access and refresh token pair." + NEW_LINE + + "The API method is rate limited (using rate limit config from TwoFactorAuthSettings), and also will block a user " + + "after X unsuccessful verification attempts if such behavior is configured (in TwoFactorAuthSettings)." + NEW_LINE + + "Will return a Bad Request error if provider is not configured for usage, " + + "and Too Many Requests error if rate limits are exceeded.") @PostMapping("/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public JwtTokenPair checkTwoFaVerificationCode(@RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { + public JwtTokenPair checkTwoFaVerificationCode(@ApiParam(value = "6-digit verification code", required = true) + @RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, true); if (verificationSuccess) { From b7db4ed60415133ef5342412598d6e87d052314b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 24 Mar 2022 14:00:17 +0200 Subject: [PATCH 18/92] Minor 2FA refactoring --- .../server/controller/TwoFactorAuthController.java | 14 ++++++++++++++ .../provider/SmsTwoFactorAuthProviderConfig.java | 3 ++- 2 files changed, 16 insertions(+), 1 deletion(-) 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 5339c7a6a8..1575673dcf 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -19,6 +19,7 @@ import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -29,6 +30,9 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; +import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; @@ -46,6 +50,7 @@ import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; public class TwoFactorAuthController extends BaseController { private final TwoFactorAuthService twoFactorAuthService; + private final TwoFactorAuthConfigManager twoFactorAuthConfigManager; private final JwtTokenFactory tokenFactory; private final SystemSecurityService systemSecurityService; private final UserService userService; @@ -88,4 +93,13 @@ public class TwoFactorAuthController extends BaseController { } } + @ApiOperation(value = "Get currently used 2FA provider type (getCurrentlyUsedTwoFaProviderType)") + @GetMapping("/provider/type") + @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") + public TwoFactorAuthProviderType getCurrentlyUsedTwoFaProviderType() throws ThingsboardException { + SecurityUser user = getCurrentUser(); + return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()) + .map(TwoFactorAuthAccountConfig::getProviderType).orElse(null); + } + } 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 a589905292..88eca4cb2c 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 @@ -30,7 +30,8 @@ import javax.validation.constraints.Pattern; public class SmsTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig { @ApiModelProperty(value = "SMS verification message template. Available template variables are ${verificationCode} and ${userEmail}. " + - "It must not be blank and must contain verification code variable.", required = true) + "It must not be blank and must contain verification code variable.", + example = "Here is your verification code: ${verificationCode}", required = true) @NotBlank(message = "verification message template is required") @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "template must contain verification code") private String smsVerificationMessageTemplate; From 95f41810ac347e782baf7e00d3ad90dc3be27e22 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 29 Mar 2022 11:32:27 +0300 Subject: [PATCH 19/92] Store 2FA account config is UserCredentials' additionalInfo --- .../DefaultTwoFactorAuthConfigManager.java | 42 ++++++++++++------- .../server/dao/user/UserService.java | 14 +++---- .../common/data/security/UserCredentials.java | 9 +++- .../dao/model/sql/UserCredentialsEntity.java | 11 +++++ .../main/resources/sql/schema-entities.sql | 3 +- 5 files changed, 53 insertions(+), 26 deletions(-) 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 bd486a6af0..ebdd03d734 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 @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.security.auth.mfa.config; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; @@ -22,13 +23,13 @@ import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.User; 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.id.UserId; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.settings.AdminSettingsDao; @@ -41,6 +42,7 @@ import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthPr import java.util.Collections; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; @Service @RequiredArgsConstructor @@ -62,9 +64,8 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan @Override public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { - User user = userService.findUserById(tenantId, userId); - return Optional.ofNullable(user.getAdditionalInfo()) - .flatMap(additionalInfo -> Optional.ofNullable(additionalInfo.get(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY)).filter(jsonNode -> !jsonNode.isNull())) + return Optional.ofNullable(getAccountInfo(tenantId, userId).get(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY)) + .filter(JsonNode::isObject) .map(jsonNode -> JacksonUtil.treeToValue(jsonNode, TwoFactorAuthAccountConfig.class)) .filter(twoFactorAuthAccountConfig -> { return getTwoFaProviderConfig(tenantId, twoFactorAuthAccountConfig.getProviderType()).isPresent(); @@ -76,24 +77,33 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); - User user = userService.findUserById(tenantId, userId); - ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) - .orElseGet(JacksonUtil::newObjectNode); - additionalInfo.set(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY, JacksonUtil.valueToTree(accountConfig)); - user.setAdditionalInfo(additionalInfo); - - userService.saveUser(user); + updateAccountInfo(tenantId, userId, accountInfo -> { + accountInfo.set(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY, JacksonUtil.valueToTree(accountConfig)); + }); } @Override public void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId) { - User user = userService.findUserById(tenantId, userId); - ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(user.getAdditionalInfo()) + updateAccountInfo(tenantId, userId, accountInfo -> { + accountInfo.remove(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY); + }); + } + + private ObjectNode getAccountInfo(TenantId tenantId, UserId userId) { + return (ObjectNode) Optional.ofNullable(userService.findUserCredentialsByUserId(tenantId, userId).getAdditionalInfo()) + .filter(JsonNode::isObject) .orElseGet(JacksonUtil::newObjectNode); - additionalInfo.remove(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY); - user.setAdditionalInfo(additionalInfo); + } - userService.saveUser(user); + // FIXME [viacheslav]: upgrade script for credentials' additional info + private void updateAccountInfo(TenantId tenantId, UserId userId, Consumer updater) { + UserCredentials credentials = userService.findUserCredentialsByUserId(tenantId, userId); + ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(credentials.getAdditionalInfo()) + .filter(JsonNode::isObject) + .orElseGet(JacksonUtil::newObjectNode); + updater.accept(additionalInfo); + credentials.setAdditionalInfo(additionalInfo); + userService.saveUserCredentials(tenantId, credentials); } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java index d5d29efa40..93da1ecd6c 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/user/UserService.java @@ -51,24 +51,24 @@ public interface UserService { UserCredentials replaceUserCredentials(TenantId tenantId, UserCredentials userCredentials); - void deleteUser(TenantId tenantId, UserId userId); + void deleteUser(TenantId tenantId, UserId userId); PageData findUsersByTenantId(TenantId tenantId, PageLink pageLink); PageData findTenantAdmins(TenantId tenantId, PageLink pageLink); - void deleteTenantAdmins(TenantId tenantId); + void deleteTenantAdmins(TenantId tenantId); PageData findCustomerUsers(TenantId tenantId, CustomerId customerId, PageLink pageLink); - void deleteCustomerUsers(TenantId tenantId, CustomerId customerId); + void deleteCustomerUsers(TenantId tenantId, CustomerId customerId); - void setUserCredentialsEnabled(TenantId tenantId, UserId userId, boolean enabled); + void setUserCredentialsEnabled(TenantId tenantId, UserId userId, boolean enabled); - void resetFailedLoginAttempts(TenantId tenantId, UserId userId); + void resetFailedLoginAttempts(TenantId tenantId, UserId userId); - int increaseFailedLoginAttempts(TenantId tenantId, UserId userId); + int increaseFailedLoginAttempts(TenantId tenantId, UserId userId); - void setLastLoginTs(TenantId tenantId, UserId userId); + void setLastLoginTs(TenantId tenantId, UserId userId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java index 3816d27131..9f8dab575b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java @@ -16,12 +16,12 @@ package org.thingsboard.server.common.data.security; import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.SearchTextBasedWithAdditionalInfo; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; @EqualsAndHashCode(callSuper = true) -public class UserCredentials extends BaseData { +public class UserCredentials extends SearchTextBasedWithAdditionalInfo { private static final long serialVersionUID = -2108436378880529163L; @@ -87,6 +87,11 @@ public class UserCredentials extends BaseData { public void setResetToken(String resetToken) { this.resetToken = resetToken; } + + @Override + public String getSearchText() { + return null; + } @Override public String toString() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java index 4379af14e7..211977122a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserCredentialsEntity.java @@ -15,14 +15,18 @@ */ package org.thingsboard.server.dao.model.sql; +import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.util.mapping.JsonStringType; import javax.persistence.Column; import javax.persistence.Entity; @@ -31,6 +35,7 @@ import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) +@TypeDef(name = "json", typeClass = JsonStringType.class) @Entity @Table(name = ModelConstants.USER_CREDENTIALS_COLUMN_FAMILY_NAME) public final class UserCredentialsEntity extends BaseSqlEntity implements BaseEntity { @@ -50,6 +55,10 @@ public final class UserCredentialsEntity extends BaseSqlEntity @Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_PROPERTY, unique = true) private String resetToken; + @Type(type = "json") + @Column(name = ModelConstants.ADDITIONAL_INFO_PROPERTY) + private JsonNode additionalInfo; + public UserCredentialsEntity() { super(); } @@ -66,6 +75,7 @@ public final class UserCredentialsEntity extends BaseSqlEntity this.password = userCredentials.getPassword(); this.activateToken = userCredentials.getActivateToken(); this.resetToken = userCredentials.getResetToken(); + this.additionalInfo = userCredentials.getAdditionalInfo(); } @Override @@ -79,6 +89,7 @@ public final class UserCredentialsEntity extends BaseSqlEntity userCredentials.setPassword(password); userCredentials.setActivateToken(activateToken); userCredentials.setResetToken(resetToken); + userCredentials.setAdditionalInfo(additionalInfo); return userCredentials; } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 4e2596485b..34bee652e2 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -370,7 +370,8 @@ CREATE TABLE IF NOT EXISTS user_credentials ( enabled boolean, password varchar(255), reset_token varchar(255) UNIQUE, - user_id uuid UNIQUE + user_id uuid UNIQUE, + additional_info varchar ); CREATE TABLE IF NOT EXISTS widget_type ( From 922436d38b6d2b57cb5e7de73ee9434e7a84d8fe Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 29 Mar 2022 16:30:57 +0300 Subject: [PATCH 20/92] Tests for rate limits with different refill strategy --- .../common/msg/tools/RateLimitsTest.java | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java diff --git a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java new file mode 100644 index 0000000000..d4cc408558 --- /dev/null +++ b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java @@ -0,0 +1,87 @@ +/** + * 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.common.msg.tools; + +import org.junit.Test; + +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class RateLimitsTest { + + @Test + public void testRateLimits_greedyRefill() { + for (int period = 1; period <= 5; period++) { + for (int capacity = 1; capacity <= 5; capacity++) { + testRateLimitWithGreedyRefill(capacity, period); + } + } + } + + private void testRateLimitWithGreedyRefill(int capacity, int period) { + String rateLimitConfig = capacity + ":" + period; + TbRateLimits rateLimits = new TbRateLimits(rateLimitConfig); + + rateLimits.tryConsume(capacity); + assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); + + int expectedRefillTime = (int) (((double) period / capacity) * 1000); + int gap = 100; + + for (int i = 0; i < capacity; i++) { + await("token refill for rate limit " + rateLimitConfig) + .atLeast(expectedRefillTime - gap, TimeUnit.MILLISECONDS) + .atMost(expectedRefillTime + gap, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + assertThat(rateLimits.tryConsume()).as("token is available").isTrue(); + }); + assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); + } + } + + @Test + public void testRateLimits_intervalRefill() { + for (int period = 1; period <= 3; period++) { + for (int capacity = 1; capacity <= 3; capacity++) { + testRateLimitWithIntervalRefill(capacity, period); + } + } + } + + private void testRateLimitWithIntervalRefill(int capacity, int period) { + String rateLimitConfig = capacity + ":" + period; + TbRateLimits rateLimits = new TbRateLimits(rateLimitConfig, true); + + rateLimits.tryConsume(capacity); + assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); + + int expectedRefillTime = period * 1000; + int gap = 100; + + await("tokens refill for rate limit " + rateLimitConfig) + .atLeast(expectedRefillTime - gap, TimeUnit.MILLISECONDS) + .atMost(expectedRefillTime + gap, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + for (int i = 0; i < capacity; i++) { + assertThat(rateLimits.tryConsume()).as("token is available").isTrue(); + } + assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); + }); + } + +} From 190430ffc45da3971fcc652795d546b71adcec13 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Tue, 29 Mar 2022 19:18:00 +0300 Subject: [PATCH 21/92] Store 2FA account config in UserAuthSettings table --- .../TwoFactorAuthConfigController.java | 8 +- .../controller/TwoFactorAuthController.java | 4 +- .../auth/mfa/DefaultTwoFactorAuthService.java | 8 +- .../auth/mfa/TwoFactorAuthService.java | 4 +- .../DefaultTwoFactorAuthConfigManager.java | 62 ++++++-------- .../config/TwoFactorAuthConfigManager.java | 3 +- .../mfa/provider/TwoFactorAuthProvider.java | 5 +- .../impl/OtpBasedTwoFactorAuthProvider.java | 4 +- .../impl/SmsTwoFactorAuthProvider.java | 6 +- .../impl/TotpTwoFactorAuthProvider.java | 6 +- .../system/DefaultSystemSecurityService.java | 2 +- .../system/SystemSecurityService.java | 2 +- .../controller/TwoFactorAuthConfigTest.java | 16 ++-- .../server/controller/TwoFactorAuthTest.java | 14 ++-- .../common/data/id/UserAuthSettingsId.java | 26 ++++++ .../data/security/UserAuthSettings.java | 34 ++++++++ .../model/mfa}/TwoFactorAuthSettings.java | 6 +- .../OtpBasedTwoFactorAuthAccountConfig.java | 2 +- .../SmsTwoFactorAuthAccountConfig.java | 4 +- .../TotpTwoFactorAuthAccountConfig.java | 4 +- .../account/TwoFactorAuthAccountConfig.java | 4 +- .../OtpBasedTwoFactorAuthProviderConfig.java | 2 +- .../SmsTwoFactorAuthProviderConfig.java | 3 +- .../TotpTwoFactorAuthProviderConfig.java | 3 +- .../provider/TwoFactorAuthProviderConfig.java | 3 +- .../provider/TwoFactorAuthProviderType.java | 2 +- .../server/dao/model/ModelConstants.java | 7 ++ .../dao/model/sql/UserAuthSettingsEntity.java | 80 +++++++++++++++++++ .../dao/sql/user/JpaUserAuthSettingsDao.java | 56 +++++++++++++ .../sql/user/UserAuthSettingsRepository.java | 33 ++++++++ .../server/dao/user/UserAuthSettingsDao.java | 28 +++++++ .../server/dao/user/UserServiceImpl.java | 5 +- .../main/resources/sql/schema-entities.sql | 8 ++ 33 files changed, 356 insertions(+), 98 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/TwoFactorAuthSettings.java (92%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/account/OtpBasedTwoFactorAuthAccountConfig.java (91%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/account/SmsTwoFactorAuthAccountConfig.java (89%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/account/TotpTwoFactorAuthAccountConfig.java (90%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/account/TwoFactorAuthAccountConfig.java (88%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/provider/OtpBasedTwoFactorAuthProviderConfig.java (93%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/provider/SmsTwoFactorAuthProviderConfig.java (91%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/provider/TotpTwoFactorAuthProviderConfig.java (88%) rename {application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config => common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa}/provider/TwoFactorAuthProviderConfig.java (88%) rename {application/src/main/java/org/thingsboard/server/service/security/auth => common/data/src/main/java/org/thingsboard/server/common/data/security/model}/mfa/provider/TwoFactorAuthProviderType.java (90%) create mode 100644 dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java create mode 100644 dao/src/main/java/org/thingsboard/server/dao/user/UserAuthSettingsDao.java 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 29df21bca4..d4cc690604 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -36,10 +36,10 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; 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.TotpTwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; import javax.servlet.ServletOutputStream; 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 1575673dcf..2a74d565b5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -31,8 +31,8 @@ import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; -import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; 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 ee9e252831..9b2b0b90bd 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 @@ -25,15 +25,15 @@ 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.id.UserId; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.queue.util.TbCoreComponent; 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.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.system.SystemSecurityService; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index e07ac2b2fa..d84236cfb5 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -17,8 +17,8 @@ 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.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; public interface TwoFactorAuthService { 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 ebdd03d734..a74b8374af 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 @@ -15,8 +15,6 @@ */ package org.thingsboard.server.service.security.auth.mfa.config; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.SneakyThrows; import org.springframework.stereotype.Service; @@ -29,31 +27,30 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; -import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.UserAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; 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; -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.dao.user.UserAuthSettingsDao; import java.util.Collections; import java.util.Optional; import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; @Service @RequiredArgsConstructor public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigManager { - private final UserService userService; + private final UserAuthSettingsDao userAuthSettingsDao; private final AdminSettingsService adminSettingsService; private final AdminSettingsDao adminSettingsDao; private final AttributesService attributesService; - protected static final String TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY = "twoFaConfig"; protected static final String TWO_FACTOR_AUTH_SETTINGS_KEY = "twoFaSettings"; @@ -64,12 +61,9 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan @Override public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { - return Optional.ofNullable(getAccountInfo(tenantId, userId).get(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY)) - .filter(JsonNode::isObject) - .map(jsonNode -> JacksonUtil.treeToValue(jsonNode, TwoFactorAuthAccountConfig.class)) - .filter(twoFactorAuthAccountConfig -> { - return getTwoFaProviderConfig(tenantId, twoFactorAuthAccountConfig.getProviderType()).isPresent(); - }); + return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .flatMap(userAuthSettings -> Optional.ofNullable(userAuthSettings.getTwoFaAccountConfig())) + .filter(twoFaAccountConfig -> getTwoFaProviderConfig(tenantId, twoFaAccountConfig.getProviderType()).isPresent()); } @Override @@ -77,33 +71,23 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); - updateAccountInfo(tenantId, userId, accountInfo -> { - accountInfo.set(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY, JacksonUtil.valueToTree(accountConfig)); - }); + UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .orElseGet(() -> { + UserAuthSettings newUserAuthSettings = new UserAuthSettings(); + newUserAuthSettings.setUserId(userId); + return newUserAuthSettings; + }); + userAuthSettings.setTwoFaAccountConfig(accountConfig); + userAuthSettingsDao.save(tenantId, userAuthSettings); } @Override public void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId) { - updateAccountInfo(tenantId, userId, accountInfo -> { - accountInfo.remove(TWO_FACTOR_AUTH_ACCOUNT_CONFIG_KEY); - }); - } - - private ObjectNode getAccountInfo(TenantId tenantId, UserId userId) { - return (ObjectNode) Optional.ofNullable(userService.findUserCredentialsByUserId(tenantId, userId).getAdditionalInfo()) - .filter(JsonNode::isObject) - .orElseGet(JacksonUtil::newObjectNode); - } - - // FIXME [viacheslav]: upgrade script for credentials' additional info - private void updateAccountInfo(TenantId tenantId, UserId userId, Consumer updater) { - UserCredentials credentials = userService.findUserCredentialsByUserId(tenantId, userId); - ObjectNode additionalInfo = (ObjectNode) Optional.ofNullable(credentials.getAdditionalInfo()) - .filter(JsonNode::isObject) - .orElseGet(JacksonUtil::newObjectNode); - updater.accept(additionalInfo); - credentials.setAdditionalInfo(additionalInfo); - userService.saveUserCredentials(tenantId, credentials); + Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .ifPresent(userAuthSettings -> { + userAuthSettings.setTwoFaAccountConfig(null); + userAuthSettingsDao.save(tenantId, userAuthSettings); + }); } 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 96189bf584..d1c5999e7e 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 @@ -18,7 +18,8 @@ package org.thingsboard.server.service.security.auth.mfa.config; 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.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; import java.util.Optional; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java index 030482ac6d..6961aeeaec 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java @@ -17,8 +17,9 @@ package org.thingsboard.server.service.security.auth.mfa.provider; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.service.security.auth.mfa.config.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; public interface TwoFactorAuthProvider { 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 edeaf7ba3d..ce03f38230 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 @@ -21,8 +21,8 @@ import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.thingsboard.server.common.data.CacheConstants; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.service.security.auth.mfa.config.account.OtpBasedTwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.OtpBasedTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.OtpBasedTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.OtpBasedTwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; import org.thingsboard.server.service.security.model.SecurityUser; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java index d6d99d03e8..9044762585 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/SmsTwoFactorAuthProvider.java @@ -21,10 +21,10 @@ import org.thingsboard.rule.engine.api.SmsService; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.security.auth.mfa.config.account.SmsTwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.SmsTwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; import java.util.Map; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java index 83767ee3d8..9a30ea1fef 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFactorAuthProvider.java @@ -24,11 +24,11 @@ import org.jboss.aerogear.security.otp.api.Base32; import org.springframework.stereotype.Service; import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.security.auth.mfa.config.account.TotpTwoFactorAuthAccountConfig; -import org.thingsboard.server.service.security.auth.mfa.config.provider.TotpTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFactorAuthProviderConfig; import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.model.SecurityUser; @Service diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index c164ff04b4..82607cf70b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -54,7 +54,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserServiceImpl; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; import org.thingsboard.server.service.security.model.SecurityUser; diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 4c14e45ae6..90241f723b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; import org.thingsboard.server.service.security.model.SecurityUser; import javax.servlet.http.HttpServletRequest; diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 38ff2e0702..6e3f11160f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -31,14 +31,14 @@ 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.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.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.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; 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 09d1208df3..41ddd05c1b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -40,13 +40,13 @@ import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; 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.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.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.model.JwtTokenPair; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java new file mode 100644 index 0000000000..ae89bad915 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserAuthSettingsId.java @@ -0,0 +1,26 @@ +/** + * 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.common.data.id; + +import java.util.UUID; + +public class UserAuthSettingsId extends UUIDBased { + + public UserAuthSettingsId(UUID id) { + super(id); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java new file mode 100644 index 0000000000..769f861435 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java @@ -0,0 +1,34 @@ +/** + * 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.common.data.security; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.id.UserAuthSettingsId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; + +@Data +@EqualsAndHashCode(callSuper = true) +public class UserAuthSettings extends BaseData { + + private static final long serialVersionUID = 2628320657987010348L; + + private UserId userId; + private TwoFactorAuthAccountConfig twoFaAccountConfig; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/TwoFactorAuthSettings.java similarity index 92% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/TwoFactorAuthSettings.java index a39cfd1e37..49d5b64b4b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/TwoFactorAuthSettings.java @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config; +package org.thingsboard.server.common.data.security.model.mfa; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; -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.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; import javax.validation.Valid; import javax.validation.constraints.Min; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFactorAuthAccountConfig.java similarity index 91% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFactorAuthAccountConfig.java index ef090c63b4..c58287556b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/OtpBasedTwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFactorAuthAccountConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.account; +package org.thingsboard.server.common.data.security.model.mfa.account; import lombok.Data; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFactorAuthAccountConfig.java similarity index 89% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFactorAuthAccountConfig.java index ece6b780a4..2863f3f42e 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/SmsTwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFactorAuthAccountConfig.java @@ -13,13 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.account; +package org.thingsboard.server.common.data.security.model.mfa.account; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFactorAuthAccountConfig.java similarity index 90% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFactorAuthAccountConfig.java index c67b1ee345..cc1171a713 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TotpTwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFactorAuthAccountConfig.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.account; +package org.thingsboard.server.common.data.security.model.mfa.account; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFactorAuthAccountConfig.java similarity index 88% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFactorAuthAccountConfig.java index 31ee1e2807..fc1c9bc636 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/account/TwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFactorAuthAccountConfig.java @@ -13,14 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.account; +package org.thingsboard.server.common.data.security.model.mfa.account; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFactorAuthProviderConfig.java similarity index 93% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFactorAuthProviderConfig.java index 6b4ef92cda..655816fcc6 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/OtpBasedTwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFactorAuthProviderConfig.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.provider; +package org.thingsboard.server.common.data.security.model.mfa.provider; import io.swagger.annotations.ApiModelProperty; import lombok.Data; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFactorAuthProviderConfig.java similarity index 91% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFactorAuthProviderConfig.java index 88eca4cb2c..4096fe36f4 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/SmsTwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFactorAuthProviderConfig.java @@ -13,13 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.provider; +package org.thingsboard.server.common.data.security.model.mfa.provider; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFactorAuthProviderConfig.java similarity index 88% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFactorAuthProviderConfig.java index 8c0c324ae0..df44a662ec 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TotpTwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFactorAuthProviderConfig.java @@ -13,12 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.provider; +package org.thingsboard.server.common.data.security.model.mfa.provider; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; import javax.validation.constraints.NotBlank; diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderConfig.java similarity index 88% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderConfig.java index f912f43144..24458af562 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/provider/TwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderConfig.java @@ -13,14 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.config.provider; +package org.thingsboard.server.common.data.security.model.mfa.provider; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProviderType; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderType.java similarity index 90% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderType.java index 9a4a3672a7..04e4401395 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProviderType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderType.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.security.auth.mfa.provider; +package org.thingsboard.server.common.data.security.model.mfa.provider; public enum TwoFactorAuthProviderType { TOTP, diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 8f692d5a12..d054b2b051 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -559,6 +559,13 @@ public class ModelConstants { public static final String EDGE_EVENT_BY_ID_VIEW_NAME = "edge_event_by_id"; + /** + * User auth settings constants. + * */ + public static final String USER_AUTH_SETTINGS_COLUMN_FAMILY_NAME = "user_auth_settings"; + public static final String USER_AUTH_SETTINGS_USER_ID_PROPERTY = USER_ID_PROPERTY; + public static final String USER_AUTH_SETTINGS_TWO_FA_ACCOUNT_CONFIG_PROPERTY = "mfa_account_config"; + /** * Cassandra attributes and timeseries constants. */ diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java new file mode 100644 index 0000000000..59c24c3405 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java @@ -0,0 +1,80 @@ +/** + * 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.dao.model.sql; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Type; +import org.hibernate.annotations.TypeDef; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.id.UserAuthSettingsId; +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.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.dao.model.BaseEntity; +import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.util.mapping.JsonStringType; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Table; +import java.util.UUID; + +@EqualsAndHashCode(callSuper = true) +@Data +@NoArgsConstructor +@TypeDef(name = "json", typeClass = JsonStringType.class) +@Entity +@Table(name = ModelConstants.USER_AUTH_SETTINGS_COLUMN_FAMILY_NAME) // FIXME [viacheslav]: add to upgrade script +public class UserAuthSettingsEntity extends BaseSqlEntity implements BaseEntity { + + @Column(name = ModelConstants.USER_AUTH_SETTINGS_USER_ID_PROPERTY, nullable = false, unique = true) + private UUID userId; + @Type(type = "json") + @Column(name = ModelConstants.USER_AUTH_SETTINGS_TWO_FA_ACCOUNT_CONFIG_PROPERTY) + private JsonNode twoFaAccountConfig; + + public UserAuthSettingsEntity(UserAuthSettings userAuthSettings) { + if (userAuthSettings.getId() != null) { + this.setId(userAuthSettings.getId().getId()); + } + this.setCreatedTime(userAuthSettings.getCreatedTime()); + if (userAuthSettings.getUserId() != null) { + this.userId = userAuthSettings.getUserId().getId(); + } + if (userAuthSettings.getTwoFaAccountConfig() != null) { + this.twoFaAccountConfig = JacksonUtil.valueToTree(userAuthSettings.getTwoFaAccountConfig()); + } + } + + @Override + public UserAuthSettings toData() { + UserAuthSettings userAuthSettings = new UserAuthSettings(); + userAuthSettings.setId(new UserAuthSettingsId(id)); + userAuthSettings.setCreatedTime(createdTime); + if (userId != null) { + userAuthSettings.setUserId(new UserId(userId)); + } + if (twoFaAccountConfig != null) { + userAuthSettings.setTwoFaAccountConfig(JacksonUtil.treeToValue(twoFaAccountConfig, TwoFactorAuthAccountConfig.class)); + } + return userAuthSettings; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java new file mode 100644 index 0000000000..55cc195f6b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/JpaUserAuthSettingsDao.java @@ -0,0 +1,56 @@ +/** + * 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.dao.sql.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserAuthSettings; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.model.sql.UserAuthSettingsEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.user.UserAuthSettingsDao; + +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class JpaUserAuthSettingsDao extends JpaAbstractDao implements UserAuthSettingsDao { + + private final UserAuthSettingsRepository repository; + + @Override + public UserAuthSettings findByUserId(UserId userId) { + return DaoUtil.getData(repository.findByUserId(userId.getId())); + } + + @Override + public void removeByUserId(UserId userId) { + repository.deleteByUserId(userId.getId()); + } + + @Override + protected Class getEntityClass() { + return UserAuthSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java new file mode 100644 index 0000000000..38642a0161 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/user/UserAuthSettingsRepository.java @@ -0,0 +1,33 @@ +/** + * 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.dao.sql.user; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.model.sql.UserAuthSettingsEntity; + +import java.util.UUID; + +@Repository +public interface UserAuthSettingsRepository extends JpaRepository { + + UserAuthSettingsEntity findByUserId(UUID userId); + + @Transactional + void deleteByUserId(UUID userId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserAuthSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserAuthSettingsDao.java new file mode 100644 index 0000000000..50bc21d6f2 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserAuthSettingsDao.java @@ -0,0 +1,28 @@ +/** + * 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.dao.user; + +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserAuthSettings; +import org.thingsboard.server.dao.Dao; + +public interface UserAuthSettingsDao extends Dao { + + UserAuthSettings findByUserId(UserId userId); + + void removeByUserId(UserId userId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java index 218f604709..24b5dc837e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/user/UserServiceImpl.java @@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.ListenableFuture; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Value; @@ -69,17 +68,20 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic private final UserDao userDao; private final UserCredentialsDao userCredentialsDao; + private final UserAuthSettingsDao userAuthSettingsDao; private final DataValidator userValidator; private final DataValidator userCredentialsValidator; private final ApplicationEventPublisher eventPublisher; public UserServiceImpl(UserDao userDao, UserCredentialsDao userCredentialsDao, + UserAuthSettingsDao userAuthSettingsDao, DataValidator userValidator, DataValidator userCredentialsValidator, ApplicationEventPublisher eventPublisher) { this.userDao = userDao; this.userCredentialsDao = userCredentialsDao; + this.userAuthSettingsDao = userAuthSettingsDao; this.userValidator = userValidator; this.userCredentialsValidator = userCredentialsValidator; this.eventPublisher = eventPublisher; @@ -216,6 +218,7 @@ public class UserServiceImpl extends AbstractEntityService implements UserServic validateId(userId, INCORRECT_USER_ID + userId); UserCredentials userCredentials = userCredentialsDao.findByUserId(tenantId, userId.getId()); userCredentialsDao.removeById(tenantId, userCredentials.getUuidId()); + userAuthSettingsDao.removeByUserId(userId); deleteEntityRelations(tenantId, userId); userDao.removeById(tenantId, userId.getId()); eventPublisher.publishEvent(new UserAuthDataChangedEvent(userId)); diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 34bee652e2..da4bcc5bec 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -694,3 +694,11 @@ BEGIN deleted := ttl_deleted_count; END $$; + + +CREATE TABLE IF NOT EXISTS user_auth_settings ( + id uuid NOT NULL CONSTRAINT user_auth_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + user_id uuid UNIQUE NOT NULL CONSTRAINT fk_user_auth_settings_user_id REFERENCES tb_user(id), + mfa_account_config varchar +); From b58be41083444b68afd45c86a17e60100ad06999 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 30 Mar 2022 10:44:21 +0300 Subject: [PATCH 22/92] Tests for JwtTokenFactory --- .../security/auth/JwtTokenFactoryTest.java | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java diff --git a/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java new file mode 100644 index 0000000000..f865c9b5e0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/auth/JwtTokenFactoryTest.java @@ -0,0 +1,165 @@ +/** + * 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.service.security.auth; + +import io.jsonwebtoken.Claims; +import org.junit.BeforeClass; +import org.junit.Test; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.model.JwtToken; +import org.thingsboard.server.config.JwtSettings; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.security.model.token.AccessJwtToken; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.model.token.RawAccessJwtToken; + +import java.util.Calendar; +import java.util.Date; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JwtTokenFactoryTest { + + private static JwtTokenFactory tokenFactory; + private static JwtSettings jwtSettings; + + @BeforeClass + public static void beforeAll() { + jwtSettings = new JwtSettings(); + jwtSettings.setTokenIssuer("tb"); + jwtSettings.setTokenSigningKey("abewafaf"); + jwtSettings.setTokenExpirationTime((int) TimeUnit.HOURS.toSeconds(2)); + jwtSettings.setRefreshTokenExpTime((int) TimeUnit.DAYS.toSeconds(7)); + + tokenFactory = new JwtTokenFactory(jwtSettings); + } + + @Test + public void testCreateAndParseAccessJwtToken() { + SecurityUser securityUser = new SecurityUser(); + securityUser.setId(new UserId(UUID.randomUUID())); + securityUser.setEmail("tenant@thingsboard.org"); + securityUser.setAuthority(Authority.TENANT_ADMIN); + securityUser.setTenantId(new TenantId(UUID.randomUUID())); + securityUser.setEnabled(true); + securityUser.setFirstName("A"); + securityUser.setLastName("B"); + securityUser.setUserPrincipal(new UserPrincipal(UserPrincipal.Type.USER_NAME, securityUser.getEmail())); + securityUser.setCustomerId(new CustomerId(UUID.randomUUID())); + + testCreateAndParseAccessJwtToken(securityUser); + + securityUser = new SecurityUser(securityUser, true, new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, securityUser.getEmail())); + securityUser.setFirstName(null); + securityUser.setLastName(null); + securityUser.setCustomerId(null); + + testCreateAndParseAccessJwtToken(securityUser); + } + + public void testCreateAndParseAccessJwtToken(SecurityUser securityUser) { + AccessJwtToken accessToken = tokenFactory.createAccessJwtToken(securityUser); + checkExpirationTime(accessToken, jwtSettings.getTokenExpirationTime()); + + SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(new RawAccessJwtToken(accessToken.getToken())); + assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); + assertThat(parsedSecurityUser.getEmail()).isEqualTo(securityUser.getEmail()); + assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { + return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType()) + && userPrincipal.getValue().equals(securityUser.getUserPrincipal().getValue()); + }); + assertThat(parsedSecurityUser.getAuthorities()).isEqualTo(securityUser.getAuthorities()); + assertThat(parsedSecurityUser.isEnabled()).isEqualTo(securityUser.isEnabled()); + assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId()); + assertThat(parsedSecurityUser.getCustomerId()).isEqualTo(securityUser.getCustomerId()); + assertThat(parsedSecurityUser.getFirstName()).isEqualTo(securityUser.getFirstName()); + assertThat(parsedSecurityUser.getLastName()).isEqualTo(securityUser.getLastName()); + } + + @Test + public void testCreateAndParseRefreshJwtToken() { + SecurityUser securityUser = new SecurityUser(); + securityUser.setId(new UserId(UUID.randomUUID())); + securityUser.setEmail("tenant@thingsboard.org"); + securityUser.setAuthority(Authority.TENANT_ADMIN); + securityUser.setUserPrincipal(new UserPrincipal(UserPrincipal.Type.USER_NAME, securityUser.getEmail())); + securityUser.setEnabled(true); + securityUser.setTenantId(new TenantId(UUID.randomUUID())); + securityUser.setCustomerId(new CustomerId(UUID.randomUUID())); + + JwtToken refreshToken = tokenFactory.createRefreshToken(securityUser); + checkExpirationTime(refreshToken, jwtSettings.getRefreshTokenExpTime()); + + SecurityUser parsedSecurityUser = tokenFactory.parseRefreshToken(new RawAccessJwtToken(refreshToken.getToken())); + assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); + assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { + return userPrincipal.getType().equals(securityUser.getUserPrincipal().getType()) + && userPrincipal.getValue().equals(securityUser.getUserPrincipal().getValue()); + }); + assertThat(parsedSecurityUser.getAuthority()).isNull(); + } + + @Test + public void testCreateAndParsePreVerificationJwtToken() { + SecurityUser securityUser = new SecurityUser(); + securityUser.setId(new UserId(UUID.randomUUID())); + securityUser.setEmail("tenant@thingsboard.org"); + securityUser.setAuthority(Authority.TENANT_ADMIN); + securityUser.setUserPrincipal(new UserPrincipal(UserPrincipal.Type.USER_NAME, securityUser.getEmail())); + securityUser.setEnabled(true); + securityUser.setTenantId(new TenantId(UUID.randomUUID())); + securityUser.setCustomerId(new CustomerId(UUID.randomUUID())); + + int tokenLifetime = (int) TimeUnit.MINUTES.toSeconds(30); + JwtToken preVerificationToken = tokenFactory.createPreVerificationToken(securityUser, tokenLifetime); + checkExpirationTime(preVerificationToken, tokenLifetime); + + SecurityUser parsedSecurityUser = tokenFactory.parseAccessJwtToken(new RawAccessJwtToken(preVerificationToken.getToken())); + assertThat(parsedSecurityUser.getId()).isEqualTo(securityUser.getId()); + assertThat(parsedSecurityUser.getAuthority()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); + assertThat(parsedSecurityUser.getTenantId()).isEqualTo(securityUser.getTenantId()); + assertThat(parsedSecurityUser.getCustomerId()).isEqualTo(securityUser.getCustomerId()); + assertThat(parsedSecurityUser.getUserPrincipal()).matches(userPrincipal -> { + return userPrincipal.getType() == UserPrincipal.Type.USER_NAME + && userPrincipal.getValue().equals(securityUser.getUserPrincipal().getValue()); + }); + } + + private void checkExpirationTime(JwtToken jwtToken, int tokenLifetime) { + Claims claims = tokenFactory.parseTokenClaims(jwtToken).getBody(); + assertThat(claims.getExpiration()).matches(actualExpirationTime -> { + Calendar expirationTime = Calendar.getInstance(); + expirationTime.setTime(new Date()); + expirationTime.add(Calendar.SECOND, tokenLifetime); + if (actualExpirationTime.equals(expirationTime.getTime())) { + return true; + } else if (actualExpirationTime.before(expirationTime.getTime())) { + int gap = 2; + expirationTime.add(Calendar.SECOND, -gap); + return actualExpirationTime.after(expirationTime.getTime()); + } else { + return false; + } + }); + } + +} From de16eca0a5fa7a401b39886ecd31f93de2f80ada Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 30 Mar 2022 10:48:56 +0300 Subject: [PATCH 23/92] Fix RateLimitsTest --- .../server/common/msg/tools/RateLimitsTest.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java index d4cc408558..3878d64ec9 100644 --- a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java +++ b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java @@ -26,11 +26,9 @@ public class RateLimitsTest { @Test public void testRateLimits_greedyRefill() { - for (int period = 1; period <= 5; period++) { - for (int capacity = 1; capacity <= 5; capacity++) { - testRateLimitWithGreedyRefill(capacity, period); - } - } + testRateLimitWithGreedyRefill(3, 10); + testRateLimitWithGreedyRefill(3, 3); + testRateLimitWithGreedyRefill(4, 2); } private void testRateLimitWithGreedyRefill(int capacity, int period) { @@ -56,11 +54,9 @@ public class RateLimitsTest { @Test public void testRateLimits_intervalRefill() { - for (int period = 1; period <= 3; period++) { - for (int capacity = 1; capacity <= 3; capacity++) { - testRateLimitWithIntervalRefill(capacity, period); - } - } + testRateLimitWithIntervalRefill(10, 5); + testRateLimitWithIntervalRefill(3, 3); + testRateLimitWithIntervalRefill(4, 2); } private void testRateLimitWithIntervalRefill(int capacity, int period) { From 8eb68ff7a6bd154985eac32a9e60418a061b3245 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 30 Mar 2022 10:50:57 +0300 Subject: [PATCH 24/92] Revert changes to UserCredentials --- .../server/common/data/security/UserCredentials.java | 9 ++------- .../server/dao/model/sql/UserCredentialsEntity.java | 11 ----------- dao/src/main/resources/sql/schema-entities.sql | 3 +-- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java index 9f8dab575b..3816d27131 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserCredentials.java @@ -16,12 +16,12 @@ package org.thingsboard.server.common.data.security; import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.SearchTextBasedWithAdditionalInfo; +import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.id.UserCredentialsId; import org.thingsboard.server.common.data.id.UserId; @EqualsAndHashCode(callSuper = true) -public class UserCredentials extends SearchTextBasedWithAdditionalInfo { +public class UserCredentials extends BaseData { private static final long serialVersionUID = -2108436378880529163L; @@ -87,11 +87,6 @@ public class UserCredentials extends SearchTextBasedWithAdditionalInfo implements BaseEntity { @@ -55,10 +50,6 @@ public final class UserCredentialsEntity extends BaseSqlEntity @Column(name = ModelConstants.USER_CREDENTIALS_RESET_TOKEN_PROPERTY, unique = true) private String resetToken; - @Type(type = "json") - @Column(name = ModelConstants.ADDITIONAL_INFO_PROPERTY) - private JsonNode additionalInfo; - public UserCredentialsEntity() { super(); } @@ -75,7 +66,6 @@ public final class UserCredentialsEntity extends BaseSqlEntity this.password = userCredentials.getPassword(); this.activateToken = userCredentials.getActivateToken(); this.resetToken = userCredentials.getResetToken(); - this.additionalInfo = userCredentials.getAdditionalInfo(); } @Override @@ -89,7 +79,6 @@ public final class UserCredentialsEntity extends BaseSqlEntity userCredentials.setPassword(password); userCredentials.setActivateToken(activateToken); userCredentials.setResetToken(resetToken); - userCredentials.setAdditionalInfo(additionalInfo); return userCredentials; } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index da4bcc5bec..8ee0854ae9 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -370,8 +370,7 @@ CREATE TABLE IF NOT EXISTS user_credentials ( enabled boolean, password varchar(255), reset_token varchar(255) UNIQUE, - user_id uuid UNIQUE, - additional_info varchar + user_id uuid UNIQUE ); CREATE TABLE IF NOT EXISTS widget_type ( From 2cb6b6d425110f48da8a9971fb289adb96cbee6d Mon Sep 17 00:00:00 2001 From: Vladyslav Prykhodko Date: Thu, 28 Apr 2022 23:03:37 +0300 Subject: [PATCH 25/92] UI: Add page setting fb2 --- .../http/two-factor-authentication.service.ts | 25 +++ ui-ngx/src/app/core/services/menu.service.ts | 16 +- .../home/pages/admin/admin-routing.module.ts | 15 ++ .../modules/home/pages/admin/admin.module.ts | 4 +- .../two-factor-auth-settings.component.html | 142 ++++++++++++++++ .../two-factor-auth-settings.component.scss | 0 .../two-factor-auth-settings.component.ts | 155 ++++++++++++++++++ .../shared/models/two-factor-auth.models.ts | 30 ++++ .../assets/locale/locale.constant-en_US.json | 16 ++ 9 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 ui-ngx/src/app/core/http/two-factor-authentication.service.ts create mode 100644 ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts create mode 100644 ui-ngx/src/app/shared/models/two-factor-auth.models.ts diff --git a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts new file mode 100644 index 0000000000..d608d3b362 --- /dev/null +++ b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { TwoFactorAuthSettings } from '@shared/models/two-factor-auth.models'; + +@Injectable({ + providedIn: 'root' +}) +export class TwoFactorAuthenticationService { + + constructor( + private http: HttpClient + ) { + } + + getTwoFaSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/2fa/settings`, defaultHttpOptionsFromConfig(config)); + } + + saveTwoFaSettings(settings: TwoFactorAuthSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/settings`, settings, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 4a52fc66f6..294513c782 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -108,7 +108,7 @@ export class MenuService { name: 'admin.system-settings', type: 'toggle', path: '/settings', - height: '240px', + height: '280px', icon: 'settings', pages: [ { @@ -146,6 +146,14 @@ export class MenuService { path: '/settings/oauth2', icon: 'security' }, + { + id: guid(), + name: 'admin.2fa.2fa', + type: 'link', + path: '/settings/2fa', + icon: 'mdi:two-factor-authentication', + isMdiIcon: true + }, { id: guid(), name: 'resource.resources-library', @@ -216,6 +224,12 @@ export class MenuService { icon: 'security', path: '/settings/oauth2' }, + { + name: 'admin.2fa.2fa', + icon: 'mdi:two-factor-authentication', + isMdiIcon: true, + path: '/settings/2fa' + }, { name: 'resource.resources-library', icon: 'folder', diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index ddccbb4da4..e63eec7b52 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -32,6 +32,7 @@ import { ResourcesLibraryTableConfigResolver } from '@home/pages/admin/resource/ import { EntityDetailsPageComponent } from '@home/components/entity/entity-details-page.component'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; +import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component'; @Injectable() export class OAuth2LoginProcessingUrlResolver implements Resolve { @@ -183,6 +184,20 @@ const routes: Routes = [ } } ] + }, + { + path: '2fa', + component: TwoFactorAuthSettingsComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + title: 'admin.2fa.2fa', + breadcrumb: { + label: 'admin.2fa.2fa', + icon: 'mdi:two-factor-authentication', + isMdiIcon: true + } + } } ] } diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts index 326b16f950..37214d7ce5 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin.module.ts @@ -28,6 +28,7 @@ import { SmsProviderComponent } from '@home/pages/admin/sms-provider.component'; import { SendTestSmsDialogComponent } from '@home/pages/admin/send-test-sms-dialog.component'; import { HomeSettingsComponent } from '@home/pages/admin/home-settings.component'; import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources-library.component'; +import { TwoFactorAuthSettingsComponent } from '@home/pages/admin/two-factor-auth-settings.component'; @NgModule({ declarations: @@ -39,7 +40,8 @@ import { ResourcesLibraryComponent } from '@home/pages/admin/resource/resources- SecuritySettingsComponent, OAuth2SettingsComponent, HomeSettingsComponent, - ResourcesLibraryComponent + ResourcesLibraryComponent, + TwoFactorAuthSettingsComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html new file mode 100644 index 0000000000..9a507e45f2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -0,0 +1,142 @@ +
+ + +
+ admin.2fa.2fa + + +
+
+ + +
+ +
+ + {{ 'admin.2fa.use-system-two-factor-auth-settings' | translate }} + + + + admin.2fa.total-allowed-time-for-verification + + + {{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }} + + + {{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }} + + + + admin.2fa.max-verification-failures-before-user-lockout + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-required' | translate }} + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} + + + + admin.2fa.verification-code-send-rate-limit + + admin.2fa.verification-code-send-rate-limit-hint + + {{ 'admin.2fa.verification-code-send-rate-limit-pattern' | translate }} + + + + admin.2fa.verification-code-check-rate-limit + + admin.2fa.verification-code-check-rate-limit-hint + + {{ 'admin.2fa.verification-code-check-rate-limit-pattern' | translate }} + + +
Providers
+ +
+ + + + {{ provider.value.providerType }} + + + + + + + +
+
+
+ + admin.oauth2.login-provider + + + {{ provider }} + + + +
+ + admin.oauth2.allowed-platforms + + + {{ platformTypeTranslations.get(platform) | translate }} + + + +
+
+ + admin.oauth2.client-id + + + {{ 'admin.oauth2.client-id-required' | translate }} + + + {{ 'admin.oauth2.client-id-max-length' | translate }} + + + + + admin.oauth2.client-secret + + + {{ 'admin.oauth2.client-secret-required' | translate }} + + + {{ 'admin.oauth2.client-secret-max-length' | translate }} + + +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts new file mode 100644 index 0000000000..1995605786 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts @@ -0,0 +1,155 @@ +import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { ActivatedRoute } from '@angular/router'; +import { AbstractControl, FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { DialogService } from '@core/services/dialog.service'; +import { TranslateService } from '@ngx-translate/core'; +import { WINDOW } from '@core/services/window.service'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { AuthState } from '@core/auth/auth.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { Authority } from '@shared/models/authority.enum'; +import { + TwoFactorAuthProviderType, + TwoFactorAuthSettings, + TwoFactorAuthSettingsForm +} from '@shared/models/two-factor-auth.models'; + +@Component({ + selector: 'tb-2fa-settings', + templateUrl: './two-factor-auth-settings.component.html', + styleUrls: ['./two-factor-auth-settings.component.scss', './settings-card.scss'] +}) +export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy { + + private authState: AuthState = getCurrentAuthState(this.store); + private authUser = this.authState.authUser; + + twoFaFormGroup: FormGroup; + + constructor(protected store: Store, + private route: ActivatedRoute, + private twoFaService: TwoFactorAuthenticationService, + private fb: FormBuilder, + private dialogService: DialogService, + private translate: TranslateService, + @Inject(WINDOW) private window: Window) { + super(store); + } + + ngOnInit() { + this.build2faSettingsForm(); + this.twoFaService.getTwoFaSettings().subscribe((setting) => { + console.log(this.formDataPreprocessing(setting)); + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + } + + confirmForm(): FormGroup { + return this.twoFaFormGroup; + } + + isTenantAdmin(): boolean { + return this.authUser.authority === Authority.TENANT_ADMIN; + } + + save() { + + } + + private build2faSettingsForm(): void { + this.twoFaFormGroup = this.fb.group({ + useSystemTwoFactorAuthSettings: [false], + maxVerificationFailuresBeforeUserLockout: [30, [ + Validators.required, + Validators.pattern(/^\d*$/), + Validators.min(0), + Validators.max(65535) + ]], + totalAllowedTimeForVerification: [3600, [ + Validators.required, + Validators.min(1), + Validators.pattern(/^\d*$/) + ]], + verificationCodeCheckRateLimit: ['', Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)], + verificationCodeSendRateLimit: ['', Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)], + providers: this.fb.array([]) + }); + } + + addProviders() { + const newProviders = this.fb.group({ + providerType: [TwoFactorAuthProviderType.TOTP], + issuerName: ['', Validators.required], + smsVerificationMessageTemplate: [{ + value: 'Verification code: ${verificationCode}', + disabled: true + }, [ + Validators.required, + Validators.pattern(/\${verificationCode}/) + ]], + verificationCodeLifetime: [{ + value: 120, + disabled: true + }, [ + Validators.required, + Validators.min(1), + Validators.pattern(/^\d*$/) + ]] + }); + newProviders.get('providerType').valueChanges.subscribe(type => { + switch (type) { + case TwoFactorAuthProviderType.SMS: + newProviders.get('issuerName').disable({emitEvent: false}); + newProviders.get('smsVerificationMessageTemplate').enable({emitEvent: false}); + newProviders.get('verificationCodeLifetime').enable({emitEvent: false}); + break; + case TwoFactorAuthProviderType.TOTP: + newProviders.get('issuerName').enable({emitEvent: false}); + newProviders.get('smsVerificationMessageTemplate').disable({emitEvent: false}); + newProviders.get('verificationCodeLifetime').disable({emitEvent: false}); + break; + } + }); + if (this.providersForm.length) { + const selectProvidersType = this.providersForm.value[0].providerType; + if (selectProvidersType !== TwoFactorAuthProviderType.TOTP) { + newProviders.get('providerType').patchValue(TwoFactorAuthProviderType.SMS, {emitEvents: true}) + } + } + this.providersForm.push(newProviders); + } + + removeProviders($event: Event, index: number): void { + if ($event) { + $event.stopPropagation(); + $event.preventDefault(); + } + this.providersForm.removeAt(index); + this.providersForm.markAsTouched(); + this.providersForm.markAsDirty(); + } + + get providersForm(): FormArray { + return this.twoFaFormGroup.get('providers') as FormArray; + } + + private formDataPreprocessing(data: TwoFactorAuthSettings): TwoFactorAuthSettingsForm { + return data; + } + + private formDataPostprocessing(data: TwoFactorAuthSettingsForm): TwoFactorAuthSettings{ + return data; + } + + trackByParams(index: number): number { + return index; + } + +} diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts new file mode 100644 index 0000000000..b2c32e6156 --- /dev/null +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -0,0 +1,30 @@ +export interface TwoFactorAuthSettings { + maxVerificationFailuresBeforeUserLockout: number; + providers: Array; + totalAllowedTimeForVerification: number; + useSystemTwoFactorAuthSettings: boolean; + verificationCodeCheckRateLimit: string; + verificationCodeSendRateLimit: string; +} + +export type TwoFactorAuthProviderConfig = Partial + +export interface TotpTwoFactorAuthProviderConfig { + providerType: TwoFactorAuthProviderType; + issuerName: string; +} + +export interface SmsTwoFactorAuthProviderConfig { + providerType: TwoFactorAuthProviderType; + smsVerificationMessageTemplate: string; + verificationCodeLifetime: number; +} + +export enum TwoFactorAuthProviderType{ + TOTP = 'TOTP', + SMS = 'SMS' +} + +export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings { + +} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index aab433c992..642592b669 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -252,6 +252,22 @@ "platform-ios": "iOS", "all-platforms": "All platforms", "allowed-platforms": "Allowed platforms" + }, + "2fa": { + "2fa": "Two-factor authentication", + "use-system-two-factor-auth-settings": "Use system two factor auth settings", + "total-allowed-time-for-verification": "Total allowed time for verification", + "total-allowed-time-for-verification-required": "Total allowed time is required.", + "total-allowed-time-for-verification-pattern": "Total allowed time must be a positive integer.", + "max-verification-failures-before-user-lockout": "Max verification failures before user lockout", + "max-verification-failures-before-user-lockout-required": "Max verification failures is required.", + "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.", + "verification-code-check-rate-limit": "Verification code check rate limit", + "verification-code-check-rate-limit-hint": "If empty field, the limit not be apply", + "verification-code-check-rate-limit-pattern": "Verification code check limit has invalid format", + "verification-code-send-rate-limit": "Verification code send rate limit", + "verification-code-send-rate-limit-hint": "If empty field, the limit not be apply", + "verification-code-send-rate-limit-pattern": "Verification code send limit has invalid format" } }, "alarm": { From d9a2495ea43c4bc246dc7a2433d2b7d4d0d1288b Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 29 Apr 2022 17:25:06 +0300 Subject: [PATCH 26/92] Add upgrade script for 2FA --- .../main/data/upgrade/3.3.4/schema_update.sql | 22 +++++++++++++++++++ .../install/ThingsboardInstallService.java | 1 + .../install/SqlDatabaseUpgradeService.java | 14 ++++++++++++ 3 files changed, 37 insertions(+) create mode 100644 application/src/main/data/upgrade/3.3.4/schema_update.sql diff --git a/application/src/main/data/upgrade/3.3.4/schema_update.sql b/application/src/main/data/upgrade/3.3.4/schema_update.sql new file mode 100644 index 0000000000..d2134bdc48 --- /dev/null +++ b/application/src/main/data/upgrade/3.3.4/schema_update.sql @@ -0,0 +1,22 @@ +-- +-- 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. +-- + +CREATE TABLE IF NOT EXISTS user_auth_settings ( + id uuid NOT NULL CONSTRAINT user_auth_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + user_id uuid UNIQUE NOT NULL CONSTRAINT fk_user_auth_settings_user_id REFERENCES tb_user(id), + mfa_account_config varchar +); diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index ebab403149..e728c004c0 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -219,6 +219,7 @@ public class ThingsboardInstallService { databaseEntitiesUpgradeService.upgradeDatabase("3.3.3"); case "3.3.4": log.info("Upgrading ThingsBoard from version 3.3.4 to 3.4.0 ..."); + databaseEntitiesUpgradeService.upgradeDatabase("3.3.4"); log.info("Updating system data..."); systemDataLoaderService.updateSystemWidgets(); break; diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index f7b9e5cad1..7be899f995 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -534,6 +534,20 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService log.error("Failed updating schema!!!", e); } break; + case "3.3.4": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "3.3.4", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + + log.info("Updating schema settings..."); + conn.createStatement().execute("UPDATE tb_schema_settings SET schema_version = 3004000;"); + log.info("Schema updated."); + } catch (Exception e) { + log.error("Failed updating schema!!!", e); + } + break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); } From e29be2bdedb4a86d159a608d5fb67010b2d270a8 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 29 Apr 2022 18:12:35 +0300 Subject: [PATCH 27/92] UI: Add 2FA general setting form --- .../http/two-factor-authentication.service.ts | 16 ++ ui-ngx/src/app/core/services/menu.service.ts | 16 +- .../two-factor-auth-settings.component.html | 266 ++++++++++-------- .../two-factor-auth-settings.component.scss | 21 ++ .../two-factor-auth-settings.component.ts | 71 +++-- .../shared/models/two-factor-auth.models.ts | 22 +- .../assets/locale/locale.constant-en_US.json | 26 +- 7 files changed, 283 insertions(+), 155 deletions(-) diff --git a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts index d608d3b362..230eddd208 100644 --- a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts +++ b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts @@ -1,3 +1,19 @@ +/// +/// 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. +/// + import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 294513c782..15ab552d53 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -364,7 +364,7 @@ export class MenuService { name: 'admin.system-settings', type: 'toggle', path: '/settings', - height: '80px', + height: '120px', icon: 'settings', pages: [ { @@ -374,6 +374,14 @@ export class MenuService { path: '/settings/home', icon: 'settings_applications' }, + { + id: guid(), + name: 'admin.2fa.2fa', + type: 'link', + path: '/settings/2fa', + icon: 'mdi:two-factor-authentication', + isMdiIcon: true + }, { id: guid(), name: 'resource.resources-library', @@ -510,6 +518,12 @@ export class MenuService { icon: 'settings_applications', path: '/settings/home' }, + { + name: 'admin.2fa.2fa', + icon: 'mdi:two-factor-authentication', + isMdiIcon: true, + path: '/settings/2fa' + }, { name: 'resource.resources-library', icon: 'folder', diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index 9a507e45f2..c92fe6de74 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -1,3 +1,20 @@ +
@@ -11,131 +28,146 @@
-
- - {{ 'admin.2fa.use-system-two-factor-auth-settings' | translate }} - - - - admin.2fa.total-allowed-time-for-verification - - - {{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }} - - - {{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }} - - - - admin.2fa.max-verification-failures-before-user-lockout - - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-required' | translate }} - - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} - - - - admin.2fa.verification-code-send-rate-limit - - admin.2fa.verification-code-send-rate-limit-hint - - {{ 'admin.2fa.verification-code-send-rate-limit-pattern' | translate }} - - - - admin.2fa.verification-code-check-rate-limit - - admin.2fa.verification-code-check-rate-limit-hint - - {{ 'admin.2fa.verification-code-check-rate-limit-pattern' | translate }} - - -
Providers
- -
- - - - {{ provider.value.providerType }} - - - - - + +
+ + {{ 'admin.2fa.use-system-two-factor-auth-settings' | translate }} + + + + admin.2fa.total-allowed-time-for-verification + + + {{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }} + + + {{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }} + + + + admin.2fa.max-verification-failures-before-user-lockout + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-required' | translate }} + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} + + + + admin.2fa.verification-code-send-rate-limit + + + {{ 'admin.2fa.verification-code-send-rate-limit-required' | translate }} + + + {{ 'admin.2fa.verification-code-send-rate-limit-pattern' | translate }} + + + + admin.2fa.verification-code-check-rate-limit + + + {{ 'admin.2fa.verification-code-check-rate-limit-required' | translate }} + + + {{ 'admin.2fa.verification-code-check-rate-limit-pattern' | translate }} + + +
admin.2fa.available-providers
+ +
+ + + + + {{ provider.value.providerType }} + + + + + - -
-
-
+ +
- admin.oauth2.login-provider - - - {{ provider }} + admin.2fa.provider + + + {{ twoFactorAuthProviderType }} -
- - admin.oauth2.allowed-platforms - - - {{ platformTypeTranslations.get(platform) | translate }} - - - -
-
- - admin.oauth2.client-id - - - {{ 'admin.oauth2.client-id-required' | translate }} - - - {{ 'admin.oauth2.client-id-max-length' | translate }} - - + + + + admin.2fa.issuer-name + + + {{ "admin.2fa.issuer-name-required" | translate }} + + + +
+ + admin.2fa.verification-message-template + + + {{ "admin.2fa.verification-message-template-required" | translate }} + + + {{ "admin.2fa.verification-message-template-pattern" | translate }} + + - - admin.oauth2.client-secret - - - {{ 'admin.oauth2.client-secret-required' | translate }} - - - {{ 'admin.oauth2.client-secret-max-length' | translate }} - - -
+ + admin.2fa.verification-code-lifetime + + + {{ "admin.2fa.verification-code-lifetime-required" | translate }} + + + {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} + + +
+ +
+
+
+
+
+
- - - -
- -
-
- -
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss index e69de29bb2..8fb412f648 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss @@ -0,0 +1,21 @@ +/** + * 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. + */ + +:host{ + .container { + margin-bottom: 1em; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts index 1995605786..992c1c8ae4 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts @@ -1,10 +1,26 @@ +/// +/// 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. +/// + import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { ActivatedRoute } from '@angular/router'; -import { AbstractControl, FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; import { WINDOW } from '@core/services/window.service'; @@ -12,11 +28,7 @@ import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentica import { AuthState } from '@core/auth/auth.models'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; -import { - TwoFactorAuthProviderType, - TwoFactorAuthSettings, - TwoFactorAuthSettingsForm -} from '@shared/models/two-factor-auth.models'; +import { TwoFactorAuthProviderType, TwoFactorAuthSettings } from '@shared/models/two-factor-auth.models'; @Component({ selector: 'tb-2fa-settings', @@ -29,6 +41,8 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI private authUser = this.authState.authUser; twoFaFormGroup: FormGroup; + twoFactorAuthProviderTypes = Object.keys(TwoFactorAuthProviderType); + twoFactorAuthProviderType = TwoFactorAuthProviderType; constructor(protected store: Store, private route: ActivatedRoute, @@ -43,7 +57,7 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI ngOnInit() { this.build2faSettingsForm(); this.twoFaService.getTwoFaSettings().subscribe((setting) => { - console.log(this.formDataPreprocessing(setting)); + this.initTwoFactorAuthForm(setting); }); } @@ -60,12 +74,19 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI } save() { - + const setting = this.twoFaFormGroup.value; + this.twoFaService.saveTwoFaSettings(setting).subscribe( + (twoFactorAuthSettings) => { + this.twoFaFormGroup.patchValue(twoFactorAuthSettings, {emitEvent: false}); + this.twoFaFormGroup.markAsUntouched(); + this.twoFaFormGroup.markAsPristine(); + } + ); } private build2faSettingsForm(): void { this.twoFaFormGroup = this.fb.group({ - useSystemTwoFactorAuthSettings: [false], + useSystemTwoFactorAuthSettings: [this.isTenantAdmin()], maxVerificationFailuresBeforeUserLockout: [30, [ Validators.required, Validators.pattern(/^\d*$/), @@ -77,13 +98,20 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI Validators.min(1), Validators.pattern(/^\d*$/) ]], - verificationCodeCheckRateLimit: ['', Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)], - verificationCodeSendRateLimit: ['', Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)], + verificationCodeCheckRateLimit: ['3:900', [Validators.required, Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)]], + verificationCodeSendRateLimit: ['1:60', [Validators.required, Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)]], providers: this.fb.array([]) }); } - addProviders() { + private initTwoFactorAuthForm(settings: TwoFactorAuthSettings) { + settings.providers.forEach(() => { + this.addProvider(); + }); + this.twoFaFormGroup.patchValue(settings, {emitEvent: false}); + } + + addProvider() { const newProviders = this.fb.group({ providerType: [TwoFactorAuthProviderType.TOTP], issuerName: ['', Validators.required], @@ -119,8 +147,9 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI }); if (this.providersForm.length) { const selectProvidersType = this.providersForm.value[0].providerType; - if (selectProvidersType !== TwoFactorAuthProviderType.TOTP) { - newProviders.get('providerType').patchValue(TwoFactorAuthProviderType.SMS, {emitEvents: true}) + if (selectProvidersType === TwoFactorAuthProviderType.TOTP) { + newProviders.get('providerType').setValue(TwoFactorAuthProviderType.SMS); + newProviders.updateValueAndValidity(); } } this.providersForm.push(newProviders); @@ -140,16 +169,10 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI return this.twoFaFormGroup.get('providers') as FormArray; } - private formDataPreprocessing(data: TwoFactorAuthSettings): TwoFactorAuthSettingsForm { - return data; - } - - private formDataPostprocessing(data: TwoFactorAuthSettingsForm): TwoFactorAuthSettings{ - return data; - } - - trackByParams(index: number): number { - return index; + selectedTypes(type: TwoFactorAuthProviderType, index: number): boolean { + const selectedProviderTypes: TwoFactorAuthProviderType[] = this.providersForm.value.map(providers => providers.providerType); + selectedProviderTypes.splice(index, 1); + return selectedProviderTypes.includes(type); } } diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts index b2c32e6156..ab64143a66 100644 --- a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -1,3 +1,19 @@ +/// +/// 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. +/// + export interface TwoFactorAuthSettings { maxVerificationFailuresBeforeUserLockout: number; providers: Array; @@ -7,7 +23,7 @@ export interface TwoFactorAuthSettings { verificationCodeSendRateLimit: string; } -export type TwoFactorAuthProviderConfig = Partial +export type TwoFactorAuthProviderConfig = Partial; export interface TotpTwoFactorAuthProviderConfig { providerType: TwoFactorAuthProviderType; @@ -24,7 +40,3 @@ export enum TwoFactorAuthProviderType{ TOTP = 'TOTP', SMS = 'SMS' } - -export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings { - -} diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 642592b669..89b9bc5d2d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -255,19 +255,29 @@ }, "2fa": { "2fa": "Two-factor authentication", - "use-system-two-factor-auth-settings": "Use system two factor auth settings", - "total-allowed-time-for-verification": "Total allowed time for verification", - "total-allowed-time-for-verification-required": "Total allowed time is required.", - "total-allowed-time-for-verification-pattern": "Total allowed time must be a positive integer.", + "available-providers": "Available providers:", + "issuer-name": "Issuer name", + "issuer-name-required": "Issuer name is required.", "max-verification-failures-before-user-lockout": "Max verification failures before user lockout", - "max-verification-failures-before-user-lockout-required": "Max verification failures is required.", "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.", + "max-verification-failures-before-user-lockout-required": "Max verification failures is required.", + "provider": "Provider", + "total-allowed-time-for-verification": "Total allowed time for verification", + "total-allowed-time-for-verification-pattern": "Total allowed time must be a positive integer.", + "total-allowed-time-for-verification-required": "Total allowed time is required.", + "use-system-two-factor-auth-settings": "Use system two factor auth settings", "verification-code-check-rate-limit": "Verification code check rate limit", - "verification-code-check-rate-limit-hint": "If empty field, the limit not be apply", "verification-code-check-rate-limit-pattern": "Verification code check limit has invalid format", + "verification-code-check-rate-limit-required": "Verification code check rate limit is required.", + "verification-code-lifetime": "Verification code lifetime", + "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.", + "verification-code-lifetime-required": "Verification code lifetime is required.", "verification-code-send-rate-limit": "Verification code send rate limit", - "verification-code-send-rate-limit-hint": "If empty field, the limit not be apply", - "verification-code-send-rate-limit-pattern": "Verification code send limit has invalid format" + "verification-code-send-rate-limit-pattern": "Verification code send limit has invalid format", + "verification-code-send-rate-limit-required": "Verification code send rate limit is required.", + "verification-message-template": "Verification message template", + "verification-message-template-pattern": "Verification message need to contains pattern: ${verificationCode}", + "verification-message-template-required": "Verification message template is required." } }, "alarm": { From 812e3490a6a2c29fdc8f034df2baea376447ceb0 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 2 May 2022 12:27:49 +0300 Subject: [PATCH 28/92] API to get the list of available 2FA providers for user --- .../TwoFactorAuthConfigController.java | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) 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 d4cc690604..f35f7a231a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; @@ -46,6 +47,10 @@ import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; @RestController @@ -63,15 +68,15 @@ public class TwoFactorAuthConfigController extends BaseController { "or if a provider for previously set up account config is not now configured." + NEW_LINE + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE + "Response example for TOTP 2FA: " + NEW_LINE + - "{\n" + + "```\n{\n" + " \"providerType\": \"TOTP\",\n" + " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + - "}" + NEW_LINE + + "}\n```" + NEW_LINE + "Response example for SMS 2FA: " + NEW_LINE + - "{\n" + + "```\n{\n" + " \"providerType\": \"SMS\",\n" + " \"phoneNumber\": \"+380505005050\"\n" + - "}") + "}\n```") @GetMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public TwoFactorAuthAccountConfig getTwoFaAccountConfig() throws ThingsboardException { @@ -79,6 +84,17 @@ public class TwoFactorAuthConfigController extends BaseController { return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); } + + @GetMapping("/providers") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public List getAvailableTwoFaProviders() throws ThingsboardException { + return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId(), true) + .map(TwoFactorAuthSettings::getProviders).orElse(Collections.emptyList()).stream() + .map(TwoFactorAuthProviderConfig::getProviderType) + .collect(Collectors.toList()); + } + + @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", notes = "Generate new 2FA account config for specified provider type. " + "This method is only useful for TOTP 2FA, as there is nothing to generate for other provider types. " + @@ -89,15 +105,15 @@ 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 + NEW_LINE + "Example of a generated account config for TOTP 2FA: " + NEW_LINE + - "{\n" + + "```\n{\n" + " \"providerType\": \"TOTP\",\n" + " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + - "}" + NEW_LINE + + "}\n```" + NEW_LINE + "For SMS provider type it will return something like: " + NEW_LINE + - "{\n" + + "```\n{\n" + " \"providerType\": \"SMS\",\n" + " \"phoneNumber\": null\n" + - "}") + "}\n```") @PostMapping("/account/config/generate") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public TwoFactorAuthAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true) From bcc736991ed9bf789053dc0b0a051691946723e7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Wed, 4 May 2022 14:29:36 +0300 Subject: [PATCH 29/92] Multiple used 2FA providers for account --- .../main/data/upgrade/3.3.4/schema_update.sql | 2 +- ...roller.java => TwoFaConfigController.java} | 107 +-- .../controller/TwoFactorAuthController.java | 44 +- .../auth/mfa/DefaultTwoFactorAuthService.java | 120 ++-- .../auth/mfa/TwoFactorAuthService.java | 21 +- ...er.java => DefaultTwoFaConfigManager.java} | 89 ++- .../auth/mfa/config/TwoFaConfigManager.java | 45 ++ .../config/TwoFactorAuthConfigManager.java | 43 -- ...orAuthProvider.java => TwoFaProvider.java} | 10 +- ...ovider.java => OtpBasedTwoFaProvider.java} | 12 +- ...uthProvider.java => SmsTwoFaProvider.java} | 20 +- ...thProvider.java => TotpTwoFaProvider.java} | 22 +- .../auth/rest/RestAuthenticationProvider.java | 10 +- ...RestAwareAuthenticationSuccessHandler.java | 6 +- .../system/DefaultSystemSecurityService.java | 4 +- .../system/SystemSecurityService.java | 4 +- .../controller/TwoFactorAuthConfigTest.java | 141 ++-- .../server/controller/TwoFactorAuthTest.java | 644 +++++++++--------- .../data/security/UserAuthSettings.java | 4 +- ...ttings.java => PlatformTwoFaSettings.java} | 10 +- .../mfa/account/AccountTwoFaSettings.java | 26 + ...g.java => OtpBasedTwoFaAccountConfig.java} | 4 +- ...Config.java => SmsTwoFaAccountConfig.java} | 8 +- ...onfig.java => TotpTwoFaAccountConfig.java} | 13 +- ...untConfig.java => TwoFaAccountConfig.java} | 14 +- ....java => OtpBasedTwoFaProviderConfig.java} | 2 +- ...onfig.java => SmsTwoFaProviderConfig.java} | 8 +- ...nfig.java => TotpTwoFaProviderConfig.java} | 6 +- ...erConfig.java => TwoFaProviderConfig.java} | 8 +- ...oviderType.java => TwoFaProviderType.java} | 2 +- .../server/common/msg/tools/TbRateLimits.java | 6 + .../server/dao/model/ModelConstants.java | 2 +- .../dao/model/sql/UserAuthSettingsEntity.java | 15 +- .../main/resources/sql/schema-entities.sql | 2 +- 34 files changed, 799 insertions(+), 675 deletions(-) rename application/src/main/java/org/thingsboard/server/controller/{TwoFactorAuthConfigController.java => TwoFaConfigController.java} (70%) rename application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/{DefaultTwoFactorAuthConfigManager.java => DefaultTwoFaConfigManager.java} (62%) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java delete mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java rename application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/{TwoFactorAuthProvider.java => TwoFaProvider.java} (84%) rename application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/{OtpBasedTwoFactorAuthProvider.java => OtpBasedTwoFaProvider.java} (87%) rename application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/{SmsTwoFactorAuthProvider.java => SmsTwoFaProvider.java} (73%) rename application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/{TotpTwoFactorAuthProvider.java => TotpTwoFaProvider.java} (76%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/{TwoFactorAuthSettings.java => PlatformTwoFaSettings.java} (92%) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/{OtpBasedTwoFactorAuthAccountConfig.java => OtpBasedTwoFaAccountConfig.java} (82%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/{SmsTwoFactorAuthAccountConfig.java => SmsTwoFaAccountConfig.java} (86%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/{TotpTwoFactorAuthAccountConfig.java => TotpTwoFaAccountConfig.java} (84%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/{TwoFactorAuthAccountConfig.java => TwoFaAccountConfig.java} (79%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/{OtpBasedTwoFactorAuthProviderConfig.java => OtpBasedTwoFaProviderConfig.java} (91%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/{SmsTwoFactorAuthProviderConfig.java => SmsTwoFaProviderConfig.java} (85%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/{TotpTwoFactorAuthProviderConfig.java => TotpTwoFaProviderConfig.java} (85%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/{TwoFactorAuthProviderConfig.java => TwoFaProviderConfig.java} (82%) rename common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/{TwoFactorAuthProviderType.java => TwoFaProviderType.java} (94%) diff --git a/application/src/main/data/upgrade/3.3.4/schema_update.sql b/application/src/main/data/upgrade/3.3.4/schema_update.sql index d2134bdc48..2b1524fcb3 100644 --- a/application/src/main/data/upgrade/3.3.4/schema_update.sql +++ b/application/src/main/data/upgrade/3.3.4/schema_update.sql @@ -18,5 +18,5 @@ CREATE TABLE IF NOT EXISTS user_auth_settings ( id uuid NOT NULL CONSTRAINT user_auth_settings_pkey PRIMARY KEY, created_time bigint NOT NULL, user_id uuid UNIQUE NOT NULL CONSTRAINT fk_user_auth_settings_user_id REFERENCES tb_user(id), - mfa_account_config varchar + two_fa_settings varchar ); diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java similarity index 70% rename from application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java rename to application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java index f35f7a231a..6eb192b7e2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java @@ -21,11 +21,13 @@ import com.google.zxing.common.BitMatrix; import com.google.zxing.qrcode.QRCodeWriter; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; +import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -33,20 +35,20 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; +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.TotpTwoFaAccountConfig; +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.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.service.security.model.SecurityUser; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; - import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -57,9 +59,9 @@ import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; @RequestMapping("/api/2fa") @TbCoreComponent @RequiredArgsConstructor -public class TwoFactorAuthConfigController extends BaseController { +public class TwoFaConfigController extends BaseController { - private final TwoFactorAuthConfigManager twoFactorAuthConfigManager; + private final TwoFaConfigManager twoFaConfigManager; private final TwoFactorAuthService twoFactorAuthService; @@ -71,27 +73,17 @@ public class TwoFactorAuthConfigController extends BaseController { "```\n{\n" + " \"providerType\": \"TOTP\",\n" + " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + - "}\n```" + NEW_LINE + + "}\n```" + NEW_LINE + "Response example for SMS 2FA: " + NEW_LINE + "```\n{\n" + " \"providerType\": \"SMS\",\n" + " \"phoneNumber\": \"+380505005050\"\n" + "}\n```") - @GetMapping("/account/config") + @GetMapping("/account/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public TwoFactorAuthAccountConfig getTwoFaAccountConfig() throws ThingsboardException { + public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException { SecurityUser user = getCurrentUser(); - return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()).orElse(null); - } - - - @GetMapping("/providers") - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public List getAvailableTwoFaProviders() throws ThingsboardException { - return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId(), true) - .map(TwoFactorAuthSettings::getProviders).orElse(Collections.emptyList()).stream() - .map(TwoFactorAuthProviderConfig::getProviderType) - .collect(Collectors.toList()); + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()).orElse(null); } @@ -116,19 +108,19 @@ public class TwoFactorAuthConfigController extends BaseController { "}\n```") @PostMapping("/account/config/generate") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public TwoFactorAuthAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true) - @RequestParam TwoFactorAuthProviderType providerType) throws Exception { + public TwoFaAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true) + @RequestParam TwoFaProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); return twoFactorAuthService.generateNewAccountConfig(user, providerType); } /* TMP */ - @PostMapping("/account/config/generate/qr") + @PostMapping("/account/config/tmp/generate/qr") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void generateTwoFaAccountConfigWithQr(@RequestParam TwoFactorAuthProviderType providerType, HttpServletResponse response) throws Exception { - TwoFactorAuthAccountConfig config = generateTwoFaAccountConfig(providerType); - if (providerType == TwoFactorAuthProviderType.TOTP) { - BitMatrix qr = new QRCodeWriter().encode(((TotpTwoFactorAuthAccountConfig) config).getAuthUrl(), BarcodeFormat.QR_CODE, 200, 200); + public void generateTwoFaAccountConfigWithQr(@RequestParam TwoFaProviderType providerType, HttpServletResponse response) throws Exception { + TwoFaAccountConfig config = generateTwoFaAccountConfig(providerType); + if (providerType == TwoFaProviderType.TOTP) { + BitMatrix qr = new QRCodeWriter().encode(((TotpTwoFaAccountConfig) config).getAuthUrl(), BarcodeFormat.QR_CODE, 200, 200); try (ServletOutputStream outputStream = response.getOutputStream()) { MatrixToImageWriter.writeToStream(qr, "PNG", outputStream); } @@ -148,7 +140,7 @@ public class TwoFactorAuthConfigController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public void submitTwoFaAccountConfig(@ApiParam(value = "2FA account config value. For TOTP 2FA config, authUrl value must not be blank and must match specific pattern. " + "For SMS 2FA, phoneNumber property must not be blank and must be of E.164 phone number format.", required = true) - @Valid @RequestBody TwoFactorAuthAccountConfig accountConfig) throws Exception { + @Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); } @@ -160,36 +152,57 @@ public class TwoFactorAuthConfigController extends BaseController { ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PostMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void verifyAndSaveTwoFaAccountConfig(@ApiParam(value = "2FA account config to save. Validation rules are the same as in submitTwoFaAccountConfig API method", required = true) - @Valid @RequestBody TwoFactorAuthAccountConfig accountConfig, - @ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS message in case of SMS 2FA", required = true) - @RequestParam String verificationCode) throws Exception { + public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@ApiParam(value = "2FA account config to save. Validation rules are the same as in submitTwoFaAccountConfig API method", required = true) + @Valid @RequestBody TwoFaAccountConfig accountConfig, + @ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS message in case of SMS 2FA", required = true) + @RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false); if (verificationSuccess) { - twoFactorAuthConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); } else { throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.INVALID_ARGUMENTS); } } + @PutMapping("/account/config") + public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType, + @RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException { + SecurityUser user = getCurrentUser(); + TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + .orElseThrow(() -> new IllegalArgumentException("No 2FA config for provider " + providerType)); + + accountConfig.setUseByDefault(updateRequest.isUseByDefault()); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + } + @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes = "Delete user's 2FA config. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @DeleteMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void deleteTwoFaAccountConfig() throws ThingsboardException { + public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException { SecurityUser user = getCurrentUser(); - twoFactorAuthConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId()); + return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType); + } + + + @GetMapping("/providers") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + public List getAvailableTwoFaProviders() throws ThingsboardException { + return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) + .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() + .map(TwoFaProviderConfig::getProviderType) + .collect(Collectors.toList()); } - @ApiOperation(value = "Get 2FA settings (getTwoFaSettings)", + @ApiOperation(value = "Get 2FA settings (getTwoFaSettings)", // FIXME [viacheslav] notes = "Get settings for 2FA. If 2FA is not configured, then an empty response will be returned." + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @GetMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public TwoFactorAuthSettings getTwoFaSettings() throws ThingsboardException { - return twoFactorAuthConfigManager.getTwoFaSettings(getTenantId(), false).orElse(null); + public PlatformTwoFaSettings getPlatformTwoFaSettings() throws ThingsboardException { + return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), false).orElse(null); } @ApiOperation(value = "Save 2FA settings (saveTwoFaSettings)", @@ -198,9 +211,15 @@ public class TwoFactorAuthConfigController extends BaseController { ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PostMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public void saveTwoFaSettings(@ApiParam(value = "Settings value", required = true) - @RequestBody TwoFactorAuthSettings twoFaSettings) throws ThingsboardException { - twoFactorAuthConfigManager.saveTwoFaSettings(getTenantId(), twoFaSettings); + public void savePlatformTwoFaSettings(@ApiParam(value = "Settings value", required = true) + @RequestBody PlatformTwoFaSettings twoFaSettings) throws ThingsboardException { + twoFaConfigManager.savePlatformTwoFaSettings(getTenantId(), twoFaSettings); + } + + + @Data + public static class TwoFaAccountConfigUpdateRequest { + private boolean useByDefault; } } 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 2a74d565b5..a1fe5a7cfc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -17,6 +17,9 @@ package org.thingsboard.server.controller; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; @@ -30,9 +33,8 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.model.JwtTokenPair; import org.thingsboard.server.service.security.model.SecurityUser; @@ -41,6 +43,10 @@ import org.thingsboard.server.service.security.system.SystemSecurityService; import javax.servlet.http.HttpServletRequest; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; @RestController @@ -50,7 +56,7 @@ import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; public class TwoFactorAuthController extends BaseController { private final TwoFactorAuthService twoFactorAuthService; - private final TwoFactorAuthConfigManager twoFactorAuthConfigManager; + private final TwoFaConfigManager twoFaConfigManager; private final JwtTokenFactory tokenFactory; private final SystemSecurityService systemSecurityService; private final UserService userService; @@ -65,9 +71,9 @@ public class TwoFactorAuthController extends BaseController { "and Too Many Requests error if rate limits are exceeded.") @PostMapping("/verification/send") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public void requestTwoFaVerificationCode() throws Exception { + public void requestTwoFaVerificationCode(@RequestParam TwoFaProviderType providerType) throws Exception { SecurityUser user = getCurrentUser(); - twoFactorAuthService.prepareVerificationCode(user, true); + twoFactorAuthService.prepareVerificationCode(user, providerType, true); } @ApiOperation(value = "Check 2FA verification code (checkTwoFaVerificationCode)", @@ -79,9 +85,10 @@ public class TwoFactorAuthController extends BaseController { @PostMapping("/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public JwtTokenPair checkTwoFaVerificationCode(@ApiParam(value = "6-digit verification code", required = true) + @RequestParam TwoFaProviderType providerType, @RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { SecurityUser user = getCurrentUser(); - boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, true); + 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()); @@ -93,13 +100,26 @@ public class TwoFactorAuthController extends BaseController { } } - @ApiOperation(value = "Get currently used 2FA provider type (getCurrentlyUsedTwoFaProviderType)") - @GetMapping("/provider/type") + + @GetMapping("/providers") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public TwoFactorAuthProviderType getCurrentlyUsedTwoFaProviderType() throws ThingsboardException { + public List getAvailableTwoFaProviders() throws ThingsboardException { SecurityUser user = getCurrentUser(); - return twoFactorAuthConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId()) - .map(TwoFactorAuthAccountConfig::getProviderType).orElse(null); + return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()) + .map(settings -> settings.getConfigs().values()).orElse(Collections.emptyList()) + .stream().map(config -> TwoFaProviderInfo.builder() + .type(config.getProviderType()) + .isDefault(config.isUseByDefault()) + .build()) + .collect(Collectors.toList()); + } + + @Data + @AllArgsConstructor + @Builder + public static class TwoFaProviderInfo { + private TwoFaProviderType type; + private boolean isDefault; } } 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 9b2b0b90bd..fd4f16ccdb 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 @@ -25,15 +25,15 @@ 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.id.UserId; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import org.thingsboard.server.common.msg.tools.TbRateLimits; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.service.security.auth.mfa.provider.TwoFactorAuthProvider; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +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; +import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.system.SystemSecurityService; @@ -49,117 +49,129 @@ import java.util.concurrent.ConcurrentMap; @TbCoreComponent public class DefaultTwoFactorAuthService implements TwoFactorAuthService { - private final TwoFactorAuthConfigManager configManager; + private final TwoFaConfigManager configManager; private final SystemSecurityService systemSecurityService; private final UserService userService; - private final Map> providers = new EnumMap<>(TwoFactorAuthProviderType.class); - - // TODO [viacheslav]: these rate limits are local, and will work bad in the cluster - private final ConcurrentMap verificationCodeSendingRateLimits = new ConcurrentHashMap<>(); - private final ConcurrentMap verificationCodeCheckingRateLimits = new ConcurrentHashMap<>(); + private final Map> providers = new EnumMap<>(TwoFaProviderType.class); private static final ThingsboardException ACCOUNT_NOT_CONFIGURED_ERROR = new ThingsboardException("2FA is not configured for account", ThingsboardErrorCode.BAD_REQUEST_PARAMS); private static final ThingsboardException PROVIDER_NOT_CONFIGURED_ERROR = new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS); private static final ThingsboardException PROVIDER_NOT_AVAILABLE_ERROR = new ThingsboardException("2FA provider is not available", ThingsboardErrorCode.GENERAL); + private final ConcurrentMap> verificationCodeSendingRateLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap> verificationCodeCheckingRateLimits = new ConcurrentHashMap<>(); + + @Override + public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) { + return configManager.getAccountTwoFaSettings(tenantId, userId) + .map(settings -> !settings.getConfigs().isEmpty()) + .orElse(false); + } + @Override - public void prepareVerificationCode(SecurityUser securityUser, boolean checkLimits) throws Exception { - TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) + public void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception { + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); - prepareVerificationCode(securityUser, accountConfig, checkLimits); + prepareVerificationCode(user, accountConfig, checkLimits); } @Override - public void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { - TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId(), true) + public void prepareVerificationCode(SecurityUser user, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { + PlatformTwoFaSettings twoFaSettings = configManager.getPlatformTwoFaSettings(user.getTenantId(), true) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); if (checkLimits) { - if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeSendRateLimit())) { - TbRateLimits rateLimits = verificationCodeSendingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { - return new TbRateLimits(twoFaSettings.getVerificationCodeSendRateLimit(), true); - }); - if (!rateLimits.tryConsume()) { - throw new ThingsboardException("Too many verification code sending requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); - } - } + checkRateLimits(user.getId(), accountConfig.getProviderType(), twoFaSettings.getVerificationCodeSendRateLimit(), verificationCodeSendingRateLimits); } - TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) + TwoFaProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); - getTwoFaProvider(accountConfig.getProviderType()).prepareVerificationCode(securityUser, providerConfig, accountConfig); + getTwoFaProvider(accountConfig.getProviderType()).prepareVerificationCode(user, providerConfig, accountConfig); } + @Override - public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean checkLimits) throws ThingsboardException { - TwoFactorAuthAccountConfig accountConfig = configManager.getTwoFaAccountConfig(securityUser.getTenantId(), securityUser.getId()) + public boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException { + TwoFaAccountConfig accountConfig = configManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) .orElseThrow(() -> ACCOUNT_NOT_CONFIGURED_ERROR); - return checkVerificationCode(securityUser, verificationCode, accountConfig, checkLimits); + return checkVerificationCode(user, verificationCode, accountConfig, checkLimits); } @Override - public boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { - if (!userService.findUserCredentialsByUserId(securityUser.getTenantId(), securityUser.getId()).isEnabled()) { + public boolean checkVerificationCode(SecurityUser user, String verificationCode, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException { + if (!userService.findUserCredentialsByUserId(user.getTenantId(), user.getId()).isEnabled()) { throw new ThingsboardException("User is disabled", ThingsboardErrorCode.AUTHENTICATION); } - TwoFactorAuthSettings twoFaSettings = configManager.getTwoFaSettings(securityUser.getTenantId(), true) + PlatformTwoFaSettings twoFaSettings = configManager.getPlatformTwoFaSettings(user.getTenantId(), true) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); if (checkLimits) { - if (StringUtils.isNotEmpty(twoFaSettings.getVerificationCodeCheckRateLimit())) { - TbRateLimits rateLimits = verificationCodeCheckingRateLimits.computeIfAbsent(securityUser.getId(), sessionId -> { - return new TbRateLimits(twoFaSettings.getVerificationCodeCheckRateLimit(), true); - }); - if (!rateLimits.tryConsume()) { - throw new ThingsboardException("Too many verification code checking requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); - } - } + checkRateLimits(user.getId(), accountConfig.getProviderType(), twoFaSettings.getVerificationCodeCheckRateLimit(), verificationCodeCheckingRateLimits); } - TwoFactorAuthProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) + TwoFaProviderConfig providerConfig = twoFaSettings.getProviderConfig(accountConfig.getProviderType()) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); boolean verificationSuccess; if (StringUtils.isNumeric(verificationCode) && verificationCode.length() == 6) { - verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(securityUser, verificationCode, providerConfig, accountConfig); + verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(user, verificationCode, providerConfig, accountConfig); } else { verificationSuccess = false; } if (checkLimits) { try { - systemSecurityService.validateTwoFaVerification(securityUser, verificationSuccess, twoFaSettings); + systemSecurityService.validateTwoFaVerification(user, verificationSuccess, twoFaSettings); } catch (LockedException e) { - verificationCodeCheckingRateLimits.remove(securityUser.getId()); - verificationCodeSendingRateLimits.remove(securityUser.getId()); + verificationCodeCheckingRateLimits.remove(user.getId()); + verificationCodeSendingRateLimits.remove(user.getId()); throw new ThingsboardException(e.getMessage(), ThingsboardErrorCode.AUTHENTICATION); } if (verificationSuccess) { - verificationCodeCheckingRateLimits.remove(securityUser.getId()); - verificationCodeSendingRateLimits.remove(securityUser.getId()); + verificationCodeCheckingRateLimits.remove(user.getId()); + verificationCodeSendingRateLimits.remove(user.getId()); } } return verificationSuccess; } + private void checkRateLimits(UserId userId, TwoFaProviderType providerType, String rateLimitConfig, + ConcurrentMap> rateLimits) throws ThingsboardException { + if (StringUtils.isNotEmpty(rateLimitConfig)) { + ConcurrentMap providersRateLimits = rateLimits.computeIfAbsent(userId, i -> new ConcurrentHashMap<>()); + + TbRateLimits rateLimit = providersRateLimits.get(providerType); + if (rateLimit == null || !rateLimit.getConfig().equals(rateLimitConfig)) { + rateLimit = new TbRateLimits(rateLimitConfig, true); + providersRateLimits.put(providerType, rateLimit); + } + if (!rateLimit.tryConsume()) { + throw new ThingsboardException("Too many requests", ThingsboardErrorCode.TOO_MANY_REQUESTS); + } + } else { + rateLimits.remove(userId); + } + } + + @Override - public TwoFactorAuthAccountConfig generateNewAccountConfig(User user, TwoFactorAuthProviderType providerType) throws ThingsboardException { - TwoFactorAuthProviderConfig providerConfig = getTwoFaProviderConfig(user.getTenantId(), providerType); + public TwoFaAccountConfig generateNewAccountConfig(User user, TwoFaProviderType providerType) throws ThingsboardException { + TwoFaProviderConfig providerConfig = getTwoFaProviderConfig(user.getTenantId(), providerType); return getTwoFaProvider(providerType).generateNewAccountConfig(user, providerConfig); } - private TwoFactorAuthProviderConfig getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) throws ThingsboardException { - return configManager.getTwoFaSettings(tenantId, true) + private TwoFaProviderConfig getTwoFaProviderConfig(TenantId tenantId, TwoFaProviderType providerType) throws ThingsboardException { + return configManager.getPlatformTwoFaSettings(tenantId, true) .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)) .orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR); } - private TwoFactorAuthProvider getTwoFaProvider(TwoFactorAuthProviderType providerType) throws ThingsboardException { + private TwoFaProvider getTwoFaProvider(TwoFaProviderType providerType) throws ThingsboardException { return Optional.ofNullable(providers.get(providerType)) .orElseThrow(() -> PROVIDER_NOT_AVAILABLE_ERROR); } @Autowired - private void setProviders(Collection providers) { + private void setProviders(Collection providers) { providers.forEach(provider -> { this.providers.put(provider.getType(), provider); }); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java index d84236cfb5..b959f94acb 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/TwoFactorAuthService.java @@ -17,20 +17,27 @@ 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.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +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 { - void prepareVerificationCode(SecurityUser securityUser, boolean checkLimits) throws Exception; + boolean isTwoFaEnabled(TenantId tenantId, UserId userId); - void prepareVerificationCode(SecurityUser securityUser, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; - boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, boolean checkLimits) throws ThingsboardException; + void prepareVerificationCode(SecurityUser user, TwoFaProviderType providerType, boolean checkLimits) throws Exception; - boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TwoFactorAuthAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; + void prepareVerificationCode(SecurityUser user, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; - TwoFactorAuthAccountConfig generateNewAccountConfig(User user, TwoFactorAuthProviderType providerType) throws ThingsboardException; + + boolean checkVerificationCode(SecurityUser user, TwoFaProviderType providerType, String verificationCode, boolean checkLimits) throws ThingsboardException; + + boolean checkVerificationCode(SecurityUser user, String verificationCode, TwoFaAccountConfig accountConfig, boolean checkLimits) throws ThingsboardException; + + + TwoFaAccountConfig generateNewAccountConfig(User user, TwoFaProviderType providerType) throws ThingsboardException; } 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/DefaultTwoFaConfigManager.java similarity index 62% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFactorAuthConfigManager.java rename to application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index a74b8374af..9f9b4e4b0a 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/DefaultTwoFaConfigManager.java @@ -21,17 +21,16 @@ import org.springframework.stereotype.Service; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.DataConstants; -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.id.UserId; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.security.UserAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +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.attributes.AttributesService; import org.thingsboard.server.dao.service.ConstraintValidator; import org.thingsboard.server.dao.settings.AdminSettingsDao; @@ -39,12 +38,15 @@ import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.user.UserAuthSettingsDao; import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; @Service @RequiredArgsConstructor -public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigManager { +public class DefaultTwoFaConfigManager implements TwoFaConfigManager { private final UserAuthSettingsDao userAuthSettingsDao; private final AdminSettingsService adminSettingsService; @@ -55,60 +57,77 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan @Override - public boolean isTwoFaEnabled(TenantId tenantId, UserId userId) { - return getTwoFaAccountConfig(tenantId, userId).isPresent(); + public Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId) { + return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .flatMap(userAuthSettings -> Optional.ofNullable(userAuthSettings.getTwoFaSettings())) + .map(twoFaSettings -> { + twoFaSettings.getConfigs().keySet().removeIf(providerType -> { + return getTwoFaProviderConfig(tenantId, providerType).isEmpty(); + }); + return twoFaSettings; + }); } @Override - public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId) { - return Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) - .flatMap(userAuthSettings -> Optional.ofNullable(userAuthSettings.getTwoFaAccountConfig())) - .filter(twoFaAccountConfig -> getTwoFaProviderConfig(tenantId, twoFaAccountConfig.getProviderType()).isPresent()); + public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { + return getAccountTwoFaSettings(tenantId, userId) + .map(AccountTwoFaSettings::getConfigs) + .flatMap(configs -> Optional.ofNullable(configs.get(providerType))); } @Override - public void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + public AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig) { getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) - .orElseThrow(() -> new ThingsboardException("2FA provider is not configured", ThingsboardErrorCode.BAD_REQUEST_PARAMS)); + .orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured")); + + return createOrUpdateAccountTwoFaSettings(tenantId, userId, accountTwoFaSettings -> { + Map configs = accountTwoFaSettings.getConfigs(); + configs.put(accountConfig.getProviderType(), accountConfig); + }); + } + + @Override + public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { + return createOrUpdateAccountTwoFaSettings(tenantId, userId, accountTwoFaSettings -> { + accountTwoFaSettings.getConfigs().keySet().removeIf(providerType::equals); + }); + } + private AccountTwoFaSettings createOrUpdateAccountTwoFaSettings(TenantId tenantId, UserId userId, Consumer updater) { UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) .orElseGet(() -> { UserAuthSettings newUserAuthSettings = new UserAuthSettings(); newUserAuthSettings.setUserId(userId); + + AccountTwoFaSettings newAccountTwoFaSettings = new AccountTwoFaSettings(); + newAccountTwoFaSettings.setConfigs(new LinkedHashMap<>()); + newUserAuthSettings.setTwoFaSettings(newAccountTwoFaSettings); return newUserAuthSettings; }); - userAuthSettings.setTwoFaAccountConfig(accountConfig); + updater.accept(userAuthSettings.getTwoFaSettings()); userAuthSettingsDao.save(tenantId, userAuthSettings); - } - - @Override - public void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId) { - Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) - .ifPresent(userAuthSettings -> { - userAuthSettings.setTwoFaAccountConfig(null); - userAuthSettingsDao.save(tenantId, userAuthSettings); - }); + return userAuthSettings.getTwoFaSettings(); } - private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFactorAuthProviderType providerType) { - return getTwoFaSettings(tenantId, true) + private Optional getTwoFaProviderConfig(TenantId tenantId, TwoFaProviderType providerType) { + return getPlatformTwoFaSettings(tenantId, true) .flatMap(twoFaSettings -> twoFaSettings.getProviderConfig(providerType)); } @SneakyThrows({InterruptedException.class, ExecutionException.class}) @Override - public Optional getTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault) { + public Optional getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault) { if (tenantId.equals(TenantId.SYS_TENANT_ID)) { return Optional.ofNullable(adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, TWO_FACTOR_AUTH_SETTINGS_KEY)) - .map(adminSettings -> JacksonUtil.treeToValue(adminSettings.getJsonValue(), TwoFactorAuthSettings.class)); + .map(adminSettings -> JacksonUtil.treeToValue(adminSettings.getJsonValue(), PlatformTwoFaSettings.class)); } else { - 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)); + 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(), PlatformTwoFaSettings.class)); if (sysadminSettingsAsDefault) { if (tenantTwoFaSettings.isEmpty() || tenantTwoFaSettings.get().isUseSystemTwoFactorAuthSettings()) { - return getTwoFaSettings(TenantId.SYS_TENANT_ID, false); + return getPlatformTwoFaSettings(TenantId.SYS_TENANT_ID, false); } } return tenantTwoFaSettings; @@ -117,7 +136,7 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan @SneakyThrows({InterruptedException.class, ExecutionException.class}) @Override - public void saveTwoFaSettings(TenantId tenantId, TwoFactorAuthSettings twoFactorAuthSettings) { + public void savePlatformTwoFaSettings(TenantId tenantId, PlatformTwoFaSettings twoFactorAuthSettings) { if (tenantId.equals(TenantId.SYS_TENANT_ID) || !twoFactorAuthSettings.isUseSystemTwoFactorAuthSettings()) { ConstraintValidator.validateFields(twoFactorAuthSettings); } @@ -139,7 +158,7 @@ public class DefaultTwoFactorAuthConfigManager implements TwoFactorAuthConfigMan @SneakyThrows({InterruptedException.class, ExecutionException.class}) @Override - public void deleteTwoFaSettings(TenantId tenantId) { + public void deletePlatformTwoFaSettings(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())); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java new file mode 100644 index 0000000000..b0073338b7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java @@ -0,0 +1,45 @@ +/** + * 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.service.security.auth.mfa.config; + +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; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; + +import java.util.Optional; + +public interface TwoFaConfigManager { + + + Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId); + + Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + + AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig); + + AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); + + + Optional getPlatformTwoFaSettings(TenantId tenantId, boolean sysadminSettingsAsDefault); + + void savePlatformTwoFaSettings(TenantId tenantId, PlatformTwoFaSettings twoFactorAuthSettings); + + void deletePlatformTwoFaSettings(TenantId tenantId); + +} 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 deleted file mode 100644 index d1c5999e7e..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFactorAuthConfigManager.java +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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.service.security.auth.mfa.config; - -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.TwoFactorAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; - -import java.util.Optional; - -public interface TwoFactorAuthConfigManager { - - boolean isTwoFaEnabled(TenantId tenantId, UserId userId); - - Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId); - - void saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFactorAuthAccountConfig accountConfig) throws ThingsboardException; - - void deleteTwoFaAccountConfig(TenantId tenantId, UserId userId); - - - 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/provider/TwoFactorAuthProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java similarity index 84% rename from application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java rename to application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java index 6961aeeaec..4d4db92af1 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFactorAuthProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java @@ -17,12 +17,12 @@ package org.thingsboard.server.service.security.auth.mfa.provider; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.ThingsboardException; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +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.service.security.model.SecurityUser; -public interface TwoFactorAuthProvider { +public interface TwoFaProvider { A generateNewAccountConfig(User user, C providerConfig); @@ -31,6 +31,6 @@ public interface TwoFactorAuthProvider implements TwoFactorAuthProvider { +public abstract class OtpBasedTwoFaProvider implements TwoFaProvider { private final Cache verificationCodesCache; - protected OtpBasedTwoFactorAuthProvider(CacheManager cacheManager) { + protected OtpBasedTwoFaProvider(CacheManager cacheManager) { this.verificationCodesCache = cacheManager.getCache(CacheConstants.TWO_FA_VERIFICATION_CODES_CACHE); } @@ -70,7 +70,7 @@ public abstract class OtpBasedTwoFactorAuthProvider { +public class SmsTwoFaProvider extends OtpBasedTwoFaProvider { private final SmsService smsService; - public SmsTwoFactorAuthProvider(CacheManager cacheManager, SmsService smsService) { + public SmsTwoFaProvider(CacheManager cacheManager, SmsService smsService) { super(cacheManager); this.smsService = smsService; } @Override - public SmsTwoFactorAuthAccountConfig generateNewAccountConfig(User user, SmsTwoFactorAuthProviderConfig providerConfig) { - return new SmsTwoFactorAuthAccountConfig(); + public SmsTwoFaAccountConfig generateNewAccountConfig(User user, SmsTwoFaProviderConfig providerConfig) { + return new SmsTwoFaAccountConfig(); } @Override - protected void sendVerificationCode(SecurityUser user, String verificationCode, SmsTwoFactorAuthProviderConfig providerConfig, SmsTwoFactorAuthAccountConfig accountConfig) throws ThingsboardException { + protected void sendVerificationCode(SecurityUser user, String verificationCode, SmsTwoFaProviderConfig providerConfig, SmsTwoFaAccountConfig accountConfig) throws ThingsboardException { Map messageData = Map.of( "verificationCode", verificationCode, "userEmail", user.getEmail() @@ -60,8 +60,8 @@ public class SmsTwoFactorAuthProvider extends OtpBasedTwoFactorAuthProvider { +public class TotpTwoFaProvider implements TwoFaProvider { @Override - public final TotpTwoFactorAuthAccountConfig generateNewAccountConfig(User user, TotpTwoFactorAuthProviderConfig providerConfig) { - TotpTwoFactorAuthAccountConfig config = new TotpTwoFactorAuthAccountConfig(); + public final TotpTwoFaAccountConfig generateNewAccountConfig(User user, TotpTwoFaProviderConfig providerConfig) { + TotpTwoFaAccountConfig config = new TotpTwoFaAccountConfig(); String secretKey = generateSecretKey(); config.setAuthUrl(getTotpAuthUrl(user, secretKey, providerConfig)); return config; } @Override - public final boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TotpTwoFactorAuthProviderConfig providerConfig, TotpTwoFactorAuthAccountConfig accountConfig) { + public final boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, TotpTwoFaProviderConfig providerConfig, TotpTwoFaAccountConfig accountConfig) { String secretKey = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build().getQueryParams().getFirst("secret"); return new Totp(secretKey).verify(verificationCode); } @SneakyThrows - private String getTotpAuthUrl(User user, String secretKey, TotpTwoFactorAuthProviderConfig providerConfig) { + private String getTotpAuthUrl(User user, String secretKey, TotpTwoFaProviderConfig providerConfig) { URIBuilder uri = new URIBuilder() .setScheme("otpauth") .setHost("totp") @@ -67,8 +67,8 @@ public class TotpTwoFactorAuthProvider implements TwoFactorAuthProvider Optional.ofNullable(settings.getTotalAllowedTimeForVerification())).orElse((int) TimeUnit.MINUTES.toSeconds(30)); tokenPair.setToken(tokenFactory.createPreVerificationToken(securityUser, preVerificationTokenLifetime).getToken()); tokenPair.setRefreshToken(null); diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 82607cf70b..57d009070c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -54,7 +54,7 @@ import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserServiceImpl; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; import org.thingsboard.server.service.security.model.SecurityUser; @@ -160,7 +160,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { } @Override - public void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings) { + public void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, PlatformTwoFaSettings twoFaSettings) { TenantId tenantId = securityUser.getTenantId(); UserId userId = securityUser.getId(); diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java index 90241f723b..6173d408c5 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/SystemSecurityService.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.data.security.model.SecuritySettings; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.service.security.model.SecurityUser; import javax.servlet.http.HttpServletRequest; @@ -36,7 +36,7 @@ public interface SystemSecurityService { void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException; - void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, TwoFactorAuthSettings twoFaSettings); + void validateTwoFaVerification(SecurityUser securityUser, boolean verificationSuccess, PlatformTwoFaSettings twoFaSettings); void validatePassword(TenantId tenantId, String password, UserCredentials userCredentials) throws DataValidationException; diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 6e3f11160f..954d313480 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -30,17 +30,18 @@ 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.service.security.auth.mfa.config.TwoFactorAuthConfigManager; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.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 org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFaProviderConfig; +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.service.security.auth.mfa.provider.impl.OtpBasedTwoFaProvider; +import org.thingsboard.server.service.security.auth.mfa.provider.impl.TotpTwoFaProvider; import java.util.Arrays; import java.util.Collections; @@ -58,13 +59,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @SpyBean - private TotpTwoFactorAuthProvider totpTwoFactorAuthProvider; + private TotpTwoFaProvider totpTwoFactorAuthProvider; @MockBean private SmsService smsService; @Autowired private CacheManager cacheManager; @Autowired - private TwoFactorAuthConfigManager twoFactorAuthConfigManager; + private TwoFaConfigManager twoFaConfigManager; + @Autowired + private TwoFactorAuthService twoFactorAuthService; @Before public void beforeEach() throws Exception { @@ -73,8 +76,8 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @After public void afterEach() { - twoFactorAuthConfigManager.deleteTwoFaSettings(TenantId.SYS_TENANT_ID); - twoFactorAuthConfigManager.deleteTwoFaSettings(tenantId); + twoFaConfigManager.deletePlatformTwoFaSettings(TenantId.SYS_TENANT_ID); + twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); } @@ -88,13 +91,13 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { } private void testSaveTestTwoFaSettings() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); - SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); smsTwoFaProviderConfig.setVerificationCodeLifetime(60); - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(List.of(totpTwoFaProviderConfig, smsTwoFaProviderConfig)); twoFaSettings.setVerificationCodeSendRateLimit("1:60"); twoFaSettings.setVerificationCodeCheckRateLimit("3:900"); @@ -103,7 +106,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); - TwoFactorAuthSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); + PlatformTwoFaSettings savedTwoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class); assertThat(savedTwoFaSettings.getProviders()).hasSize(2); assertThat(savedTwoFaSettings.getProviders()).contains(totpTwoFaProviderConfig, smsTwoFaProviderConfig); @@ -113,7 +116,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { public void testSaveTwoFaSettings_validationError() throws Exception { loginTenantAdmin(); - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Collections.emptyList()); twoFaSettings.setVerificationCodeSendRateLimit("ab:aba"); twoFaSettings.setVerificationCodeCheckRateLimit("0:12"); @@ -146,19 +149,19 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testGetTwoFaSettings_useSysadminSettingsAsDefault() throws Exception { loginSysAdmin(); - TwoFactorAuthSettings sysadminTwoFaSettings = new TwoFactorAuthSettings(); - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + PlatformTwoFaSettings sysadminTwoFaSettings = new PlatformTwoFaSettings(); + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); sysadminTwoFaSettings.setProviders(Collections.singletonList(totpTwoFaProviderConfig)); sysadminTwoFaSettings.setMaxVerificationFailuresBeforeUserLockout(25); doPost("/api/2fa/settings", sysadminTwoFaSettings).andExpect(status().isOk()); loginTenantAdmin(); - TwoFactorAuthSettings tenantTwoFaSettings = new TwoFactorAuthSettings(); + PlatformTwoFaSettings tenantTwoFaSettings = new PlatformTwoFaSettings(); 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); + PlatformTwoFaSettings twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class); assertThat(twoFaSettings).isEqualTo(tenantTwoFaSettings); doPost("/api/2fa/account/config/generate?providerType=TOTP") @@ -168,7 +171,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { 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); + twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class); assertThat(twoFaSettings).isEqualTo(tenantTwoFaSettings); assertThat(getErrorMessage(doPost("/api/2fa/account/config/generate?providerType=TOTP") @@ -192,13 +195,13 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { .andExpect(status().isOk()); loginSysAdmin(); - twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), TwoFactorAuthSettings.class); + twoFaSettings = readResponse(doGet("/api/2fa/settings").andExpect(status().isOk()), PlatformTwoFaSettings.class); assertThat(twoFaSettings).isEqualTo(sysadminTwoFaSettings); } @Test public void testSaveTotpTwoFaProviderConfig_validationError() throws Exception { - TotpTwoFactorAuthProviderConfig invalidTotpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + TotpTwoFaProviderConfig invalidTotpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); invalidTotpTwoFaProviderConfig.setIssuerName(" "); String errorResponse = saveTwoFaSettingsAndGetError(invalidTotpTwoFaProviderConfig); @@ -207,7 +210,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testSaveSmsTwoFaProviderConfig_validationError() throws Exception { - SmsTwoFactorAuthProviderConfig invalidSmsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + SmsTwoFaProviderConfig invalidSmsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); invalidSmsTwoFaProviderConfig.setSmsVerificationMessageTemplate("does not contain verification code"); invalidSmsTwoFaProviderConfig.setVerificationCodeLifetime(60); @@ -221,8 +224,8 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { assertThat(errorResponse).containsIgnoringCase("verification code lifetime is required"); } - private String saveTwoFaSettingsAndGetError(TwoFactorAuthProviderConfig invalidTwoFaProviderConfig) throws Exception { - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + private String saveTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); return getErrorMessage(doPost("/api/2fa/settings", twoFaSettings) @@ -235,12 +238,12 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); - TwoFactorAuthProviderType notConfiguredProviderType = TwoFactorAuthProviderType.TOTP; + TwoFaProviderType notConfiguredProviderType = TwoFaProviderType.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(); + TotpTwoFaAccountConfig notConfiguredProviderAccountConfig = new TotpTwoFaAccountConfig(); 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"); @@ -248,7 +251,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testGenerateTotpTwoFaAccountConfig() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + TotpTwoFaProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); loginTenantAdmin(); @@ -258,11 +261,11 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testSubmitTotpTwoFaAccountConfig() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + TotpTwoFaProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); loginTenantAdmin(); - TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + TotpTwoFaAccountConfig 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)); @@ -274,7 +277,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); - TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = new TotpTwoFactorAuthAccountConfig(); + TotpTwoFaAccountConfig totpTwoFaAccountConfig = new TotpTwoFaAccountConfig(); totpTwoFaAccountConfig.setAuthUrl(null); String errorMessage = getErrorMessage(doPost("/api/2fa/account/config/submit", totpTwoFaAccountConfig) @@ -293,11 +296,11 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testVerifyAndSaveTotpTwoFaAccountConfig() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + TotpTwoFaProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); loginTenantAdmin(); - TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + TotpTwoFaAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); String secret = UriComponentsBuilder.fromUriString(generatedTotpTwoFaAccountConfig.getAuthUrl()).build() .getQueryParams().getFirst("secret"); @@ -306,17 +309,17 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, generatedTotpTwoFaAccountConfig) .andExpect(status().isOk()); - TwoFactorAuthAccountConfig twoFaAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + TwoFaAccountConfig twoFaAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); assertThat(twoFaAccountConfig).isEqualTo(generatedTotpTwoFaAccountConfig); } @Test public void testVerifyAndSaveTotpTwoFaAccountConfig_incorrectVerificationCode() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); + TotpTwoFaProviderConfig totpTwoFaProviderConfig = configureTotpTwoFaProvider(); loginTenantAdmin(); - TotpTwoFactorAuthAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + TotpTwoFaAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); String incorrectVerificationCode = "100000"; String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=" + incorrectVerificationCode, generatedTotpTwoFaAccountConfig) @@ -325,12 +328,12 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { 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); + 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(((TotpTwoFactorAuthAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { + assertThat(((TotpTwoFaAccountConfig) generatedTwoFaAccountConfig)).satisfies(accountConfig -> { UriComponents otpAuthUrl = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build(); assertThat(otpAuthUrl.getScheme()).isEqualTo("otpauth"); assertThat(otpAuthUrl.getHost()).isEqualTo("totp"); @@ -340,14 +343,14 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { assertDoesNotThrow(() -> Base32.decode(secretKey)); }); }); - return (TotpTwoFactorAuthAccountConfig) generatedTwoFaAccountConfig; + return (TotpTwoFaAccountConfig) generatedTwoFaAccountConfig; } @Test public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { testVerifyAndSaveTotpTwoFaAccountConfig(); assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), - TotpTwoFactorAuthAccountConfig.class)).isNotNull(); + TotpTwoFaAccountConfig.class)).isNotNull(); loginSysAdmin(); @@ -371,13 +374,13 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); - SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); 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(); + .get(tenantAdminUserId, OtpBasedTwoFaProvider.Otp.class).getValue(); verify(smsService).sendSms(eq(tenantId), any(), argThat(phoneNumbers -> { return phoneNumbers[0].equals(smsTwoFaAccountConfig.getPhoneNumber()); @@ -388,7 +391,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { public void testSubmitSmsTwoFaAccountConfig_validationError() throws Exception { configureSmsTwoFaProvider("${verificationCode}"); - SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); String blankPhoneNumber = ""; smsTwoFaAccountConfig.setPhoneNumber(blankPhoneNumber); @@ -410,7 +413,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); - SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38051889445"); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); @@ -424,7 +427,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, smsTwoFaAccountConfig) .andExpect(status().isOk()); - TwoFactorAuthAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + TwoFaAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); assertThat(accountConfig).isEqualTo(smsTwoFaAccountConfig); } @@ -434,7 +437,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); - SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38051889445"); String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=100000", smsTwoFaAccountConfig) @@ -447,7 +450,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { configureSmsTwoFaProvider("${verificationCode}"); loginTenantAdmin(); - SmsTwoFactorAuthAccountConfig initialSmsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig initialSmsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); initialSmsTwoFaAccountConfig.setPhoneNumber("+38051889445"); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); @@ -459,7 +462,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { String correctVerificationCode = verificationCodeCaptor.getValue(); - SmsTwoFactorAuthAccountConfig anotherSmsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig anotherSmsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); anotherSmsTwoFaAccountConfig.setPhoneNumber("+38111111111"); String errorMessage = getErrorMessage(doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, anotherSmsTwoFaAccountConfig) .andExpect(status().isBadRequest())); @@ -467,20 +470,20 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, initialSmsTwoFaAccountConfig) .andExpect(status().isOk()); - TwoFactorAuthAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + TwoFaAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig); } - private TotpTwoFactorAuthProviderConfig configureTotpTwoFaProvider() throws Exception { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); + private TotpTwoFaProviderConfig configureTotpTwoFaProvider() throws Exception { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); saveProvidersConfigs(totpTwoFaProviderConfig); return totpTwoFaProviderConfig; } - private SmsTwoFactorAuthProviderConfig configureSmsTwoFaProvider(String verificationMessageTemplate) throws Exception { - SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); + private SmsTwoFaProviderConfig configureSmsTwoFaProvider(String verificationMessageTemplate) throws Exception { + SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); smsTwoFaProviderConfig.setSmsVerificationMessageTemplate(verificationMessageTemplate); smsTwoFaProviderConfig.setVerificationCodeLifetime(60); @@ -488,8 +491,8 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { return smsTwoFaProviderConfig; } - private void saveProvidersConfigs(TwoFactorAuthProviderConfig... providerConfigs) throws Exception { - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); + private void saveProvidersConfigs(TwoFaProviderConfig... providerConfigs) throws Exception { + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Arrays.stream(providerConfigs).collect(Collectors.toList())); doPost("/api/2fa/settings", twoFaSettings).andExpect(status().isOk()); @@ -498,24 +501,24 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testIsTwoFaEnabled() throws Exception { configureSmsTwoFaProvider("${verificationCode}"); - SmsTwoFactorAuthAccountConfig accountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); accountConfig.setPhoneNumber("+38050505050"); - twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); - assertThat(twoFactorAuthConfigManager.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); + assertThat(twoFactorAuthService.isTwoFaEnabled(tenantId, tenantAdminUserId)).isTrue(); } @Test public void testDeleteTwoFaAccountConfig() throws Exception { configureSmsTwoFaProvider("${verificationCode}"); - SmsTwoFactorAuthAccountConfig accountConfig = new SmsTwoFactorAuthAccountConfig(); + SmsTwoFaAccountConfig accountConfig = new SmsTwoFaAccountConfig(); accountConfig.setPhoneNumber("+38050505050"); loginTenantAdmin(); - twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); - TwoFactorAuthAccountConfig savedAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFactorAuthAccountConfig.class); + TwoFaAccountConfig savedAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); assertThat(savedAccountConfig).isEqualTo(accountConfig); doDelete("/api/2fa/account/config").andExpect(status().isOk()); 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 41ddd05c1b..17910c4f16 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -39,14 +39,14 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFactorAuthConfigManager; -import org.thingsboard.server.common.data.security.model.mfa.TwoFactorAuthSettings; -import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFactorAuthAccountConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; +import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFaProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFaProviderConfig; +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.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.model.JwtTokenPair; @@ -68,319 +68,319 @@ import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; public abstract class TwoFactorAuthTest extends AbstractControllerTest { - - @Autowired - private TwoFactorAuthConfigManager twoFactorAuthConfigManager; - @Autowired - private TwoFactorAuthService twoFactorAuthService; - @MockBean - private SmsService smsService; - @Autowired - private AuditLogService auditLogService; - @Autowired - private UserService userService; - - private User user; - private String username; - private String password; - - @Before - public void beforeEach() throws Exception { - username = "mfa@tb.io"; - password = "psswrd"; - - user = new User(); - user.setAuthority(Authority.TENANT_ADMIN); - user.setEmail(username); - user.setTenantId(tenantId); - - loginSysAdmin(); - user = createUser(user, password); - } - - @After - public void afterEach() { - twoFactorAuthConfigManager.deleteTwoFaSettings(tenantId); - twoFactorAuthConfigManager.deleteTwoFaSettings(TenantId.SYS_TENANT_ID); - } - - @Test - public void testTwoFa_totp() throws Exception { - TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - - logInWithPreVerificationToken(); - - doPost("/api/auth/2fa/verification/send") - .andExpect(status().isOk()); - - String correctVerificationCode = getCorrectTotp(totpTwoFaAccountConfig); - - JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) - .andExpect(status().isOk()), JsonNode.class); - validateAndSetJwtToken(tokenPair, username); - - User currentUser = readResponse(doGet("/api/auth/user") - .andExpect(status().isOk()), User.class); - assertThat(currentUser.getId()).isEqualTo(user.getId()); - } - - @Test - public void testTwoFa_sms() throws Exception { - configureSmsTwoFa(); - - logInWithPreVerificationToken(); - - doPost("/api/auth/2fa/verification/send") - .andExpect(status().isOk()); - - ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); - verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); - String correctVerificationCode = verificationCodeCaptor.getValue(); - - JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) - .andExpect(status().isOk()), JsonNode.class); - validateAndSetJwtToken(tokenPair, username); - - User currentUser = readResponse(doGet("/api/auth/user") - .andExpect(status().isOk()), User.class); - assertThat(currentUser.getId()).isEqualTo(user.getId()); - } - - @Test - public void testTwoFaPreVerificationTokenLifetime() throws Exception { - configureTotpTwoFa(twoFaSettings -> { - twoFaSettings.setTotalAllowedTimeForVerification(5); - }); - - logInWithPreVerificationToken(); - - await("expiration of the pre-verification token") - .atLeast(Duration.ofSeconds(3).plusMillis(500)) - .atMost(Duration.ofSeconds(6)) - .untilAsserted(() -> { - doPost("/api/auth/2fa/verification/send") - .andExpect(status().isUnauthorized()); - }); - } - - @Test - public void testCheckVerificationCode_userBlocked() throws Exception { - configureTotpTwoFa(twoFaSettings -> { - twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); - }); - - logInWithPreVerificationToken(); - - Stream.generate(() -> RandomStringUtils.randomNumeric(6)) - .limit(9) - .forEach(incorrectVerificationCode -> { - try { - String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + incorrectVerificationCode) - .andExpect(status().isBadRequest())); - assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); - } catch (Exception e) { - fail(); - } - }); - - String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) - .andExpect(status().isUnauthorized())); - assertThat(errorMessage).containsIgnoringCase("account was locked due to exceeded 2fa verification attempts"); - - errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) - .andExpect(status().isUnauthorized())); - assertThat(errorMessage).containsIgnoringCase("user is disabled"); - } - - @Test - public void testSendVerificationCode_rateLimit() throws Exception { - configureTotpTwoFa(twoFaSettings -> { - twoFaSettings.setVerificationCodeSendRateLimit("3:10"); - }); - - logInWithPreVerificationToken(); - - for (int i = 0; i < 3; i++) { - doPost("/api/auth/2fa/verification/send") - .andExpect(status().isOk()); - } - - String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/send") - .andExpect(status().isTooManyRequests())); - assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code sending requests"); - - await("verification code sending rate limit resetting") - .atLeast(Duration.ofSeconds(8)) - .atMost(Duration.ofSeconds(12)) - .untilAsserted(() -> { - doPost("/api/auth/2fa/verification/send") - .andExpect(status().isOk()); - }); - } - - @Test - public void testCheckVerificationCode_rateLimit() throws Exception { - TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(twoFaSettings -> { - twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); - }); - - logInWithPreVerificationToken(); - - for (int i = 0; i < 3; i++) { - String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") - .andExpect(status().isBadRequest())); - assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); - } - - String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") - .andExpect(status().isTooManyRequests())); - assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code checking requests"); - - await("verification code checking rate limit resetting") - .atLeast(Duration.ofSeconds(8)) - .atMost(Duration.ofSeconds(12)) - .untilAsserted(() -> { - String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") - .andExpect(status().isBadRequest())); - assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); - }); - - doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) - .andExpect(status().isOk()); - } - - @Test - public void testCheckVerificationCode_invalidVerificationCode() throws Exception { - configureTotpTwoFa(); - logInWithPreVerificationToken(); - - for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { - String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + invalidVerificationCode) - .andExpect(status().isBadRequest())); - assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); - } - } - - @Test - public void testCheckVerificationCode_codeExpiration() throws Exception { - configureSmsTwoFa(smsTwoFaProviderConfig -> { - smsTwoFaProviderConfig.setVerificationCodeLifetime(10); - }); - - logInWithPreVerificationToken(); - - ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); - doPost("/api/auth/2fa/verification/send").andExpect(status().isOk()); - verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); - - String correctVerificationCode = verificationCodeCaptor.getValue(); - - await("verification code expiration") - .pollDelay(10, TimeUnit.SECONDS) - .atLeast(10, TimeUnit.SECONDS) - .atMost(12, TimeUnit.SECONDS) - .untilAsserted(() -> { - String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) - .andExpect(status().isBadRequest())); - assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); - }); - } - - @Test - public void testTwoFa_logLoginAction() throws Exception { - TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); - - logInWithPreVerificationToken(); - await("async audit log saving").during(1, TimeUnit.SECONDS); - assertThat(getLogInAuditLogs()).isEmpty(); - assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() - .get("lastLoginTs")).isNull(); - - doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") - .andExpect(status().isBadRequest()); - - await("async audit log saving").atMost(1, TimeUnit.SECONDS) - .until(() -> getLogInAuditLogs().size() == 1); - assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { - assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); - assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); - assertThat(failedLogInAuditLog.getUserName()).isEqualTo(username); - }); - - doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) - .andExpect(status().isOk()); - await("async audit log saving").atMost(1, TimeUnit.SECONDS) - .until(() -> getLogInAuditLogs().size() == 2); - assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { - assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); - assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); - }); - assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() - .get("lastLoginTs").asLong()) - .isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(3)); - } - - private List getLogInAuditLogs() { - return auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, user.getId(), List.of(ActionType.LOGIN), - new TimePageLink(new PageLink(10, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC)), 0L, System.currentTimeMillis())).getData(); - } - - @Test - public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { - configureTotpTwoFa(); - twoFactorAuthConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId()); - - assertDoesNotThrow(() -> { - login(username, password); - }); - } - - private void logInWithPreVerificationToken() throws Exception { - LoginRequest loginRequest = new LoginRequest(username, password); - - JwtTokenPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtTokenPair.class); - assertThat(response.getToken()).isNotNull(); - assertThat(response.getRefreshToken()).isNull(); - assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); - - this.token = response.getToken(); - } - - private TotpTwoFactorAuthAccountConfig configureTotpTwoFa(Consumer... customizer) throws ThingsboardException { - TotpTwoFactorAuthProviderConfig totpTwoFaProviderConfig = new TotpTwoFactorAuthProviderConfig(); - totpTwoFaProviderConfig.setIssuerName("tb"); - - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); - twoFaSettings.setUseSystemTwoFactorAuthSettings(false); - twoFaSettings.setProviders(Arrays.stream(new TwoFactorAuthProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); - Arrays.stream(customizer).forEach(c -> c.accept(twoFaSettings)); - twoFactorAuthConfigManager.saveTwoFaSettings(tenantId, twoFaSettings); - - TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig = (TotpTwoFactorAuthAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFactorAuthProviderType.TOTP); - twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); - return totpTwoFaAccountConfig; - } - - private SmsTwoFactorAuthAccountConfig configureSmsTwoFa(Consumer... customizer) throws ThingsboardException { - SmsTwoFactorAuthProviderConfig smsTwoFaProviderConfig = new SmsTwoFactorAuthProviderConfig(); - smsTwoFaProviderConfig.setVerificationCodeLifetime(60); - smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); - Arrays.stream(customizer).forEach(c -> c.accept(smsTwoFaProviderConfig)); - - TwoFactorAuthSettings twoFaSettings = new TwoFactorAuthSettings(); - twoFaSettings.setUseSystemTwoFactorAuthSettings(false); - twoFaSettings.setProviders(Arrays.stream(new TwoFactorAuthProviderConfig[]{smsTwoFaProviderConfig}).collect(Collectors.toList())); - twoFactorAuthConfigManager.saveTwoFaSettings(tenantId, twoFaSettings); - - SmsTwoFactorAuthAccountConfig smsTwoFaAccountConfig = new SmsTwoFactorAuthAccountConfig(); - smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); - twoFactorAuthConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); - return smsTwoFaAccountConfig; - } - - private String getCorrectTotp(TotpTwoFactorAuthAccountConfig totpTwoFaAccountConfig) { - String secret = StringUtils.substringAfterLast(totpTwoFaAccountConfig.getAuthUrl(), "secret="); - return new Totp(secret).now(); - } +// +// @Autowired +// private TwoFaConfigManager twoFaConfigManager; +// @Autowired +// private TwoFactorAuthService twoFactorAuthService; +// @MockBean +// private SmsService smsService; +// @Autowired +// private AuditLogService auditLogService; +// @Autowired +// private UserService userService; +// +// private User user; +// private String username; +// private String password; +// +// @Before +// public void beforeEach() throws Exception { +// username = "mfa@tb.io"; +// password = "psswrd"; +// +// user = new User(); +// user.setAuthority(Authority.TENANT_ADMIN); +// user.setEmail(username); +// user.setTenantId(tenantId); +// +// loginSysAdmin(); +// user = createUser(user, password); +// } +// +// @After +// public void afterEach() { +// twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); +// twoFaConfigManager.deletePlatformTwoFaSettings(TenantId.SYS_TENANT_ID); +// } +// +// @Test +// public void testTwoFa_totp() throws Exception { +// TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); +// +// logInWithPreVerificationToken(); +// +// doPost("/api/auth/2fa/verification/send") +// .andExpect(status().isOk()); +// +// String correctVerificationCode = getCorrectTotp(totpTwoFaAccountConfig); +// +// JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) +// .andExpect(status().isOk()), JsonNode.class); +// validateAndSetJwtToken(tokenPair, username); +// +// User currentUser = readResponse(doGet("/api/auth/user") +// .andExpect(status().isOk()), User.class); +// assertThat(currentUser.getId()).isEqualTo(user.getId()); +// } +// +// @Test +// public void testTwoFa_sms() throws Exception { +// configureSmsTwoFa(); +// +// logInWithPreVerificationToken(); +// +// doPost("/api/auth/2fa/verification/send") +// .andExpect(status().isOk()); +// +// ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); +// verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); +// String correctVerificationCode = verificationCodeCaptor.getValue(); +// +// JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) +// .andExpect(status().isOk()), JsonNode.class); +// validateAndSetJwtToken(tokenPair, username); +// +// User currentUser = readResponse(doGet("/api/auth/user") +// .andExpect(status().isOk()), User.class); +// assertThat(currentUser.getId()).isEqualTo(user.getId()); +// } +// +// @Test +// public void testTwoFaPreVerificationTokenLifetime() throws Exception { +// configureTotpTwoFa(twoFaSettings -> { +// twoFaSettings.setTotalAllowedTimeForVerification(5); +// }); +// +// logInWithPreVerificationToken(); +// +// await("expiration of the pre-verification token") +// .atLeast(Duration.ofSeconds(3).plusMillis(500)) +// .atMost(Duration.ofSeconds(6)) +// .untilAsserted(() -> { +// doPost("/api/auth/2fa/verification/send") +// .andExpect(status().isUnauthorized()); +// }); +// } +// +// @Test +// public void testCheckVerificationCode_userBlocked() throws Exception { +// configureTotpTwoFa(twoFaSettings -> { +// twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); +// }); +// +// logInWithPreVerificationToken(); +// +// Stream.generate(() -> RandomStringUtils.randomNumeric(6)) +// .limit(9) +// .forEach(incorrectVerificationCode -> { +// try { +// String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + incorrectVerificationCode) +// .andExpect(status().isBadRequest())); +// assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); +// } catch (Exception e) { +// fail(); +// } +// }); +// +// String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) +// .andExpect(status().isUnauthorized())); +// assertThat(errorMessage).containsIgnoringCase("account was locked due to exceeded 2fa verification attempts"); +// +// errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) +// .andExpect(status().isUnauthorized())); +// assertThat(errorMessage).containsIgnoringCase("user is disabled"); +// } +// +// @Test +// public void testSendVerificationCode_rateLimit() throws Exception { +// configureTotpTwoFa(twoFaSettings -> { +// twoFaSettings.setVerificationCodeSendRateLimit("3:10"); +// }); +// +// logInWithPreVerificationToken(); +// +// for (int i = 0; i < 3; i++) { +// doPost("/api/auth/2fa/verification/send") +// .andExpect(status().isOk()); +// } +// +// String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/send") +// .andExpect(status().isTooManyRequests())); +// assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code sending requests"); +// +// await("verification code sending rate limit resetting") +// .atLeast(Duration.ofSeconds(8)) +// .atMost(Duration.ofSeconds(12)) +// .untilAsserted(() -> { +// doPost("/api/auth/2fa/verification/send") +// .andExpect(status().isOk()); +// }); +// } +// +// @Test +// public void testCheckVerificationCode_rateLimit() throws Exception { +// TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(twoFaSettings -> { +// twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); +// }); +// +// logInWithPreVerificationToken(); +// +// for (int i = 0; i < 3; i++) { +// String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") +// .andExpect(status().isBadRequest())); +// assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); +// } +// +// String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") +// .andExpect(status().isTooManyRequests())); +// assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code checking requests"); +// +// await("verification code checking rate limit resetting") +// .atLeast(Duration.ofSeconds(8)) +// .atMost(Duration.ofSeconds(12)) +// .untilAsserted(() -> { +// String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") +// .andExpect(status().isBadRequest())); +// assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); +// }); +// +// doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) +// .andExpect(status().isOk()); +// } +// +// @Test +// public void testCheckVerificationCode_invalidVerificationCode() throws Exception { +// configureTotpTwoFa(); +// logInWithPreVerificationToken(); +// +// for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { +// String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + invalidVerificationCode) +// .andExpect(status().isBadRequest())); +// assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); +// } +// } +// +// @Test +// public void testCheckVerificationCode_codeExpiration() throws Exception { +// configureSmsTwoFa(smsTwoFaProviderConfig -> { +// smsTwoFaProviderConfig.setVerificationCodeLifetime(10); +// }); +// +// logInWithPreVerificationToken(); +// +// ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); +// doPost("/api/auth/2fa/verification/send").andExpect(status().isOk()); +// verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); +// +// String correctVerificationCode = verificationCodeCaptor.getValue(); +// +// await("verification code expiration") +// .pollDelay(10, TimeUnit.SECONDS) +// .atLeast(10, TimeUnit.SECONDS) +// .atMost(12, TimeUnit.SECONDS) +// .untilAsserted(() -> { +// String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) +// .andExpect(status().isBadRequest())); +// assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); +// }); +// } +// +// @Test +// public void testTwoFa_logLoginAction() throws Exception { +// TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); +// +// logInWithPreVerificationToken(); +// await("async audit log saving").during(1, TimeUnit.SECONDS); +// assertThat(getLogInAuditLogs()).isEmpty(); +// assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() +// .get("lastLoginTs")).isNull(); +// +// doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") +// .andExpect(status().isBadRequest()); +// +// await("async audit log saving").atMost(1, TimeUnit.SECONDS) +// .until(() -> getLogInAuditLogs().size() == 1); +// assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { +// assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); +// assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); +// assertThat(failedLogInAuditLog.getUserName()).isEqualTo(username); +// }); +// +// doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) +// .andExpect(status().isOk()); +// await("async audit log saving").atMost(1, TimeUnit.SECONDS) +// .until(() -> getLogInAuditLogs().size() == 2); +// assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { +// assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); +// assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); +// }); +// assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() +// .get("lastLoginTs").asLong()) +// .isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(3)); +// } +// +// private List getLogInAuditLogs() { +// return auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, user.getId(), List.of(ActionType.LOGIN), +// new TimePageLink(new PageLink(10, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC)), 0L, System.currentTimeMillis())).getData(); +// } +// +// @Test +// public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { +// configureTotpTwoFa(); +// twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), ); +// +// assertDoesNotThrow(() -> { +// login(username, password); +// }); +// } +// +// private void logInWithPreVerificationToken() throws Exception { +// LoginRequest loginRequest = new LoginRequest(username, password); +// +// JwtTokenPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtTokenPair.class); +// assertThat(response.getToken()).isNotNull(); +// assertThat(response.getRefreshToken()).isNull(); +// assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); +// +// this.token = response.getToken(); +// } +// +// private TotpTwoFaAccountConfig configureTotpTwoFa(Consumer... customizer) throws ThingsboardException { +// TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); +// totpTwoFaProviderConfig.setIssuerName("tb"); +// +// PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); +// twoFaSettings.setUseSystemTwoFactorAuthSettings(false); +// twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); +// Arrays.stream(customizer).forEach(c -> c.accept(twoFaSettings)); +// twoFaConfigManager.savePlatformTwoFaSettings(tenantId, twoFaSettings); +// +// TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP); +// twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); +// return totpTwoFaAccountConfig; +// } +// +// private SmsTwoFaAccountConfig configureSmsTwoFa(Consumer... customizer) throws ThingsboardException { +// SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); +// smsTwoFaProviderConfig.setVerificationCodeLifetime(60); +// smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); +// Arrays.stream(customizer).forEach(c -> c.accept(smsTwoFaProviderConfig)); +// +// PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); +// twoFaSettings.setUseSystemTwoFactorAuthSettings(false); +// twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{smsTwoFaProviderConfig}).collect(Collectors.toList())); +// twoFaConfigManager.savePlatformTwoFaSettings(tenantId, twoFaSettings); +// +// SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); +// smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); +// twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); +// return smsTwoFaAccountConfig; +// } +// +// private String getCorrectTotp(TotpTwoFaAccountConfig totpTwoFaAccountConfig) { +// String secret = StringUtils.substringAfterLast(totpTwoFaAccountConfig.getAuthUrl(), "secret="); +// return new Totp(secret).now(); +// } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java index 769f861435..1c4eba8a5d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/UserAuthSettings.java @@ -20,7 +20,7 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.id.UserAuthSettingsId; import org.thingsboard.server.common.data.id.UserId; -import org.thingsboard.server.common.data.security.model.mfa.account.TwoFactorAuthAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; @Data @EqualsAndHashCode(callSuper = true) @@ -29,6 +29,6 @@ public class UserAuthSettings extends BaseData { private static final long serialVersionUID = 2628320657987010348L; private UserId userId; - private TwoFactorAuthAccountConfig twoFaAccountConfig; + private AccountTwoFaSettings twoFaSettings; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/TwoFactorAuthSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java similarity index 92% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/TwoFactorAuthSettings.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index 49d5b64b4b..749eb08329 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/TwoFactorAuthSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -18,8 +18,8 @@ package org.thingsboard.server.common.data.security.model.mfa; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig; import javax.validation.Valid; import javax.validation.constraints.Min; @@ -29,7 +29,7 @@ import java.util.Optional; @Data @ApiModel -public class TwoFactorAuthSettings { +public class PlatformTwoFaSettings { @ApiModelProperty(value = "Option for tenant admins to use 2FA settings configured by sysadmin. " + "If this param is set to true, then the settings will not be validated for constraints " + @@ -37,7 +37,7 @@ public class TwoFactorAuthSettings { private boolean useSystemTwoFactorAuthSettings; @ApiModelProperty(value = "The list of 2FA providers' configs. Users will only be allowed to use 2FA providers from this list.") @Valid - private List providers; + private List providers; @ApiModelProperty(value = "Rate limit configuration for verification code sending. The format is standard: 'amountOfRequests:periodInSeconds'. " + "The value of '1:60' would limit verification code sending requests to one per minute.", example = "1:60", required = false) @@ -55,7 +55,7 @@ public class TwoFactorAuthSettings { private Integer totalAllowedTimeForVerification; - public Optional getProviderConfig(TwoFactorAuthProviderType providerType) { + public Optional getProviderConfig(TwoFaProviderType providerType) { return Optional.ofNullable(providers) .flatMap(providersConfigs -> providersConfigs.stream() .filter(providerConfig -> providerConfig.getProviderType() == providerType) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java new file mode 100644 index 0000000000..d621f47a71 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java @@ -0,0 +1,26 @@ +/** + * 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.common.data.security.model.mfa.account; + +import lombok.Data; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; + +import java.util.LinkedHashMap; + +@Data +public class AccountTwoFaSettings { + private LinkedHashMap configs; +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java similarity index 82% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java index c58287556b..67e66d3886 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java @@ -16,7 +16,9 @@ package org.thingsboard.server.common.data.security.model.mfa.account; import lombok.Data; +import lombok.EqualsAndHashCode; @Data -public abstract class OtpBasedTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { +@EqualsAndHashCode(callSuper = true) +public abstract class OtpBasedTwoFaAccountConfig extends TwoFaAccountConfig { } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java similarity index 86% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java index 2863f3f42e..67d9d499ae 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java @@ -19,7 +19,7 @@ import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; @@ -27,7 +27,7 @@ import javax.validation.constraints.Pattern; @ApiModel @EqualsAndHashCode(callSuper = true) @Data -public class SmsTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountConfig { +public class SmsTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { @ApiModelProperty(value = "Phone number to use for 2FA. Must no be blank and must be of E.164 number format.", required = true) @NotBlank(message = "phone number cannot be blank") @@ -35,8 +35,8 @@ public class SmsTwoFactorAuthAccountConfig extends OtpBasedTwoFactorAuthAccountC private String phoneNumber; @Override - public TwoFactorAuthProviderType getProviderType() { - return TwoFactorAuthProviderType.SMS; + public TwoFaProviderType getProviderType() { + return TwoFaProviderType.SMS; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java similarity index 84% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java index cc1171a713..ecafde86ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java @@ -18,14 +18,16 @@ package org.thingsboard.server.common.data.security.model.mfa.account; import io.swagger.annotations.ApiModel; import io.swagger.annotations.ApiModelProperty; import lombok.Data; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; -@ApiModel +@ApiModel // FIXME [viacheslav] @Data -public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfig { +@EqualsAndHashCode(callSuper = true) +public class TotpTwoFaAccountConfig extends TwoFaAccountConfig { @ApiModelProperty(value = "OTP auth URL used to generate a QR code to scan with an authenticator app. Must not be blank and must follow specific pattern.", example = "otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII", required = true) @@ -34,8 +36,9 @@ public class TotpTwoFactorAuthAccountConfig implements TwoFactorAuthAccountConfi private String authUrl; @Override - public TwoFactorAuthProviderType getProviderType() { - return TwoFactorAuthProviderType.TOTP; + public TwoFaProviderType getProviderType() { + return TwoFaProviderType.TOTP; } } + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFactorAuthAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java similarity index 79% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFactorAuthAccountConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java index fc1c9bc636..5d366205cd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFactorAuthAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java @@ -20,19 +20,23 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFactorAuthProviderType; +import lombok.Data; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "providerType") @JsonSubTypes({ - @Type(name = "TOTP", value = TotpTwoFactorAuthAccountConfig.class), - @Type(name = "SMS", value = SmsTwoFactorAuthAccountConfig.class) + @Type(name = "TOTP", value = TotpTwoFaAccountConfig.class), + @Type(name = "SMS", value = SmsTwoFaAccountConfig.class) }) -public interface TwoFactorAuthAccountConfig { +@Data +public abstract class TwoFaAccountConfig { + + private boolean useByDefault; @JsonIgnore - TwoFactorAuthProviderType getProviderType(); + public abstract TwoFaProviderType getProviderType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java similarity index 91% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java index 655816fcc6..23d64c79aa 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java @@ -21,7 +21,7 @@ import lombok.Data; import javax.validation.constraints.Min; @Data -public abstract class OtpBasedTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { +public abstract class OtpBasedTwoFaProviderConfig implements TwoFaProviderConfig { @ApiModelProperty(value = "Verification code lifetime in seconds. Verification codes with a lifetime bigger than this param " + "will be considered incorrect", example = "60", required = true) @Min(value = 1, message = "verification code lifetime is required") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java similarity index 85% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java index 4096fe36f4..81efb7058e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java @@ -23,10 +23,10 @@ import lombok.EqualsAndHashCode; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; -@ApiModel(parent = OtpBasedTwoFactorAuthProviderConfig.class) +@ApiModel(parent = OtpBasedTwoFaProviderConfig.class) @EqualsAndHashCode(callSuper = true) @Data -public class SmsTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProviderConfig { +public class SmsTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { @ApiModelProperty(value = "SMS verification message template. Available template variables are ${verificationCode} and ${userEmail}. " + "It must not be blank and must contain verification code variable.", @@ -36,8 +36,8 @@ public class SmsTwoFactorAuthProviderConfig extends OtpBasedTwoFactorAuthProvide private String smsVerificationMessageTemplate; @Override - public TwoFactorAuthProviderType getProviderType() { - return TwoFactorAuthProviderType.SMS; + public TwoFaProviderType getProviderType() { + return TwoFaProviderType.SMS; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java similarity index 85% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java index df44a662ec..b631d367e5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java @@ -23,7 +23,7 @@ import javax.validation.constraints.NotBlank; @ApiModel @Data -public class TotpTwoFactorAuthProviderConfig implements TwoFactorAuthProviderConfig { +public class TotpTwoFaProviderConfig implements TwoFaProviderConfig { @ApiModelProperty(value = "Issuer name that will be displayed in an authenticator app near a username. " + "Must not be blank.", example = "ThingsBoard", required = true) @@ -31,8 +31,8 @@ public class TotpTwoFactorAuthProviderConfig implements TwoFactorAuthProviderCon private String issuerName; @Override - public TwoFactorAuthProviderType getProviderType() { - return TwoFactorAuthProviderType.TOTP; + public TwoFaProviderType getProviderType() { + return TwoFaProviderType.TOTP; } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java similarity index 82% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderConfig.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java index 24458af562..69d9989af4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java @@ -26,12 +26,12 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; use = JsonTypeInfo.Id.NAME, property = "providerType") @JsonSubTypes({ - @Type(name = "TOTP", value = TotpTwoFactorAuthProviderConfig.class), - @Type(name = "SMS", value = SmsTwoFactorAuthProviderConfig.class) + @Type(name = "TOTP", value = TotpTwoFaProviderConfig.class), + @Type(name = "SMS", value = SmsTwoFaProviderConfig.class) }) -public interface TwoFactorAuthProviderConfig { +public interface TwoFaProviderConfig { @JsonIgnore - TwoFactorAuthProviderType getProviderType(); + TwoFaProviderType getProviderType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderType.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java similarity index 94% rename from common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderType.java rename to common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java index 04e4401395..7e6f25195e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFactorAuthProviderType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java @@ -15,7 +15,7 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -public enum TwoFactorAuthProviderType { +public enum TwoFaProviderType { TOTP, SMS } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java index f7a75381c9..90df7fb266 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/tools/TbRateLimits.java @@ -28,6 +28,7 @@ import java.time.Duration; */ public class TbRateLimits { private final LocalBucket bucket; + private final String config; public TbRateLimits(String limitsConfiguration) { this(limitsConfiguration, false); @@ -48,6 +49,7 @@ public class TbRateLimits { } else { throw new IllegalArgumentException("Failed to parse rate limits configuration: " + limitsConfiguration); } + this.config = limitsConfiguration; } public boolean tryConsume() { @@ -58,4 +60,8 @@ public class TbRateLimits { return bucket.tryConsume(number); } + public String getConfig() { + return config; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index d054b2b051..a5da95ca37 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -564,7 +564,7 @@ public class ModelConstants { * */ public static final String USER_AUTH_SETTINGS_COLUMN_FAMILY_NAME = "user_auth_settings"; public static final String USER_AUTH_SETTINGS_USER_ID_PROPERTY = USER_ID_PROPERTY; - public static final String USER_AUTH_SETTINGS_TWO_FA_ACCOUNT_CONFIG_PROPERTY = "mfa_account_config"; + public static final String USER_AUTH_SETTINGS_TWO_FA_SETTINGS = "two_fa_settings"; /** * Cassandra attributes and timeseries constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java index 59c24c3405..1728fca936 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/UserAuthSettingsEntity.java @@ -25,7 +25,8 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.UserAuthSettingsId; 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.account.TwoFactorAuthAccountConfig; +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.dao.model.BaseEntity; import org.thingsboard.server.dao.model.BaseSqlEntity; import org.thingsboard.server.dao.model.ModelConstants; @@ -47,8 +48,8 @@ public class UserAuthSettingsEntity extends BaseSqlEntity impl @Column(name = ModelConstants.USER_AUTH_SETTINGS_USER_ID_PROPERTY, nullable = false, unique = true) private UUID userId; @Type(type = "json") - @Column(name = ModelConstants.USER_AUTH_SETTINGS_TWO_FA_ACCOUNT_CONFIG_PROPERTY) - private JsonNode twoFaAccountConfig; + @Column(name = ModelConstants.USER_AUTH_SETTINGS_TWO_FA_SETTINGS) + private JsonNode twoFaSettings; public UserAuthSettingsEntity(UserAuthSettings userAuthSettings) { if (userAuthSettings.getId() != null) { @@ -58,8 +59,8 @@ public class UserAuthSettingsEntity extends BaseSqlEntity impl if (userAuthSettings.getUserId() != null) { this.userId = userAuthSettings.getUserId().getId(); } - if (userAuthSettings.getTwoFaAccountConfig() != null) { - this.twoFaAccountConfig = JacksonUtil.valueToTree(userAuthSettings.getTwoFaAccountConfig()); + if (userAuthSettings.getTwoFaSettings() != null) { + this.twoFaSettings = JacksonUtil.valueToTree(userAuthSettings.getTwoFaSettings()); } } @@ -71,8 +72,8 @@ public class UserAuthSettingsEntity extends BaseSqlEntity impl if (userId != null) { userAuthSettings.setUserId(new UserId(userId)); } - if (twoFaAccountConfig != null) { - userAuthSettings.setTwoFaAccountConfig(JacksonUtil.treeToValue(twoFaAccountConfig, TwoFactorAuthAccountConfig.class)); + if (twoFaSettings != null) { + userAuthSettings.setTwoFaSettings(JacksonUtil.treeToValue(twoFaSettings, AccountTwoFaSettings.class)); } return userAuthSettings; } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 8ee0854ae9..9f9348dc55 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -699,5 +699,5 @@ CREATE TABLE IF NOT EXISTS user_auth_settings ( id uuid NOT NULL CONSTRAINT user_auth_settings_pkey PRIMARY KEY, created_time bigint NOT NULL, user_id uuid UNIQUE NOT NULL CONSTRAINT fk_user_auth_settings_user_id REFERENCES tb_user(id), - mfa_account_config varchar + two_fa_settings varchar ); From 4480badd7862b546b471e29e5542ffb75fcc05d2 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 5 May 2022 18:08:54 +0300 Subject: [PATCH 30/92] Email 2FA provider; 2FA API improvements --- .../controller/TwoFaConfigController.java | 168 +++++++++++++----- .../controller/TwoFactorAuthController.java | 11 +- .../service/mail/DefaultMailService.java | 9 +- .../mfa/config/DefaultTwoFaConfigManager.java | 46 ++--- .../auth/mfa/config/TwoFaConfigManager.java | 3 +- .../auth/mfa/provider/TwoFaProvider.java | 4 +- .../mfa/provider/impl/EmailTwoFaProvider.java | 57 ++++++ .../provider/impl/OtpBasedTwoFaProvider.java | 14 +- .../mfa/provider/impl/TotpTwoFaProvider.java | 2 +- .../model/mfa/PlatformTwoFaSettings.java | 15 +- .../mfa/account/EmailTwoFaAccountConfig.java | 38 ++++ .../mfa/account/SmsTwoFaAccountConfig.java | 4 - .../mfa/account/TotpTwoFaAccountConfig.java | 5 - .../model/mfa/account/TwoFaAccountConfig.java | 3 +- .../provider/EmailTwoFaProviderConfig.java | 30 ++++ .../provider/OtpBasedTwoFaProviderConfig.java | 5 +- .../mfa/provider/SmsTwoFaProviderConfig.java | 6 - .../mfa/provider/TotpTwoFaProviderConfig.java | 5 - .../mfa/provider/TwoFaProviderConfig.java | 3 +- .../model/mfa/provider/TwoFaProviderType.java | 3 +- .../rule/engine/api/MailService.java | 4 +- 21 files changed, 313 insertions(+), 122 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/EmailTwoFaProvider.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java index 6eb192b7e2..60541b1ce4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java @@ -33,7 +33,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings; @@ -51,6 +50,7 @@ import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; @@ -65,20 +65,15 @@ public class TwoFaConfigController extends BaseController { private final TwoFactorAuthService twoFactorAuthService; - @ApiOperation(value = "Get 2FA account config (getTwoFaAccountConfig)", - notes = "Get user's account 2FA configuration. Returns empty result if user did not configured 2FA, " + - "or if a provider for previously set up account config is not now configured." + NEW_LINE + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE + - "Response example for TOTP 2FA: " + NEW_LINE + - "```\n{\n" + - " \"providerType\": \"TOTP\",\n" + - " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + - "}\n```" + NEW_LINE + - "Response example for SMS 2FA: " + NEW_LINE + - "```\n{\n" + - " \"providerType\": \"SMS\",\n" + - " \"phoneNumber\": \"+380505005050\"\n" + - "}\n```") + @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" + + "```\n{\n \"configs\": {\n" + + " \"EMAIL\": {\n \"providerType\": \"EMAIL\",\n \"useByDefault\": true,\n \"email\": \"tenant@thingsboard.org\"\n },\n" + + " \"TOTP\": {\n \"providerType\": \"TOTP\",\n \"useByDefault\": false,\n \"authUrl\": \"otpauth://totp/TB%202FA:tenant@thingsboard.org?issuer=TB+2FA&secret=P6Z2TLYTASOGP6LCJZAD24ETT5DACNNX\"\n },\n" + + " \"SMS\": {\n \"providerType\": \"SMS\",\n \"useByDefault\": false,\n \"phoneNumber\": \"+380501253652\"\n }\n" + + " }\n}\n```" + + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) @GetMapping("/account/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException { @@ -88,24 +83,29 @@ public class TwoFaConfigController extends BaseController { @ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)", - notes = "Generate new 2FA account config for specified provider type. " + - "This method is only useful for TOTP 2FA, as there is nothing to generate for other provider types. " + + notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE + "For TOTP, this will return a corresponding account config template " + "with a generated OTP auth URL (with new random secret key for each API call) that can be then " + - "converted to a QR code to scan with an authenticator app. " + - "For other provider types, this method will return an empty config. " + NEW_LINE + - "Will throw an error (Bad Request) if the provider is not configured for usage. " + - ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE + - "Example of a generated account config for TOTP 2FA: " + NEW_LINE + + "converted to a QR code to scan with an authenticator app. Example:\n" + "```\n{\n" + " \"providerType\": \"TOTP\",\n" + - " \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" + + " \"useByDefault\": false,\n" + + " \"authUrl\": \"otpauth://totp/TB%202FA:tenant@thingsboard.org?issuer=TB+2FA&secret=PNJDNWJVAK4ZTUYT7RFGPQLXA7XGU7PX\"\n" + "}\n```" + NEW_LINE + - "For SMS provider type it will return something like: " + NEW_LINE + + "For EMAIL, the generated config will contain email from user's account:\n" + + "```\n{\n" + + " \"providerType\": \"EMAIL\",\n" + + " \"useByDefault\": false,\n" + + " \"email\": \"tenant@thingsboard.org\"\n" + + "}\n```" + NEW_LINE + + "For SMS 2FA this method will just return a config with empty/default values as there is nothing to generate/preset:\n" + "```\n{\n" + " \"providerType\": \"SMS\",\n" + + " \"useByDefault\": false,\n" + " \"phoneNumber\": null\n" + - "}\n```") + "}\n```" + NEW_LINE + + "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')") public TwoFaAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true) @@ -133,51 +133,84 @@ public class TwoFaConfigController extends BaseController { notes = "Submit 2FA account config to prepare for a future verification. " + "Basically, this method will send a verification code for a given account config, if this has " + "sense for a chosen 2FA provider. This code is needed to then verify and save the account config." + NEW_LINE + + "Example of EMAIL 2FA account config:\n" + + "```\n{\n" + + " \"providerType\": \"EMAIL\",\n" + + " \"useByDefault\": true,\n" + + " \"email\": \"separate-email-for-2fa@thingsboard.org\"\n" + + "}\n```" + NEW_LINE + + "Example of SMS 2FA account config:\n" + + "```\n{\n" + + " \"providerType\": \"SMS\",\n" + + " \"useByDefault\": false,\n" + + " \"phoneNumber\": \"+38012312321\"\n" + + "}\n```" + NEW_LINE + + "For TOTP this method does nothing." + NEW_LINE + "Will throw an error (Bad Request) if submitted account config is not valid, " + "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')") - public void submitTwoFaAccountConfig(@ApiParam(value = "2FA account config value. For TOTP 2FA config, authUrl value must not be blank and must match specific pattern. " + - "For SMS 2FA, phoneNumber property must not be blank and must be of E.164 phone number format.", required = true) - @Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception { + public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception { SecurityUser user = getCurrentUser(); twoFactorAuthService.prepareVerificationCode(user, accountConfig, false); } @ApiOperation(value = "Verify and save 2FA account config (verifyAndSaveTwoFaAccountConfig)", - notes = "Checks the verification code for submitted config, and if it is correct, saves the provided account config. " + - "The config is stored in the user's additionalInfo. " + NEW_LINE + + notes = "Checks the verification code for submitted config, and if it is correct, saves the provided account config. " + NEW_LINE + + "Returns whole account's 2FA settings object.\n" + "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')") - public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@ApiParam(value = "2FA account config to save. Validation rules are the same as in submitTwoFaAccountConfig API method", required = true) - @Valid @RequestBody TwoFaAccountConfig accountConfig, - @ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS message in case of SMS 2FA", required = true) + public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig, + @ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS or email message in case of SMS or EMAIL 2FA", required = true) @RequestParam String verificationCode) throws Exception { SecurityUser user = getCurrentUser(); + if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) { + throw new IllegalArgumentException("2FA provider is already configured"); + } + boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false); if (verificationSuccess) { return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); } else { - throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.INVALID_ARGUMENTS); + throw new IllegalArgumentException("Verification code is incorrect"); } } + @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) @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) - .orElseThrow(() -> new IllegalArgumentException("No 2FA config for provider " + providerType)); + AccountTwoFaSettings settings = twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()) + .orElseThrow(() -> new IllegalArgumentException("No 2FA config found")); + Map configs = settings.getConfigs(); + + TwoFaAccountConfig accountConfig; + if ((accountConfig = configs.get(providerType)) == null) { + throw new IllegalArgumentException("Config for " + providerType + " 2FA provider not found"); + } + if (updateRequest.isUseByDefault()) { + configs.values().forEach(config -> config.setUseByDefault(false)); + } accountConfig.setUseByDefault(updateRequest.isUseByDefault()); - return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); + + return twoFaConfigManager.saveAccountTwoFaSettings(user.getTenantId(), user.getId(), settings); } - @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", - notes = "Delete user's 2FA config. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @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) @DeleteMapping("/account/config") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException { @@ -186,6 +219,12 @@ public class TwoFaConfigController extends BaseController { } + @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 + ) @GetMapping("/providers") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") public List getAvailableTwoFaProviders() throws ThingsboardException { @@ -196,8 +235,9 @@ public class TwoFaConfigController extends BaseController { } - @ApiOperation(value = "Get 2FA settings (getTwoFaSettings)", // FIXME [viacheslav] - notes = "Get settings for 2FA. If 2FA is not configured, then an empty response will be returned." + + @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." + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @GetMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @@ -205,9 +245,49 @@ public class TwoFaConfigController extends BaseController { return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), false).orElse(null); } - @ApiOperation(value = "Save 2FA settings (saveTwoFaSettings)", - notes = "Save settings for 2FA. If a user is sysadmin - the settings are saved as AdminSettings; " + - "if it is a tenant admin - as a tenant attribute." + + @ApiOperation(value = "Save platform 2FA settings (savePlatformTwoFaSettings)", + notes = "Save 2FA settings for platform. The settings have following properties:\n" + + "- `useSystemTwoFactorAuthSettings` - option for tenant admins to use 2FA settings configured by sysadmin. " + + "If this param is set to true, then the settings will not be validated for constraints (if it is a tenant admin; for sysadmin this param is ignored).\n" + + "- `providers` - the list of 2FA providers' configs. Users will only be allowed to use 2FA providers from this list. \n\n" + + "- `verificationCodeSendRateLimit` - rate limit configuration for verification code sending. " + + "The format is standard: 'amountOfRequests:periodInSeconds'. The value of '1:60' would limit verification " + + "code sending requests to one per minute.\n" + + "- `verificationCodeCheckRateLimit` - rate limit configuration for verification code checking.\n" + + "- `maxVerificationFailuresBeforeUserLockout` - maximum number of verification failures before a user gets disabled.\n" + + "- `totalAllowedTimeForVerification` - total amount of time in seconds allotted for verification. " + + "Basically, this property sets a lifetime for pre-verification token. If not set, default value of 30 minutes is used.\n" + NEW_LINE + + "TOTP 2FA provider config has following settings:\n" + + "- `issuerName` - issuer name that will be displayed in an authenticator app near a username. Must not be blank.\n\n" + + "For SMS 2FA provider:\n" + + "- `smsVerificationMessageTemplate` - verification message template. Available template variables " + + "are ${verificationCode} and ${userEmail}. It must not be blank and must contain verification code variable.\n" + + "- `verificationCodeLifetime` - verification code lifetime in seconds. Required to be positive.\n\n" + + "For EMAIL provider type:\n" + + "- `verificationCodeLifetime` - the same as for SMS." + NEW_LINE + + "Example of the settings:\n" + + "```\n{\n" + + " \"useSystemTwoFactorAuthSettings\": false,\n" + + " \"providers\": [\n" + + " {\n" + + " \"providerType\": \"TOTP\",\n" + + " \"issuerName\": \"TB\"\n" + + " },\n" + + " {\n" + + " \"providerType\": \"EMAIL\",\n" + + " \"verificationCodeLifetime\": 60\n" + + " },\n" + + " {\n" + + " \"providerType\": \"SMS\",\n" + + " \"verificationCodeLifetime\": 60,\n" + + " \"smsVerificationMessageTemplate\": \"Here is your verification code: ${verificationCode}\"\n" + + " }\n" + + " ],\n" + + " \"verificationCodeSendRateLimit\": \"1:60\",\n" + + " \"verificationCodeCheckRateLimit\": \"3:900\",\n" + + " \"maxVerificationFailuresBeforeUserLockout\": 10,\n" + + " \"totalAllowedTimeForVerification\": 600\n" + + "}\n```" + ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PostMapping("/settings") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") 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 a1fe5a7cfc..a43a73896e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -84,8 +84,8 @@ public class TwoFactorAuthController extends BaseController { "and Too Many Requests error if rate limits are exceeded.") @PostMapping("/verification/check") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public JwtTokenPair checkTwoFaVerificationCode(@ApiParam(value = "6-digit verification code", required = true) - @RequestParam TwoFaProviderType providerType, + public JwtTokenPair checkTwoFaVerificationCode(@RequestParam TwoFaProviderType providerType, + @ApiParam(value = "6-digit verification code", required = true) @RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception { SecurityUser user = getCurrentUser(); boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true); @@ -101,6 +101,13 @@ public class TwoFactorAuthController extends BaseController { } + @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 },\n" + + " {\n \"type\": \"TOTP\",\n \"default\": false\n },\n" + + " {\n \"type\": \"SMS\",\n \"default\": false\n }\n" + + "]\n```") @GetMapping("/providers") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") public List getAvailableTwoFaProviders() throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index fa7cb2b7ff..c6ae0c70e4 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -40,7 +40,6 @@ import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.settings.AdminSettingsService; @@ -311,6 +310,14 @@ public class DefaultMailService implements MailService { sendMail(mailSender, mailFrom, email, subject, message); } + @Override + public void sendTwoFaVerificationEmail(String email, String verificationCode) throws ThingsboardException { // TODO [viacheslav]: mail template + String subject = "ThingsBoard two-factor authentication"; + String message = "Your 2FA verification code: " + verificationCode; + + sendMail(mailSender, mailFrom, email, subject, message); + } + @Override public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageStateMailMessage msg) throws ThingsboardException { String subject = messages.getMessage("api.usage.state", null, Locale.US); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index 9f9b4e4b0a..75ca7b1e45 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -68,6 +68,20 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { }); } + @Override + public AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) { + UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) + .orElseGet(() -> { + UserAuthSettings newUserAuthSettings = new UserAuthSettings(); + newUserAuthSettings.setUserId(userId); + return newUserAuthSettings; + }); + userAuthSettings.setTwoFaSettings(settings); + userAuthSettingsDao.save(tenantId, userAuthSettings); + return settings; + } + + @Override public Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { return getAccountTwoFaSettings(tenantId, userId) @@ -80,33 +94,21 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { getTwoFaProviderConfig(tenantId, accountConfig.getProviderType()) .orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured")); - return createOrUpdateAccountTwoFaSettings(tenantId, userId, accountTwoFaSettings -> { - Map configs = accountTwoFaSettings.getConfigs(); - configs.put(accountConfig.getProviderType(), accountConfig); + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> { + AccountTwoFaSettings newSettings = new AccountTwoFaSettings(); + newSettings.setConfigs(new LinkedHashMap<>()); + return newSettings; }); + settings.getConfigs().put(accountConfig.getProviderType(), accountConfig); + return saveAccountTwoFaSettings(tenantId, userId, settings); } @Override public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) { - return createOrUpdateAccountTwoFaSettings(tenantId, userId, accountTwoFaSettings -> { - accountTwoFaSettings.getConfigs().keySet().removeIf(providerType::equals); - }); - } - - private AccountTwoFaSettings createOrUpdateAccountTwoFaSettings(TenantId tenantId, UserId userId, Consumer updater) { - UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) - .orElseGet(() -> { - UserAuthSettings newUserAuthSettings = new UserAuthSettings(); - newUserAuthSettings.setUserId(userId); - - AccountTwoFaSettings newAccountTwoFaSettings = new AccountTwoFaSettings(); - newAccountTwoFaSettings.setConfigs(new LinkedHashMap<>()); - newUserAuthSettings.setTwoFaSettings(newAccountTwoFaSettings); - return newUserAuthSettings; - }); - updater.accept(userAuthSettings.getTwoFaSettings()); - userAuthSettingsDao.save(tenantId, userAuthSettings); - return userAuthSettings.getTwoFaSettings(); + AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId) + .orElseThrow(() -> new IllegalArgumentException("2FA not configured")); + settings.getConfigs().remove(providerType); + return saveAccountTwoFaSettings(tenantId, userId, settings); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java index b0073338b7..f1a4590572 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java @@ -26,9 +26,10 @@ import java.util.Optional; public interface TwoFaConfigManager { - Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId); + AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings); + Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java index 4d4db92af1..e5c278942b 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/TwoFaProvider.java @@ -26,9 +26,9 @@ public interface TwoFaProvider { + + private final MailService mailService; + + protected EmailTwoFaProvider(CacheManager cacheManager, MailService mailService) { + super(cacheManager); + this.mailService = mailService; + } + + @Override + public EmailTwoFaAccountConfig generateNewAccountConfig(User user, EmailTwoFaProviderConfig providerConfig) { + EmailTwoFaAccountConfig config = new EmailTwoFaAccountConfig(); + config.setEmail(user.getEmail()); + return config; + } + + @Override + protected void sendVerificationCode(SecurityUser user, String verificationCode, EmailTwoFaProviderConfig providerConfig, EmailTwoFaAccountConfig accountConfig) throws ThingsboardException { + mailService.sendTwoFaVerificationEmail(accountConfig.getEmail(), verificationCode); + } + + @Override + public TwoFaProviderType getType() { + return TwoFaProviderType.EMAIL; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java index 30f6fe4f50..f270316d2d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/OtpBasedTwoFaProvider.java @@ -38,27 +38,27 @@ public abstract class OtpBasedTwoFaProvider TimeUnit.SECONDS.toMillis(providerConfig.getVerificationCodeLifetime())) { - verificationCodesCache.evict(securityUser.getId()); + verificationCodesCache.evict(user.getId()); return false; } if (verificationCode.equals(correctVerificationCode.getValue()) && accountConfig.equals(correctVerificationCode.getAccountConfig())) { - verificationCodesCache.evict(securityUser.getId()); + verificationCodesCache.evict(user.getId()); return true; } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java index 9b251e5b4a..289ddb7c9d 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/provider/impl/TotpTwoFaProvider.java @@ -45,7 +45,7 @@ public class TotpTwoFaProvider implements TwoFaProvider providers; - @ApiModelProperty(value = "Rate limit configuration for verification code sending. The format is standard: 'amountOfRequests:periodInSeconds'. " + - "The value of '1:60' would limit verification code sending requests to one per minute.", example = "1:60", required = false) @Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code send rate limit configuration is invalid") private String verificationCodeSendRateLimit; - @ApiModelProperty(value = "Rate limit configuration for verification code checking.", example = "3:900", required = false) @Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code check rate limit configuration is invalid") private String verificationCodeCheckRateLimit; - @ApiModelProperty(value = "Maximum number of verification failures before a user gets disabled.", example = "10", required = false) @Min(value = 0, message = "maximum number of verification failure before user lockout must be positive") private int maxVerificationFailuresBeforeUserLockout; - @ApiModelProperty(value = "Total amount of time in seconds allotted for verification. " + - "Basically, this property sets a lifetime for pre-verification token. If not set, default value of 30 minutes is used.", example = "3600", required = false) @Min(value = 1, message = "total amount of time allotted for verification must be greater than 0") private Integer totalAllowedTimeForVerification; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java new file mode 100644 index 0000000000..a2170d7a54 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java @@ -0,0 +1,38 @@ +/** + * 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.common.data.security.model.mfa.account; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +@Data +@EqualsAndHashCode(callSuper = true) +public class EmailTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { + + @NotBlank + @Email + private String email; + + @Override + public TwoFaProviderType getProviderType() { + return TwoFaProviderType.EMAIL; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java index 67d9d499ae..84a7b6924e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; -import io.swagger.annotations.ApiModel; -import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @@ -24,12 +22,10 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; -@ApiModel @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { - @ApiModelProperty(value = "Phone number to use for 2FA. Must no be blank and must be of E.164 number format.", required = true) @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; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java index ecafde86ce..5cdec02e90 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java @@ -15,8 +15,6 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; -import io.swagger.annotations.ApiModel; -import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @@ -24,13 +22,10 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; -@ApiModel // FIXME [viacheslav] @Data @EqualsAndHashCode(callSuper = true) public class TotpTwoFaAccountConfig extends TwoFaAccountConfig { - @ApiModelProperty(value = "OTP auth URL used to generate a QR code to scan with an authenticator app. Must not be blank and must follow specific pattern.", - example = "otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII", required = true) @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; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java index 5d366205cd..6bf2ee8fd3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java @@ -29,7 +29,8 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi property = "providerType") @JsonSubTypes({ @Type(name = "TOTP", value = TotpTwoFaAccountConfig.class), - @Type(name = "SMS", value = SmsTwoFaAccountConfig.class) + @Type(name = "SMS", value = SmsTwoFaAccountConfig.class), + @Type(name = "EMAIL", value = EmailTwoFaAccountConfig.class) }) @Data public abstract class TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java new file mode 100644 index 0000000000..787f5b561d --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java @@ -0,0 +1,30 @@ +/** + * 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.common.data.security.model.mfa.provider; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class EmailTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { + + @Override + public TwoFaProviderType getProviderType() { + return TwoFaProviderType.EMAIL; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java index 23d64c79aa..f1595e733f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java @@ -15,15 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import io.swagger.annotations.ApiModelProperty; import lombok.Data; import javax.validation.constraints.Min; @Data public abstract class OtpBasedTwoFaProviderConfig implements TwoFaProviderConfig { - @ApiModelProperty(value = "Verification code lifetime in seconds. Verification codes with a lifetime bigger than this param " + - "will be considered incorrect", example = "60", required = true) + @Min(value = 1, message = "verification code lifetime is required") private int verificationCodeLifetime; + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java index 81efb7058e..e0ed0587d9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java @@ -15,22 +15,16 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import io.swagger.annotations.ApiModel; -import io.swagger.annotations.ApiModelProperty; import lombok.Data; import lombok.EqualsAndHashCode; import javax.validation.constraints.NotBlank; import javax.validation.constraints.Pattern; -@ApiModel(parent = OtpBasedTwoFaProviderConfig.class) @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { - @ApiModelProperty(value = "SMS verification message template. Available template variables are ${verificationCode} and ${userEmail}. " + - "It must not be blank and must contain verification code variable.", - example = "Here is your verification code: ${verificationCode}", required = true) @NotBlank(message = "verification message template is required") @Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "template must contain verification code") private String smsVerificationMessageTemplate; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java index b631d367e5..32f443f785 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java @@ -15,18 +15,13 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; -import io.swagger.annotations.ApiModel; -import io.swagger.annotations.ApiModelProperty; import lombok.Data; import javax.validation.constraints.NotBlank; -@ApiModel @Data public class TotpTwoFaProviderConfig implements TwoFaProviderConfig { - @ApiModelProperty(value = "Issuer name that will be displayed in an authenticator app near a username. " + - "Must not be blank.", example = "ThingsBoard", required = true) @NotBlank(message = "issuer name must not be blank") private String issuerName; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java index 69d9989af4..65d9242900 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java @@ -27,7 +27,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; property = "providerType") @JsonSubTypes({ @Type(name = "TOTP", value = TotpTwoFaProviderConfig.class), - @Type(name = "SMS", value = SmsTwoFaProviderConfig.class) + @Type(name = "SMS", value = SmsTwoFaProviderConfig.class), + @Type(name = "EMAIL", value = EmailTwoFaProviderConfig.class) }) public interface TwoFaProviderConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java index 7e6f25195e..dd36f24394 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderType.java @@ -17,5 +17,6 @@ package org.thingsboard.server.common.data.security.model.mfa.provider; public enum TwoFaProviderType { TOTP, - SMS + SMS, + EMAIL } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java index 86da82187d..10c37da564 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/MailService.java @@ -24,8 +24,6 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; -import java.util.Map; - public interface MailService { void updateMailConfiguration(); @@ -46,6 +44,8 @@ public interface MailService { void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException; + void sendTwoFaVerificationEmail(String email, String verificationCode) throws ThingsboardException; + void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException; void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender) throws ThingsboardException; From 05301efe2c6c8631aced6606b818c6de95c4124a Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Fri, 6 May 2022 12:41:10 +0300 Subject: [PATCH 31/92] Update 2FA tests --- .../controller/TwoFactorAuthConfigTest.java | 55 +- .../server/controller/TwoFactorAuthTest.java | 693 ++++++++++-------- .../common/msg/tools/RateLimitsTest.java | 2 +- 3 files changed, 408 insertions(+), 342 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 954d313480..3438ed19f8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -30,6 +30,7 @@ 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.security.model.mfa.account.AccountTwoFaSettings; import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; @@ -82,15 +83,15 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test - public void testSaveTwoFaSettings() throws Exception { + public void testSavePlatformTwoFaSettingsForDifferentAuthorities() throws Exception { loginSysAdmin(); - testSaveTestTwoFaSettings(); + testSavePlatformTwoFaSettings(); loginTenantAdmin(); - testSaveTestTwoFaSettings(); + testSavePlatformTwoFaSettings(); } - private void testSaveTestTwoFaSettings() throws Exception { + private void testSavePlatformTwoFaSettings() throws Exception { TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); totpTwoFaProviderConfig.setIssuerName("tb"); SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); @@ -113,7 +114,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { } @Test - public void testSaveTwoFaSettings_validationError() throws Exception { + public void testSavePlatformTwoFaSettings_validationError() throws Exception { loginTenantAdmin(); PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); @@ -147,7 +148,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { } @Test - public void testGetTwoFaSettings_useSysadminSettingsAsDefault() throws Exception { + public void testGetPlatformTwoFaSettings_useSysadminSettingsAsDefault() throws Exception { loginSysAdmin(); PlatformTwoFaSettings sysadminTwoFaSettings = new PlatformTwoFaSettings(); TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); @@ -204,7 +205,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { TotpTwoFaProviderConfig invalidTotpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); invalidTotpTwoFaProviderConfig.setIssuerName(" "); - String errorResponse = saveTwoFaSettingsAndGetError(invalidTotpTwoFaProviderConfig); + String errorResponse = savePlatformTwoFaSettingsAndGetError(invalidTotpTwoFaProviderConfig); assertThat(errorResponse).containsIgnoringCase("issuer name must not be blank"); } @@ -214,17 +215,17 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { invalidSmsTwoFaProviderConfig.setSmsVerificationMessageTemplate("does not contain verification code"); invalidSmsTwoFaProviderConfig.setVerificationCodeLifetime(60); - String errorResponse = saveTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); + String errorResponse = savePlatformTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); assertThat(errorResponse).containsIgnoringCase("must contain verification code"); invalidSmsTwoFaProviderConfig.setSmsVerificationMessageTemplate(null); invalidSmsTwoFaProviderConfig.setVerificationCodeLifetime(0); - errorResponse = saveTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); + errorResponse = savePlatformTwoFaSettingsAndGetError(invalidSmsTwoFaProviderConfig); assertThat(errorResponse).containsIgnoringCase("verification message template is required"); assertThat(errorResponse).containsIgnoringCase("verification code lifetime is required"); } - private String saveTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { + private String savePlatformTwoFaSettingsAndGetError(TwoFaProviderConfig invalidTwoFaProviderConfig) throws Exception { PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); twoFaSettings.setProviders(Collections.singletonList(invalidTwoFaProviderConfig)); @@ -232,6 +233,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { .andExpect(status().isBadRequest())); } + @Test public void testSaveTwoFaAccountConfig_providerNotConfigured() throws Exception { configureSmsTwoFaProvider("${verificationCode}"); @@ -255,7 +257,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)).isNullOrEmpty(); + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), String.class)).isNullOrEmpty(); generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); } @@ -309,7 +311,10 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, generatedTotpTwoFaAccountConfig) .andExpect(status().isOk()); - TwoFaAccountConfig twoFaAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); + AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); + assertThat(accountTwoFaSettings.getConfigs()).size().isOne(); + + TwoFaAccountConfig twoFaAccountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.TOTP); assertThat(twoFaAccountConfig).isEqualTo(generatedTotpTwoFaAccountConfig); } @@ -349,15 +354,16 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { @Test public void testGetTwoFaAccountConfig_whenProviderNotConfigured() throws Exception { testVerifyAndSaveTotpTwoFaAccountConfig(); - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), - TotpTwoFaAccountConfig.class)).isNotNull(); + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), + AccountTwoFaSettings.class).getConfigs()).isNotEmpty(); loginSysAdmin(); - saveProvidersConfigs(); - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)) - .isNullOrEmpty(); + loginTenantAdmin(); + + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) + .isEmpty(); } @Test @@ -427,7 +433,8 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, smsTwoFaAccountConfig) .andExpect(status().isOk()); - TwoFaAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); + AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); + TwoFaAccountConfig accountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); assertThat(accountConfig).isEqualTo(smsTwoFaAccountConfig); } @@ -470,7 +477,8 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { doPost("/api/2fa/account/config?verificationCode=" + correctVerificationCode, initialSmsTwoFaAccountConfig) .andExpect(status().isOk()); - TwoFaAccountConfig accountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); + AccountTwoFaSettings accountTwoFaSettings = readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class); + TwoFaAccountConfig accountConfig = accountTwoFaSettings.getConfigs().get(TwoFaProviderType.SMS); assertThat(accountConfig).isEqualTo(initialSmsTwoFaAccountConfig); } @@ -518,13 +526,14 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { twoFaConfigManager.saveTwoFaAccountConfig(tenantId, tenantAdminUserId, accountConfig); - TwoFaAccountConfig savedAccountConfig = readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), TwoFaAccountConfig.class); + 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").andExpect(status().isOk()); + doDelete("/api/2fa/account/config?providerType=SMS").andExpect(status().isOk()); - assertThat(readResponse(doGet("/api/2fa/account/config").andExpect(status().isOk()), String.class)) - .isNullOrEmpty(); + assertThat(readResponse(doGet("/api/2fa/account/settings").andExpect(status().isOk()), AccountTwoFaSettings.class).getConfigs()) + .doesNotContainKey(TwoFaProviderType.SMS); } } 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 17910c4f16..1ad340d54b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; @@ -36,23 +37,26 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.audit.AuditLogService; -import org.thingsboard.server.dao.user.UserService; -import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; -import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings; +import org.thingsboard.server.common.data.security.model.mfa.account.EmailTwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig; import org.thingsboard.server.common.data.security.model.mfa.account.TotpTwoFaAccountConfig; +import org.thingsboard.server.common.data.security.model.mfa.provider.EmailTwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.SmsTwoFaProviderConfig; import org.thingsboard.server.common.data.security.model.mfa.provider.TotpTwoFaProviderConfig; 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.audit.AuditLogService; +import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService; +import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.model.JwtTokenPair; import java.time.Duration; import java.util.Arrays; import java.util.List; +import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -68,319 +72,372 @@ import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; public abstract class TwoFactorAuthTest extends AbstractControllerTest { -// -// @Autowired -// private TwoFaConfigManager twoFaConfigManager; -// @Autowired -// private TwoFactorAuthService twoFactorAuthService; -// @MockBean -// private SmsService smsService; -// @Autowired -// private AuditLogService auditLogService; -// @Autowired -// private UserService userService; -// -// private User user; -// private String username; -// private String password; -// -// @Before -// public void beforeEach() throws Exception { -// username = "mfa@tb.io"; -// password = "psswrd"; -// -// user = new User(); -// user.setAuthority(Authority.TENANT_ADMIN); -// user.setEmail(username); -// user.setTenantId(tenantId); -// -// loginSysAdmin(); -// user = createUser(user, password); -// } -// -// @After -// public void afterEach() { -// twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); -// twoFaConfigManager.deletePlatformTwoFaSettings(TenantId.SYS_TENANT_ID); -// } -// -// @Test -// public void testTwoFa_totp() throws Exception { -// TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); -// -// logInWithPreVerificationToken(); -// -// doPost("/api/auth/2fa/verification/send") -// .andExpect(status().isOk()); -// -// String correctVerificationCode = getCorrectTotp(totpTwoFaAccountConfig); -// -// JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) -// .andExpect(status().isOk()), JsonNode.class); -// validateAndSetJwtToken(tokenPair, username); -// -// User currentUser = readResponse(doGet("/api/auth/user") -// .andExpect(status().isOk()), User.class); -// assertThat(currentUser.getId()).isEqualTo(user.getId()); -// } -// -// @Test -// public void testTwoFa_sms() throws Exception { -// configureSmsTwoFa(); -// -// logInWithPreVerificationToken(); -// -// doPost("/api/auth/2fa/verification/send") -// .andExpect(status().isOk()); -// -// ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); -// verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); -// String correctVerificationCode = verificationCodeCaptor.getValue(); -// -// JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) -// .andExpect(status().isOk()), JsonNode.class); -// validateAndSetJwtToken(tokenPair, username); -// -// User currentUser = readResponse(doGet("/api/auth/user") -// .andExpect(status().isOk()), User.class); -// assertThat(currentUser.getId()).isEqualTo(user.getId()); -// } -// -// @Test -// public void testTwoFaPreVerificationTokenLifetime() throws Exception { -// configureTotpTwoFa(twoFaSettings -> { -// twoFaSettings.setTotalAllowedTimeForVerification(5); -// }); -// -// logInWithPreVerificationToken(); -// -// await("expiration of the pre-verification token") -// .atLeast(Duration.ofSeconds(3).plusMillis(500)) -// .atMost(Duration.ofSeconds(6)) -// .untilAsserted(() -> { -// doPost("/api/auth/2fa/verification/send") -// .andExpect(status().isUnauthorized()); -// }); -// } -// -// @Test -// public void testCheckVerificationCode_userBlocked() throws Exception { -// configureTotpTwoFa(twoFaSettings -> { -// twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); -// }); -// -// logInWithPreVerificationToken(); -// -// Stream.generate(() -> RandomStringUtils.randomNumeric(6)) -// .limit(9) -// .forEach(incorrectVerificationCode -> { -// try { -// String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + incorrectVerificationCode) -// .andExpect(status().isBadRequest())); -// assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); -// } catch (Exception e) { -// fail(); -// } -// }); -// -// String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) -// .andExpect(status().isUnauthorized())); -// assertThat(errorMessage).containsIgnoringCase("account was locked due to exceeded 2fa verification attempts"); -// -// errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + RandomStringUtils.randomNumeric(6)) -// .andExpect(status().isUnauthorized())); -// assertThat(errorMessage).containsIgnoringCase("user is disabled"); -// } -// -// @Test -// public void testSendVerificationCode_rateLimit() throws Exception { -// configureTotpTwoFa(twoFaSettings -> { -// twoFaSettings.setVerificationCodeSendRateLimit("3:10"); -// }); -// -// logInWithPreVerificationToken(); -// -// for (int i = 0; i < 3; i++) { -// doPost("/api/auth/2fa/verification/send") -// .andExpect(status().isOk()); -// } -// -// String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/send") -// .andExpect(status().isTooManyRequests())); -// assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code sending requests"); -// -// await("verification code sending rate limit resetting") -// .atLeast(Duration.ofSeconds(8)) -// .atMost(Duration.ofSeconds(12)) -// .untilAsserted(() -> { -// doPost("/api/auth/2fa/verification/send") -// .andExpect(status().isOk()); -// }); -// } -// -// @Test -// public void testCheckVerificationCode_rateLimit() throws Exception { -// TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(twoFaSettings -> { -// twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); -// }); -// -// logInWithPreVerificationToken(); -// -// for (int i = 0; i < 3; i++) { -// String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") -// .andExpect(status().isBadRequest())); -// assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); -// } -// -// String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") -// .andExpect(status().isTooManyRequests())); -// assertThat(rateLimitExceededError).containsIgnoringCase("too many verification code checking requests"); -// -// await("verification code checking rate limit resetting") -// .atLeast(Duration.ofSeconds(8)) -// .atMost(Duration.ofSeconds(12)) -// .untilAsserted(() -> { -// String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") -// .andExpect(status().isBadRequest())); -// assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); -// }); -// -// doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) -// .andExpect(status().isOk()); -// } -// -// @Test -// public void testCheckVerificationCode_invalidVerificationCode() throws Exception { -// configureTotpTwoFa(); -// logInWithPreVerificationToken(); -// -// for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { -// String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + invalidVerificationCode) -// .andExpect(status().isBadRequest())); -// assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); -// } -// } -// -// @Test -// public void testCheckVerificationCode_codeExpiration() throws Exception { -// configureSmsTwoFa(smsTwoFaProviderConfig -> { -// smsTwoFaProviderConfig.setVerificationCodeLifetime(10); -// }); -// -// logInWithPreVerificationToken(); -// -// ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); -// doPost("/api/auth/2fa/verification/send").andExpect(status().isOk()); -// verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); -// -// String correctVerificationCode = verificationCodeCaptor.getValue(); -// -// await("verification code expiration") -// .pollDelay(10, TimeUnit.SECONDS) -// .atLeast(10, TimeUnit.SECONDS) -// .atMost(12, TimeUnit.SECONDS) -// .untilAsserted(() -> { -// String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?verificationCode=" + correctVerificationCode) -// .andExpect(status().isBadRequest())); -// assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); -// }); -// } -// -// @Test -// public void testTwoFa_logLoginAction() throws Exception { -// TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); -// -// logInWithPreVerificationToken(); -// await("async audit log saving").during(1, TimeUnit.SECONDS); -// assertThat(getLogInAuditLogs()).isEmpty(); -// assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() -// .get("lastLoginTs")).isNull(); -// -// doPost("/api/auth/2fa/verification/check?verificationCode=incorrect") -// .andExpect(status().isBadRequest()); -// -// await("async audit log saving").atMost(1, TimeUnit.SECONDS) -// .until(() -> getLogInAuditLogs().size() == 1); -// assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { -// assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); -// assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); -// assertThat(failedLogInAuditLog.getUserName()).isEqualTo(username); -// }); -// -// doPost("/api/auth/2fa/verification/check?verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) -// .andExpect(status().isOk()); -// await("async audit log saving").atMost(1, TimeUnit.SECONDS) -// .until(() -> getLogInAuditLogs().size() == 2); -// assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { -// assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); -// assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); -// }); -// assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() -// .get("lastLoginTs").asLong()) -// .isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(3)); -// } -// -// private List getLogInAuditLogs() { -// return auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, user.getId(), List.of(ActionType.LOGIN), -// new TimePageLink(new PageLink(10, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC)), 0L, System.currentTimeMillis())).getData(); -// } -// -// @Test -// public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { -// configureTotpTwoFa(); -// twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), ); -// -// assertDoesNotThrow(() -> { -// login(username, password); -// }); -// } -// -// private void logInWithPreVerificationToken() throws Exception { -// LoginRequest loginRequest = new LoginRequest(username, password); -// -// JwtTokenPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtTokenPair.class); -// assertThat(response.getToken()).isNotNull(); -// assertThat(response.getRefreshToken()).isNull(); -// assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); -// -// this.token = response.getToken(); -// } -// -// private TotpTwoFaAccountConfig configureTotpTwoFa(Consumer... customizer) throws ThingsboardException { -// TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); -// totpTwoFaProviderConfig.setIssuerName("tb"); -// -// PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); -// twoFaSettings.setUseSystemTwoFactorAuthSettings(false); -// twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); -// Arrays.stream(customizer).forEach(c -> c.accept(twoFaSettings)); -// twoFaConfigManager.savePlatformTwoFaSettings(tenantId, twoFaSettings); -// -// TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP); -// twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); -// return totpTwoFaAccountConfig; -// } -// -// private SmsTwoFaAccountConfig configureSmsTwoFa(Consumer... customizer) throws ThingsboardException { -// SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); -// smsTwoFaProviderConfig.setVerificationCodeLifetime(60); -// smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); -// Arrays.stream(customizer).forEach(c -> c.accept(smsTwoFaProviderConfig)); -// -// PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); -// twoFaSettings.setUseSystemTwoFactorAuthSettings(false); -// twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{smsTwoFaProviderConfig}).collect(Collectors.toList())); -// twoFaConfigManager.savePlatformTwoFaSettings(tenantId, twoFaSettings); -// -// SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); -// smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); -// twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); -// return smsTwoFaAccountConfig; -// } -// -// private String getCorrectTotp(TotpTwoFaAccountConfig totpTwoFaAccountConfig) { -// String secret = StringUtils.substringAfterLast(totpTwoFaAccountConfig.getAuthUrl(), "secret="); -// return new Totp(secret).now(); -// } + + @Autowired + private TwoFaConfigManager twoFaConfigManager; + @Autowired + private TwoFactorAuthService twoFactorAuthService; + @MockBean + private SmsService smsService; + @Autowired + private AuditLogService auditLogService; + @Autowired + private UserService userService; + + private User user; + private String username; + private String password; + + @Before + public void beforeEach() throws Exception { + username = "mfa@tb.io"; + password = "psswrd"; + + user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setEmail(username); + user.setTenantId(tenantId); + + loginSysAdmin(); + user = createUser(user, password); + } + + @After + public void afterEach() { + twoFaConfigManager.deletePlatformTwoFaSettings(tenantId); + twoFaConfigManager.deletePlatformTwoFaSettings(TenantId.SYS_TENANT_ID); + } + + @Test + public void testTwoFa_totp() throws Exception { + TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); + + logInWithPreVerificationToken(username, password); + + doPost("/api/auth/2fa/verification/send?providerType=TOTP") + .andExpect(status().isOk()); + + String correctVerificationCode = getCorrectTotp(totpTwoFaAccountConfig); + + JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + correctVerificationCode) + .andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenPair, username); + + User currentUser = readResponse(doGet("/api/auth/user") + .andExpect(status().isOk()), User.class); + assertThat(currentUser.getId()).isEqualTo(user.getId()); + } + + @Test + public void testTwoFa_sms() throws Exception { + configureSmsTwoFa(); + + logInWithPreVerificationToken(username, password); + + doPost("/api/auth/2fa/verification/send?providerType=SMS") + .andExpect(status().isOk()); + + ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); + verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); + String correctVerificationCode = verificationCodeCaptor.getValue(); + + JsonNode tokenPair = readResponse(doPost("/api/auth/2fa/verification/check?providerType=SMS&verificationCode=" + correctVerificationCode) + .andExpect(status().isOk()), JsonNode.class); + validateAndSetJwtToken(tokenPair, username); + + User currentUser = readResponse(doGet("/api/auth/user") + .andExpect(status().isOk()), User.class); + assertThat(currentUser.getId()).isEqualTo(user.getId()); + } + + @Test + public void testTwoFaPreVerificationTokenLifetime() throws Exception { + configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setTotalAllowedTimeForVerification(5); + }); + + logInWithPreVerificationToken(username, password); + + await("expiration of the pre-verification token") + .atLeast(Duration.ofSeconds(3).plusMillis(500)) + .atMost(Duration.ofSeconds(6)) + .untilAsserted(() -> { + doPost("/api/auth/2fa/verification/send?providerType=TOTP") + .andExpect(status().isUnauthorized()); + }); + } + + @Test + public void testCheckVerificationCode_userBlocked() throws Exception { + configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setMaxVerificationFailuresBeforeUserLockout(10); + }); + + logInWithPreVerificationToken(username, password); + + Stream.generate(() -> RandomStringUtils.randomNumeric(6)) + .limit(9) + .forEach(incorrectVerificationCode -> { + try { + String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + incorrectVerificationCode) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + } catch (Exception e) { + fail(); + } + }); + + String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + RandomStringUtils.randomNumeric(6)) + .andExpect(status().isUnauthorized())); + assertThat(errorMessage).containsIgnoringCase("account was locked due to exceeded 2fa verification attempts"); + + errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + RandomStringUtils.randomNumeric(6)) + .andExpect(status().isUnauthorized())); + assertThat(errorMessage).containsIgnoringCase("user is disabled"); + } + + @Test + public void testSendVerificationCode_rateLimit() throws Exception { + configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setVerificationCodeSendRateLimit("3:10"); + }); + + logInWithPreVerificationToken(username, password); + + for (int i = 0; i < 3; i++) { + doPost("/api/auth/2fa/verification/send?providerType=TOTP") + .andExpect(status().isOk()); + } + + String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/send?providerType=TOTP") + .andExpect(status().isTooManyRequests())); + assertThat(rateLimitExceededError).containsIgnoringCase("too many requests"); + + await("verification code sending rate limit resetting") + .atLeast(Duration.ofSeconds(8)) + .atMost(Duration.ofSeconds(12)) + .untilAsserted(() -> { + doPost("/api/auth/2fa/verification/send?providerType=TOTP") + .andExpect(status().isOk()); + }); + } + + @Test + public void testCheckVerificationCode_rateLimit() throws Exception { + TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(twoFaSettings -> { + twoFaSettings.setVerificationCodeCheckRateLimit("3:10"); + }); + + logInWithPreVerificationToken(username, password); + + for (int i = 0; i < 3; i++) { + String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") + .andExpect(status().isBadRequest())); + assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); + } + + String rateLimitExceededError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") + .andExpect(status().isTooManyRequests())); + assertThat(rateLimitExceededError).containsIgnoringCase("too many requests"); + + await("verification code checking rate limit resetting") + .atLeast(Duration.ofSeconds(8)) + .atMost(Duration.ofSeconds(12)) + .untilAsserted(() -> { + String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") + .andExpect(status().isBadRequest())); + assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); + }); + + doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) + .andExpect(status().isOk()); + } + + @Test + public void testCheckVerificationCode_invalidVerificationCode() throws Exception { + configureTotpTwoFa(); + logInWithPreVerificationToken(username, password); + + for (String invalidVerificationCode : new String[]{"1234567", "ab1212", "12311 ", "oewkriwejqf"}) { + String errorMessage = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + invalidVerificationCode) + .andExpect(status().isBadRequest())); + assertThat(errorMessage).containsIgnoringCase("verification code is incorrect"); + } + } + + @Test + public void testCheckVerificationCode_codeExpiration() throws Exception { + configureSmsTwoFa(smsTwoFaProviderConfig -> { + smsTwoFaProviderConfig.setVerificationCodeLifetime(10); + }); + + logInWithPreVerificationToken(username, password); + + ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); + doPost("/api/auth/2fa/verification/send?providerType=SMS").andExpect(status().isOk()); + verify(smsService).sendSms(eq(tenantId), any(), any(), verificationCodeCaptor.capture()); + + String correctVerificationCode = verificationCodeCaptor.getValue(); + + await("verification code expiration") + .pollDelay(10, TimeUnit.SECONDS) + .atLeast(10, TimeUnit.SECONDS) + .atMost(12, TimeUnit.SECONDS) + .untilAsserted(() -> { + String incorrectVerificationCodeError = getErrorMessage(doPost("/api/auth/2fa/verification/check?providerType=SMS&verificationCode=" + correctVerificationCode) + .andExpect(status().isBadRequest())); + assertThat(incorrectVerificationCodeError).containsIgnoringCase("verification code is incorrect"); + }); + } + + @Test + public void testTwoFa_logLoginAction() throws Exception { + TotpTwoFaAccountConfig totpTwoFaAccountConfig = configureTotpTwoFa(); + + logInWithPreVerificationToken(username, password); + await("async audit log saving").during(1, TimeUnit.SECONDS); + assertThat(getLogInAuditLogs()).isEmpty(); + assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() + .get("lastLoginTs")).isNull(); + + doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") + .andExpect(status().isBadRequest()); + + await("async audit log saving").atMost(1, TimeUnit.SECONDS) + .until(() -> getLogInAuditLogs().size() == 1); + assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { + assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); + assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); + assertThat(failedLogInAuditLog.getUserName()).isEqualTo(username); + }); + + doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) + .andExpect(status().isOk()); + await("async audit log saving").atMost(1, TimeUnit.SECONDS) + .until(() -> getLogInAuditLogs().size() == 2); + assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { + assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); + assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); + }); + assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() + .get("lastLoginTs").asLong()) + .isGreaterThan(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(3)); + } + + private List getLogInAuditLogs() { + return auditLogService.findAuditLogsByTenantIdAndUserId(tenantId, user.getId(), List.of(ActionType.LOGIN), + new TimePageLink(new PageLink(10, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC)), 0L, System.currentTimeMillis())).getData(); + } + + @Test + public void testAuthWithoutTwoFaAccountConfig() throws ThingsboardException { + configureTotpTwoFa(); + twoFaConfigManager.deleteTwoFaAccountConfig(tenantId, user.getId(), TwoFaProviderType.TOTP); + + assertDoesNotThrow(() -> { + login(username, password); + }); + } + + @Test + public void testTwoFa_multipleProviders() throws Exception { + PlatformTwoFaSettings platformTwoFaSettings = new PlatformTwoFaSettings(); + platformTwoFaSettings.setUseSystemTwoFactorAuthSettings(true); + + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("TB"); + + SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); + smsTwoFaProviderConfig.setVerificationCodeLifetime(60); + smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); + + EmailTwoFaProviderConfig emailTwoFaProviderConfig = new EmailTwoFaProviderConfig(); + emailTwoFaProviderConfig.setVerificationCodeLifetime(60); + + platformTwoFaSettings.setProviders(List.of(totpTwoFaProviderConfig, smsTwoFaProviderConfig, emailTwoFaProviderConfig)); + twoFaConfigManager.savePlatformTwoFaSettings(TenantId.SYS_TENANT_ID, platformTwoFaSettings); + + User twoFaUser = new User(); + twoFaUser.setAuthority(Authority.TENANT_ADMIN); + twoFaUser.setTenantId(tenantId); + twoFaUser.setEmail("2fa@thingsboard.org"); + twoFaUser = createUserAndLogin(twoFaUser, "12345678"); + + TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(twoFaUser, TwoFaProviderType.TOTP); + totpTwoFaAccountConfig.setUseByDefault(true); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), totpTwoFaAccountConfig); + + SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); + smsTwoFaAccountConfig.setPhoneNumber("+38012312322"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), smsTwoFaAccountConfig); + + EmailTwoFaAccountConfig emailTwoFaAccountConfig = new EmailTwoFaAccountConfig(); + emailTwoFaAccountConfig.setEmail(twoFaUser.getEmail()); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, twoFaUser.getId(), emailTwoFaAccountConfig); + + logInWithPreVerificationToken(twoFaUser.getEmail(), "12345678"); + + Map providersInfos = readResponse(doGet("/api/auth/2fa/providers").andExpect(status().isOk()), new TypeReference>() {}).stream() + .collect(Collectors.toMap(TwoFactorAuthController.TwoFaProviderInfo::getType, v -> v)); + + assertThat(providersInfos).size().isEqualTo(3); + + assertThat(providersInfos).containsKey(TwoFaProviderType.TOTP); + assertThat(providersInfos.get(TwoFaProviderType.TOTP).isDefault()).isTrue(); + + assertThat(providersInfos).containsKey(TwoFaProviderType.SMS); + assertThat(providersInfos.get(TwoFaProviderType.SMS).isDefault()).isFalse(); + + assertThat(providersInfos).containsKey(TwoFaProviderType.EMAIL); + assertThat(providersInfos.get(TwoFaProviderType.EMAIL).isDefault()).isFalse(); + } + + private void logInWithPreVerificationToken(String username, String password) throws Exception { + LoginRequest loginRequest = new LoginRequest(username, password); + + JwtTokenPair response = readResponse(doPost("/api/auth/login", loginRequest).andExpect(status().isOk()), JwtTokenPair.class); + assertThat(response.getToken()).isNotNull(); + assertThat(response.getRefreshToken()).isNull(); + assertThat(response.getScope()).isEqualTo(Authority.PRE_VERIFICATION_TOKEN); + + this.token = response.getToken(); + } + + private TotpTwoFaAccountConfig configureTotpTwoFa(Consumer... customizer) throws ThingsboardException { + TotpTwoFaProviderConfig totpTwoFaProviderConfig = new TotpTwoFaProviderConfig(); + totpTwoFaProviderConfig.setIssuerName("tb"); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setUseSystemTwoFactorAuthSettings(false); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{totpTwoFaProviderConfig}).collect(Collectors.toList())); + Arrays.stream(customizer).forEach(c -> c.accept(twoFaSettings)); + twoFaConfigManager.savePlatformTwoFaSettings(tenantId, twoFaSettings); + + TotpTwoFaAccountConfig totpTwoFaAccountConfig = (TotpTwoFaAccountConfig) twoFactorAuthService.generateNewAccountConfig(user, TwoFaProviderType.TOTP); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), totpTwoFaAccountConfig); + return totpTwoFaAccountConfig; + } + + private SmsTwoFaAccountConfig configureSmsTwoFa(Consumer... customizer) throws ThingsboardException { + SmsTwoFaProviderConfig smsTwoFaProviderConfig = new SmsTwoFaProviderConfig(); + smsTwoFaProviderConfig.setVerificationCodeLifetime(60); + smsTwoFaProviderConfig.setSmsVerificationMessageTemplate("${verificationCode}"); + Arrays.stream(customizer).forEach(c -> c.accept(smsTwoFaProviderConfig)); + + PlatformTwoFaSettings twoFaSettings = new PlatformTwoFaSettings(); + twoFaSettings.setUseSystemTwoFactorAuthSettings(false); + twoFaSettings.setProviders(Arrays.stream(new TwoFaProviderConfig[]{smsTwoFaProviderConfig}).collect(Collectors.toList())); + twoFaConfigManager.savePlatformTwoFaSettings(tenantId, twoFaSettings); + + SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); + smsTwoFaAccountConfig.setPhoneNumber("+38050505050"); + twoFaConfigManager.saveTwoFaAccountConfig(tenantId, user.getId(), smsTwoFaAccountConfig); + return smsTwoFaAccountConfig; + } + + private String getCorrectTotp(TotpTwoFaAccountConfig totpTwoFaAccountConfig) { + String secret = StringUtils.substringAfterLast(totpTwoFaAccountConfig.getAuthUrl(), "secret="); + return new Totp(secret).now(); + } } diff --git a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java index 3878d64ec9..b0bbfa3dc6 100644 --- a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java +++ b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/RateLimitsTest.java @@ -67,7 +67,7 @@ public class RateLimitsTest { assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); int expectedRefillTime = period * 1000; - int gap = 100; + int gap = 300; await("tokens refill for rate limit " + rateLimitConfig) .atLeast(expectedRefillTime - gap, TimeUnit.MILLISECONDS) From 70cbe29a5ddcdb8e766f382ed1174dba97813ad0 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 6 May 2022 17:52:22 +0300 Subject: [PATCH 32/92] UI: Add 2FA profile setting form and added support Email 2FA provider --- .../http/two-factor-authentication.service.ts | 40 ++++++- .../two-factor-auth-settings.component.html | 25 ++++- .../two-factor-auth-settings.component.ts | 26 +++-- .../email-auth-dialog.component.html | 104 +++++++++++++++++ .../email-auth-dialog.component.scss | 15 +++ .../email-auth-dialog.component.ts | 93 ++++++++++++++++ .../sms-auth-dialog.component.html | 105 ++++++++++++++++++ .../sms-auth-dialog.component.scss | 15 +++ .../sms-auth-dialog.component.ts | 91 +++++++++++++++ .../totp-auth-dialog.component.html | 97 ++++++++++++++++ .../totp-auth-dialog.component.scss | 15 +++ .../totp-auth-dialog.component.ts | 80 +++++++++++++ .../pages/profile/profile-routing.module.ts | 19 +++- .../home/pages/profile/profile.component.html | 65 ++++++++++- .../home/pages/profile/profile.component.scss | 17 +++ .../home/pages/profile/profile.component.ts | 103 ++++++++++++++++- .../home/pages/profile/profile.module.ts | 8 +- ui-ngx/src/app/shared/models/constants.ts | 1 + .../shared/models/two-factor-auth.models.ts | 35 +++++- .../assets/locale/locale.constant-en_US.json | 5 +- 20 files changed, 932 insertions(+), 27 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts diff --git a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts index 230eddd208..36183db9d8 100644 --- a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts +++ b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts @@ -18,7 +18,12 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; import { Observable } from 'rxjs'; -import { TwoFactorAuthSettings } from '@shared/models/two-factor-auth.models'; +import { + AccountTwoFaSettings, + TwoFactorAuthAccountConfig, + TwoFactorAuthProviderType, + TwoFactorAuthSettings +} from '@shared/models/two-factor-auth.models'; @Injectable({ providedIn: 'root' @@ -38,4 +43,37 @@ export class TwoFactorAuthenticationService { return this.http.post(`/api/2fa/settings`, settings, defaultHttpOptionsFromConfig(config)); } + getAvailableTwoFaProviders(config?: RequestConfig): Observable> { + return this.http.get>(`/api/2fa/providers`, defaultHttpOptionsFromConfig(config)); + } + + generateTwoFaAccountConfig(providerType: TwoFactorAuthProviderType, config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/account/config/generate?providerType=${providerType}`, + defaultHttpOptionsFromConfig(config)); + } + + getAccountTwoFaSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/2fa/account/settings`, defaultHttpOptionsFromConfig(config)); + } + + updateTwoFaAccountConfig(providerType: TwoFactorAuthProviderType, useByDefault: boolean, + config?: RequestConfig): Observable { + return this.http.put(`/api/2fa/account/config?providerType=${providerType}`, {useByDefault}, + defaultHttpOptionsFromConfig(config)); + } + + submitTwoFaAccountConfig(authConfig: TwoFactorAuthAccountConfig, config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/account/config/submit`, authConfig, defaultHttpOptionsFromConfig(config)); + } + + verifyAndSaveTwoFaAccountConfig(authConfig: TwoFactorAuthAccountConfig, verificationCode: number, + config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/account/config?verificationCode=${verificationCode}`, authConfig, defaultHttpOptionsFromConfig(config)); + } + + deleteTwoFaAccountConfig(providerType: TwoFactorAuthProviderType, config?: RequestConfig): Observable { + return this.http.delete(`/api/2fa/account/config?providerType=${providerType}`, + defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index c92fe6de74..fccec76b54 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -81,7 +81,7 @@
- + {{ provider.value.providerType }} @@ -102,10 +102,10 @@ admin.2fa.provider - - {{ twoFactorAuthProviderType }} + + {{ provider }} @@ -144,6 +144,19 @@
+
+ + admin.2fa.verification-code-lifetime + + + {{ "admin.2fa.verification-code-lifetime-required" | translate }} + + + {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} + + +
@@ -159,7 +172,7 @@ || providersForm.length == twoFactorAuthProviderTypes.length || (isLoading$ | async)" (click)="addProvider()"> add - action.add + admin.2fa.add-provider + + + +
+
+ + + done + + + Add email +
+ + user.email + + + {{ 'user.email-required' | translate }} + + + {{ 'user.invalid-email-format' | translate }} + + +
+ + +
+
+
+ + Authentication verification +
+

Enter the 6-digit code here

+

Enter the 6 digit verification code we just sent to {{ emailConfigForm.get('email').value }}.

+ + 6-digit code + + +
+ + +
+
+
+ + Two-factor authentication activated +
+

Email authentication enabled

+

If we notice an attempted login from a device or browser we don’t recognize, you will be prompted to enter the security code that will be sent to your email address.

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss new file mode 100644 index 0000000000..ec6f008a80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.scss @@ -0,0 +1,15 @@ +/** + * 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. + */ diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts new file mode 100644 index 0000000000..be5976c78b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts @@ -0,0 +1,93 @@ +/// +/// 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. +/// + +import { Component, Inject, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { TwoFactorAuthAccountConfig, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { MatStepper } from '@angular/material/stepper'; + +export interface EmailAuthDialogData { + email: string; +} + +@Component({ + selector: 'tb-email-auth-dialog', + templateUrl: './email-auth-dialog.component.html', + styleUrls: ['./email-auth-dialog.component.scss'] +}) +export class EmailAuthDialogComponent extends DialogComponent { + + private authAccountConfig: TwoFactorAuthAccountConfig; + + emailConfigForm: FormGroup; + emailVerificationForm: FormGroup; + + @ViewChild('stepper', {static: false}) stepper: MatStepper; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + @Inject(MAT_DIALOG_DATA) public data: EmailAuthDialogData, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.emailConfigForm = this.fb.group({ + email: [this.data.email, [Validators.required, Validators.email]] + }); + + this.emailVerificationForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + } + + nextStep() { + switch (this.stepper.selectedIndex) { + case 0: + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.EMAIL, + useByDefault: true, + email: this.emailConfigForm.get('email').value as string + }; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => { + this.stepper.next(); + }); + break; + case 1: + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.emailVerificationForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + break; + } + } + + closeDialog() { + return this.dialogRef.close(this.stepper.selectedIndex > 1); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html new file mode 100644 index 0000000000..03c6c7a465 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html @@ -0,0 +1,105 @@ + + +

Enable SMS authenticator

+ + +
+ + +
+
+ + + done + + + Add phone number +
+

Enter the phone number including the area code.

+ + Phone number + + + {{ 'admin.number-to-required' | translate }} + + + {{ 'admin.phone-number-pattern' | translate }} + + +
+ + +
+
+
+ + Authentication verification +
+

Enter the 6-digit code here

+

Enter the 6 digit verification code we just sent to {{ smsConfigForm.get('phone').value }}.

+ + 6-digit code + + +
+ + +
+
+
+ + Two-factor authentication activated +
+

SMS authentication enabled

+

If we notice an attempted login from a device or browser we don’t recognize, you will be prompted to enter the security code that will be sent to the phone number.

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss new file mode 100644 index 0000000000..ec6f008a80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss @@ -0,0 +1,15 @@ +/** + * 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. + */ diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts new file mode 100644 index 0000000000..ed707922eb --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts @@ -0,0 +1,91 @@ +/// +/// 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. +/// + +import { Component, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { TwoFactorAuthAccountConfig, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { phoneNumberPattern } from '@shared/models/settings.models'; +import { MatStepper } from '@angular/material/stepper'; + +@Component({ + selector: 'tb-sms-auth-dialog', + templateUrl: './sms-auth-dialog.component.html', + styleUrls: ['./sms-auth-dialog.component.scss'] +}) +export class SMSAuthDialogComponent extends DialogComponent { + + private authAccountConfig: TwoFactorAuthAccountConfig; + + phoneNumberPattern = phoneNumberPattern; + + smsConfigForm: FormGroup; + smsVerificationForm: FormGroup; + + @ViewChild('stepper', {static: false}) stepper: MatStepper; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + + this.smsConfigForm = this.fb.group({ + phone: ['', [Validators.required, Validators.pattern(phoneNumberPattern)]] + }); + + this.smsVerificationForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + } + + nextStep() { + switch (this.stepper.selectedIndex) { + case 0: + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.SMS, + useByDefault: true, + phoneNumber: this.smsConfigForm.get('phone').value as string + }; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => { + this.stepper.next(); + }); + break; + case 1: + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.smsVerificationForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + break; + } + } + + closeDialog() { + return this.dialogRef.close(this.stepper.selectedIndex > 1); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html new file mode 100644 index 0000000000..09afbb2de0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html @@ -0,0 +1,97 @@ + + +

Enable authenticator app

+ + +
+ + +
+
+ + + done + + + Get app +
+

You'll need to use a verification app such as Google Authenticator, Authy, or Duo. Install from your app store

+
+ + +
+
+
+ + Authentication verification +
+

1. Scan this QR code with your verification app

+

Once your app reads the QR code, you'll get a 6-digit code.

+ +

2. Enter the 6-digit code here

+

Enter the code from the app below. Once connected, we'll remember your phone so you can use it each time you log in.

+ + 6-digit code + + +
+ + +
+
+
+ + Two-factor authentication activated +
+

Success

+

The next time you login from an unrecognized browser or device, you will need to provide a two-factor authentication code.

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss new file mode 100644 index 0000000000..ec6f008a80 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss @@ -0,0 +1,15 @@ +/** + * 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. + */ diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts new file mode 100644 index 0000000000..86b596ac2b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts @@ -0,0 +1,80 @@ +/// +/// 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. +/// + +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { Router } from '@angular/router'; +import { MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { TotpTwoFactorAuthAccountConfig, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { MatStepper } from '@angular/material/stepper'; + +@Component({ + selector: 'tb-totp-auth-dialog', + templateUrl: './totp-auth-dialog.component.html', + styleUrls: ['./totp-auth-dialog.component.scss'] +}) +export class TotpAuthDialogComponent extends DialogComponent { + + private authAccountConfig: TotpTwoFactorAuthAccountConfig; + + totpConfigForm: FormGroup; + totpAuthURL: string; + + @ViewChild('stepper', {static: false}) stepper: MatStepper; + @ViewChild('canvas', {static: false}) canvasRef: ElementRef; + + constructor(protected store: Store, + protected router: Router, + private twoFaService: TwoFactorAuthenticationService, + public dialogRef: MatDialogRef, + public fb: FormBuilder) { + super(store, router, dialogRef); + this.twoFaService.generateTwoFaAccountConfig(TwoFactorAuthProviderType.TOTP).subscribe(accountConfig => { + this.authAccountConfig = accountConfig as TotpTwoFactorAuthAccountConfig; + this.totpAuthURL = this.authAccountConfig.authUrl; + this.authAccountConfig.useByDefault = true; + import('qrcode').then((QRCode) => { + QRCode.toCanvas(this.canvasRef.nativeElement, this.totpAuthURL); + this.canvasRef.nativeElement.style.width = 'auto'; + this.canvasRef.nativeElement.style.height = 'auto'; + }); + }); + this.totpConfigForm = this.fb.group({ + verificationCode: ['', [ + Validators.required, + Validators.minLength(6), + Validators.maxLength(6), + Validators.pattern(/^\d*$/) + ]] + }); + } + + onSaveConfig() { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.totpConfigForm.get('verificationCode').value).subscribe((res) => { + this.stepper.next(); + }); + } + + closeDialog() { + return this.dialogRef.close(this.stepper.selectedIndex > 1); + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts index 440db606fd..e7dae87288 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts @@ -26,6 +26,8 @@ import { AppState } from '@core/core.state'; import { UserService } from '@core/http/user.service'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Observable } from 'rxjs'; +import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; @Injectable() export class UserProfileResolver implements Resolve { @@ -40,6 +42,17 @@ export class UserProfileResolver implements Resolve { } } +@Injectable() +export class UserTwoFAProvidersResolver implements Resolve> { + + constructor(private twoFactorAuthService: TwoFactorAuthenticationService) { + } + + resolve(): Observable> { + return this.twoFactorAuthService.getAvailableTwoFaProviders(); + } +} + const routes: Routes = [ { path: 'profile', @@ -54,7 +67,8 @@ const routes: Routes = [ } }, resolve: { - user: UserProfileResolver + user: UserProfileResolver, + providers: UserTwoFAProvidersResolver } } ]; @@ -63,7 +77,8 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], providers: [ - UserProfileResolver + UserProfileResolver, + UserTwoFAProvidersResolver ] }) export class ProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index b0bcabf62b..a1e9ec0523 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -93,8 +93,7 @@ {{ expirationJwtData }}
-
- +
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss index 08916ca584..f3f41c41d8 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss @@ -59,4 +59,21 @@ } } } + .description { + padding-bottom: 8px; + color: #808080; + margin-right: 8px; + } + + .provider { + padding: 14px 0; + + .mat-h3 { + margin-bottom: 8px; + } + + .checkbox-label { + font-size: 14px; + } + } } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts index 610006a3fd..0c676aca46 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewChild } from '@angular/core'; import { UserService } from '@core/http/user.service'; import { AuthUser, User } from '@shared/models/user.model'; import { Authority } from '@shared/models/authority.enum'; @@ -37,6 +37,12 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { DatePipe } from '@angular/common'; import { ClipboardService } from 'ngx-clipboard'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { AccountTwoFaSettings, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { MatSlideToggle } from '@angular/material/slide-toggle'; +import { TotpAuthDialogComponent } from '@home/pages/profile/authentication-dialog/totp-auth-dialog.component'; +import { SMSAuthDialogComponent } from '@home/pages/profile/authentication-dialog/sms-auth-dialog.component'; +import { EmailAuthDialogComponent, } from '@home/pages/profile/authentication-dialog/email-auth-dialog.component'; @Component({ selector: 'tb-profile', @@ -47,8 +53,25 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir authorities = Authority; profile: FormGroup; + twoFactorAuth: FormGroup; user: User; languageList = env.supportedLangs; + allowTwoFactorAuth = false; + allowSMS2faProvider = false; + allowTOTP2faProvider = false; + allowEmail2faProvider = false; + twoFactorAuthProviderType = TwoFactorAuthProviderType; + + @ViewChild('totp') totp: MatSlideToggle; + + private authDialogMap = new Map( +[ + [TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent], + [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent], + [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent] + ] + ); + private readonly authUser: AuthUser; get jwtToken(): string { @@ -69,6 +92,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private userService: UserService, private authService: AuthService, private translate: TranslateService, + private twoFaService: TwoFactorAuthenticationService, public dialog: MatDialog, public dialogService: DialogService, public fb: FormBuilder, @@ -80,10 +104,12 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir ngOnInit() { this.buildProfileForm(); + this.buildTwoFactorForm(); this.userLoaded(this.route.snapshot.data.user); + this.twoFactorLoad(this.route.snapshot.data.providers); } - buildProfileForm() { + private buildProfileForm() { this.profile = this.fb.group({ email: ['', [Validators.required, Validators.email]], firstName: [''], @@ -94,6 +120,19 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir }); } + private buildTwoFactorForm() { + this.twoFactorAuth = this.fb.group({ + TOTP: [false], + SMS: [false], + EMAIL: [false], + useByDefault: [null] + }); + this.twoFactorAuth.get('useByDefault').valueChanges.subscribe(value => { + this.twoFaService.updateTwoFaAccountConfig(value, true, {ignoreLoading: true}) + .subscribe(data => this.processTwoFactorAuthConfig(data)); + }); + } + save(): void { this.user = {...this.user, ...this.profile.value}; if (!this.user.additionalInfo) { @@ -128,7 +167,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir }); } - userLoaded(user: User) { + private userLoaded(user: User) { this.user = user; this.profile.reset(user); let lang; @@ -151,6 +190,37 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir this.profile.get('homeDashboardHideToolbar').setValue(homeDashboardHideToolbar); } + private twoFactorLoad(providers: TwoFactorAuthProviderType[]) { + if (providers.length) { + this.allowTwoFactorAuth = true; + this.twoFaService.getAccountTwoFaSettings().subscribe(data => this.processTwoFactorAuthConfig(data)); + providers.forEach(provider => { + switch (provider) { + case TwoFactorAuthProviderType.SMS: + this.allowSMS2faProvider = true; + break; + case TwoFactorAuthProviderType.TOTP: + this.allowTOTP2faProvider = true; + break; + case TwoFactorAuthProviderType.EMAIL: + this.allowEmail2faProvider = true; + break; + } + }); + } + } + + private processTwoFactorAuthConfig(setting?: AccountTwoFaSettings) { + if (setting) { + Object.values(setting.configs).forEach(config => { + this.twoFactorAuth.get(config.providerType).setValue(true); + if (config.useByDefault) { + this.twoFactorAuth.get('useByDefault').setValue(config.providerType, {emitEvent: false}); + } + }); + } + } + confirmForm(): FormGroup { return this.profile; } @@ -179,4 +249,31 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir })); } } + + confirm2FAChange(event: MouseEvent, provider: TwoFactorAuthProviderType) { + event.stopPropagation(); + event.preventDefault(); + const providerName = provider === TwoFactorAuthProviderType.TOTP ? 'authenticator app' : `${provider.toLowerCase()} authentication`; + if (this.twoFactorAuth.get(provider).value) { + this.dialogService.confirm(`Are you sure you want to disable ${providerName}?`, + `Disabling ${providerName} will make your account less secure`).subscribe(res => { + if (res) { + this.twoFaService.deleteTwoFaAccountConfig(provider).subscribe(data => this.processTwoFactorAuthConfig(data)); + } + }); + } else { + this.dialog.open(this.authDialogMap.get(provider), { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + email: this.user.email + } + }).afterClosed().subscribe(res => { + if (res) { + this.twoFactorAuth.get(provider).setValue(res); + this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); + } + }); + } + } } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts index 4a8bcab074..5210158537 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts @@ -20,11 +20,17 @@ import { ProfileComponent } from './profile.component'; import { SharedModule } from '@shared/shared.module'; import { ProfileRoutingModule } from './profile-routing.module'; import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component'; +import { TotpAuthDialogComponent } from './authentication-dialog/totp-auth-dialog.component'; +import { SMSAuthDialogComponent } from '@home/pages/profile/authentication-dialog/sms-auth-dialog.component'; +import { EmailAuthDialogComponent } from '@home/pages/profile/authentication-dialog/email-auth-dialog.component'; @NgModule({ declarations: [ ProfileComponent, - ChangePasswordDialogComponent + ChangePasswordDialogComponent, + TotpAuthDialogComponent, + SMSAuthDialogComponent, + EmailAuthDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 4bfce6414a..833efd39ab 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -63,6 +63,7 @@ export const HelpLinks = { smsProviderSettings: helpBaseUrl + '/docs/user-guide/ui/sms-provider-settings', securitySettings: helpBaseUrl + '/docs/user-guide/ui/security-settings', oauth2Settings: helpBaseUrl + '/docs/user-guide/oauth-2-support/', + twoFactorAuthSettings: helpBaseUrl + '/docs/', ruleEngine: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/overview/', ruleNodeCheckRelation: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-relation-filter-node', ruleNodeCheckExistenceFields: helpBaseUrl + '/docs/user-guide/rule-engine-2-0/filter-nodes/#check-existence-fields-node', diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts index ab64143a66..731c1212e9 100644 --- a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -23,7 +23,8 @@ export interface TwoFactorAuthSettings { verificationCodeSendRateLimit: string; } -export type TwoFactorAuthProviderConfig = Partial; +export type TwoFactorAuthProviderConfig = Partial; export interface TotpTwoFactorAuthProviderConfig { providerType: TwoFactorAuthProviderType; @@ -36,7 +37,37 @@ export interface SmsTwoFactorAuthProviderConfig { verificationCodeLifetime: number; } +export interface EmailTwoFactorAuthProviderConfig { + providerType: TwoFactorAuthProviderType; + verificationCodeLifetime: number; +} + export enum TwoFactorAuthProviderType{ TOTP = 'TOTP', - SMS = 'SMS' + SMS = 'SMS', + EMAIL = 'EMAIL' +} + +interface GeneralTwoFactorAuthAccountConfig { + providerType: TwoFactorAuthProviderType; + useByDefault: boolean; +} + +export interface TotpTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + authUrl: string; +} + +export interface SmsTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + phoneNumber: string; +} + +export interface EmailTwoFactorAuthAccountConfig extends GeneralTwoFactorAuthAccountConfig { + email: string; +} + +export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwoFactorAuthAccountConfig | EmailTwoFactorAuthAccountConfig; + + +export interface AccountTwoFaSettings { + configs?: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig}; } diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 89b9bc5d2d..743068fc22 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -255,6 +255,7 @@ }, "2fa": { "2fa": "Two-factor authentication", + "add-provider": "Add provider", "available-providers": "Available providers:", "issuer-name": "Issuer name", "issuer-name-required": "Issuer name is required.", @@ -262,14 +263,14 @@ "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.", "max-verification-failures-before-user-lockout-required": "Max verification failures is required.", "provider": "Provider", - "total-allowed-time-for-verification": "Total allowed time for verification", + "total-allowed-time-for-verification": "Total allowed time for verification (sec)", "total-allowed-time-for-verification-pattern": "Total allowed time must be a positive integer.", "total-allowed-time-for-verification-required": "Total allowed time is required.", "use-system-two-factor-auth-settings": "Use system two factor auth settings", "verification-code-check-rate-limit": "Verification code check rate limit", "verification-code-check-rate-limit-pattern": "Verification code check limit has invalid format", "verification-code-check-rate-limit-required": "Verification code check rate limit is required.", - "verification-code-lifetime": "Verification code lifetime", + "verification-code-lifetime": "Verification code lifetime (sec)", "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.", "verification-code-lifetime-required": "Verification code lifetime is required.", "verification-code-send-rate-limit": "Verification code send rate limit", From d3c23c914d2a29a1b27fdfde8b7632aa3d1e312c Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 9 May 2022 11:45:21 +0300 Subject: [PATCH 33/92] Ensure at least one 2FA account config is default --- .../controller/TwoFaConfigController.java | 17 +++-------------- .../mfa/config/DefaultTwoFaConfigManager.java | 17 +++++++++++++---- .../auth/mfa/config/TwoFaConfigManager.java | 1 - .../controller/TwoFactorAuthConfigTest.java | 3 +++ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java index 60541b1ce4..d974189bab 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFaConfigController.java @@ -50,7 +50,6 @@ import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE; @@ -191,20 +190,10 @@ public class TwoFaConfigController extends BaseController { @RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException { SecurityUser user = getCurrentUser(); - AccountTwoFaSettings settings = twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId()) - .orElseThrow(() -> new IllegalArgumentException("No 2FA config found")); - Map configs = settings.getConfigs(); - - TwoFaAccountConfig accountConfig; - if ((accountConfig = configs.get(providerType)) == null) { - throw new IllegalArgumentException("Config for " + providerType + " 2FA provider not found"); - } - if (updateRequest.isUseByDefault()) { - configs.values().forEach(config -> config.setUseByDefault(false)); - } + TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType) + .orElseThrow(() -> new IllegalArgumentException("Config for " + providerType + " 2FA provider not found")); accountConfig.setUseByDefault(updateRequest.isUseByDefault()); - - return twoFaConfigManager.saveAccountTwoFaSettings(user.getTenantId(), user.getId(), settings); + return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig); } @ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes = diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java index 75ca7b1e45..a1c56e4cd7 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/DefaultTwoFaConfigManager.java @@ -38,11 +38,10 @@ import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.user.UserAuthSettingsDao; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; -import java.util.Map; import java.util.Optional; import java.util.concurrent.ExecutionException; -import java.util.function.Consumer; @Service @RequiredArgsConstructor @@ -68,8 +67,7 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { }); } - @Override - public AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) { + protected AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) { UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId)) .orElseGet(() -> { UserAuthSettings newUserAuthSettings = new UserAuthSettings(); @@ -99,7 +97,13 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { newSettings.setConfigs(new LinkedHashMap<>()); return newSettings; }); + if (accountConfig.isUseByDefault()) { + settings.getConfigs().values().forEach(config -> config.setUseByDefault(false)); + } settings.getConfigs().put(accountConfig.getProviderType(), accountConfig); + if (settings.getConfigs().values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { + settings.getConfigs().values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true)); + } return saveAccountTwoFaSettings(tenantId, userId, settings); } @@ -108,6 +112,11 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager { AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId) .orElseThrow(() -> new IllegalArgumentException("2FA not configured")); settings.getConfigs().remove(providerType); + if (!settings.getConfigs().isEmpty() && settings.getConfigs().values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) { + settings.getConfigs().values().stream() + .min(Comparator.comparing(TwoFaAccountConfig::getProviderType)) + .ifPresent(config -> config.setUseByDefault(true)); + } return saveAccountTwoFaSettings(tenantId, userId, settings); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java index f1a4590572..212c4697bf 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/mfa/config/TwoFaConfigManager.java @@ -28,7 +28,6 @@ public interface TwoFaConfigManager { Optional getAccountTwoFaSettings(TenantId tenantId, UserId userId); - AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings); Optional getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType); diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java index 3438ed19f8..f9dc02e8d3 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthConfigTest.java @@ -303,6 +303,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { loginTenantAdmin(); TotpTwoFaAccountConfig generatedTotpTwoFaAccountConfig = generateTotpTwoFaAccountConfig(totpTwoFaProviderConfig); + generatedTotpTwoFaAccountConfig.setUseByDefault(true); String secret = UriComponentsBuilder.fromUriString(generatedTotpTwoFaAccountConfig.getAuthUrl()).build() .getQueryParams().getFirst("secret"); @@ -421,6 +422,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { SmsTwoFaAccountConfig smsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); smsTwoFaAccountConfig.setPhoneNumber("+38051889445"); + smsTwoFaAccountConfig.setUseByDefault(true); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); doPost("/api/2fa/account/config/submit", smsTwoFaAccountConfig).andExpect(status().isOk()); @@ -459,6 +461,7 @@ public abstract class TwoFactorAuthConfigTest extends AbstractControllerTest { SmsTwoFaAccountConfig initialSmsTwoFaAccountConfig = new SmsTwoFaAccountConfig(); initialSmsTwoFaAccountConfig.setPhoneNumber("+38051889445"); + initialSmsTwoFaAccountConfig.setUseByDefault(true); ArgumentCaptor verificationCodeCaptor = ArgumentCaptor.forClass(String.class); doPost("/api/2fa/account/config/submit", initialSmsTwoFaAccountConfig).andExpect(status().isOk()); From 664d337a99fac8c4aab9259a7b81bb65bb61263e Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 16 May 2022 17:21:04 +0300 Subject: [PATCH 34/92] UI: Refactoring 2FA system admin settings --- ui-ngx/src/app/core/services/menu.service.ts | 14 - .../home/pages/admin/admin-routing.module.ts | 2 +- .../two-factor-auth-settings.component.html | 304 +++++++++--------- .../two-factor-auth-settings.component.scss | 63 +++- .../two-factor-auth-settings.component.ts | 251 +++++++++------ .../shared/models/two-factor-auth.models.ts | 52 ++- .../assets/locale/locale.constant-en_US.json | 17 +- 7 files changed, 430 insertions(+), 273 deletions(-) diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 15ab552d53..becf23ebb6 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -374,14 +374,6 @@ export class MenuService { path: '/settings/home', icon: 'settings_applications' }, - { - id: guid(), - name: 'admin.2fa.2fa', - type: 'link', - path: '/settings/2fa', - icon: 'mdi:two-factor-authentication', - isMdiIcon: true - }, { id: guid(), name: 'resource.resources-library', @@ -518,12 +510,6 @@ export class MenuService { icon: 'settings_applications', path: '/settings/home' }, - { - name: 'admin.2fa.2fa', - icon: 'mdi:two-factor-authentication', - isMdiIcon: true, - path: '/settings/2fa' - }, { name: 'resource.resources-library', icon: 'folder', diff --git a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts index e63eec7b52..b381d39b3c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/admin-routing.module.ts @@ -190,7 +190,7 @@ const routes: Routes = [ component: TwoFactorAuthSettingsComponent, canDeactivate: [ConfirmOnExitGuard], data: { - auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN], + auth: [Authority.SYS_ADMIN], title: 'admin.2fa.2fa', breadcrumb: { label: 'admin.2fa.2fa', diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index fccec76b54..bcc5a695e2 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -27,159 +27,171 @@
- +
- - {{ 'admin.2fa.use-system-two-factor-auth-settings' | translate }} - - - - admin.2fa.total-allowed-time-for-verification - - - {{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }} - - - {{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }} - - - - admin.2fa.max-verification-failures-before-user-lockout - - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-required' | translate }} - - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} - - - - admin.2fa.verification-code-send-rate-limit - - - {{ 'admin.2fa.verification-code-send-rate-limit-required' | translate }} - - - {{ 'admin.2fa.verification-code-send-rate-limit-pattern' | translate }} - - - - admin.2fa.verification-code-check-rate-limit - - - {{ 'admin.2fa.verification-code-check-rate-limit-required' | translate }} - - - {{ 'admin.2fa.verification-code-check-rate-limit-pattern' | translate }} - - -
admin.2fa.available-providers
- -
- - - - - {{ provider.value.providerType }} - - - - - + +
+ admin.2fa.general-setting +
+ + admin.2fa.total-allowed-time-for-verification + + + {{ 'admin.2fa.total-allowed-time-for-verification-required' | translate }} + + + {{ 'admin.2fa.total-allowed-time-for-verification-pattern' | translate }} + + + + admin.2fa.max-verification-failures-before-user-lockout + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-required' | translate }} + + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} + + +
+ + {{ 'admin.2fa.verification-code-send-rate-limit' | translate }} + +
+ + admin.2fa.number-of-attempts + + + {{ 'admin.2fa.number-of-attempts-required' | translate }} + + + {{ 'admin.2fa.number-of-attempts-pattern' | translate }} + + + + admin.2fa.within-time + + + {{ 'admin.2fa.within-time-required' | translate }} + + + {{ 'admin.2fa.within-time-pattern' | translate }} + + +
+ + {{ 'admin.2fa.verification-code-check-rate-limit' | translate }} + +
+ + admin.2fa.number-of-attempts + + + {{ 'admin.2fa.number-of-attempts-required' | translate }} + + + {{ 'admin.2fa.number-of-attempts-pattern' | translate }} + + + + admin.2fa.within-time + + + {{ 'admin.2fa.within-time-required' | translate }} + + + {{ 'admin.2fa.within-time-pattern' | translate }} + + +
+
+
+ admin.2fa.available-providers + + + + + + + {{ provider.value.providerType }} + + - -
+ + + - admin.2fa.provider - - - {{ provider }} - - + admin.2fa.issuer-name + + + {{ "admin.2fa.issuer-name-required" | translate }} + + + +
+ + admin.2fa.verification-message-template + + + {{ "admin.2fa.verification-message-template-required" | translate }} + + + {{ "admin.2fa.verification-message-template-pattern" | translate }} + - - - - admin.2fa.issuer-name - - - {{ "admin.2fa.issuer-name-required" | translate }} - - - -
- - admin.2fa.verification-message-template - - - {{ "admin.2fa.verification-message-template-required" | translate }} - - - {{ "admin.2fa.verification-message-template-pattern" | translate }} - - - - - admin.2fa.verification-code-lifetime - - - {{ "admin.2fa.verification-code-lifetime-required" | translate }} - - - {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} - - -
-
- - admin.2fa.verification-code-lifetime - - - {{ "admin.2fa.verification-code-lifetime-required" | translate }} - - - {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} - - -
-
-
-
-
- -
-
+ + admin.2fa.verification-code-lifetime + + + {{ "admin.2fa.verification-code-lifetime-required" | translate }} + + + {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} + + +
+
+ + admin.2fa.verification-code-lifetime + + + {{ "admin.2fa.verification-code-lifetime-required" | translate }} + + + {{ "admin.2fa.verification-code-lifetime-pattern" | translate }} + + +
+ + + + + + +
+ +
-
- - -
diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss index 8fb412f648..2ae3981717 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.scss @@ -15,7 +15,66 @@ */ :host{ - .container { - margin-bottom: 1em; + + .fields-group { + margin: 8px 0; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; + position: relative; + padding-bottom: 8px; + + legend { + color: rgba(0, 0, 0, .7); + width: fit-content; + } + + &:not(:last-of-type) { + padding: 8px; + } + + &:last-of-type { + margin-bottom: 24px; + legend { + margin: 0 8px; + } + } + + .rate-limit-toggle { + display: block; + margin-bottom: 8px; + } + } + + .mat-expansion-panel { + box-shadow: none; + margin: 1px 0 0; + &.provider { + overflow: inherit; + .mat-expansion-panel-header { + padding: 0 24px 0 8px; + &.mat-expanded { + height: 48px; + } + .mat-slide-toggle { + margin-right: 8px; + } + } + .mat-expansion-panel-header-title { + height: 40px; + } + } + } +} + +:host ::ng-deep { + .mat-expansion-panel { + &.provider { + .mat-expansion-panel-header > .mat-content { + overflow: inherit; + } + .mat-expansion-panel-body { + padding: 0 16px 8px 8px; + } + } } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts index 5caf1533ed..b23576f549 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.ts @@ -14,21 +14,21 @@ /// limitations under the License. /// -import { Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { ActivatedRoute } from '@angular/router'; -import { AbstractControl, FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { DialogService } from '@core/services/dialog.service'; -import { TranslateService } from '@ngx-translate/core'; -import { WINDOW } from '@core/services/window.service'; +import { FormArray, FormBuilder, FormGroup, Validators } from '@angular/forms'; import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; -import { AuthState } from '@core/auth/auth.models'; -import { getCurrentAuthState } from '@core/auth/auth.selectors'; -import { Authority } from '@shared/models/authority.enum'; -import { TwoFactorAuthProviderType, TwoFactorAuthSettings } from '@shared/models/two-factor-auth.models'; +import { + TwoFactorAuthProviderType, + TwoFactorAuthSettings, + TwoFactorAuthSettingsForm +} from '@shared/models/two-factor-auth.models'; +import { deepClone, isNotEmptyStr } from '@core/utils'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'tb-2fa-settings', @@ -37,56 +37,72 @@ import { TwoFactorAuthProviderType, TwoFactorAuthSettings } from '@shared/models }) export class TwoFactorAuthSettingsComponent extends PageComponent implements OnInit, HasConfirmForm, OnDestroy { - private authState: AuthState = getCurrentAuthState(this.store); - private authUser = this.authState.authUser; + private readonly destroy$ = new Subject(); twoFaFormGroup: FormGroup; - twoFactorAuthProviderTypes = Object.keys(TwoFactorAuthProviderType); twoFactorAuthProviderType = TwoFactorAuthProviderType; constructor(protected store: Store, - private route: ActivatedRoute, private twoFaService: TwoFactorAuthenticationService, - private fb: FormBuilder, - private dialogService: DialogService, - private translate: TranslateService, - @Inject(WINDOW) private window: Window) { + private fb: FormBuilder) { super(store); } ngOnInit() { this.build2faSettingsForm(); this.twoFaService.getTwoFaSettings().subscribe((setting) => { - this.initTwoFactorAuthForm(setting); + this.setAuthConfigFormValue(setting); }); } ngOnDestroy() { super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); } confirmForm(): FormGroup { return this.twoFaFormGroup; } - isTenantAdmin(): boolean { - return this.authUser.authority === Authority.TENANT_ADMIN; + save() { + if (this.twoFaFormGroup.valid) { + const setting = this.twoFaFormGroup.value as TwoFactorAuthSettingsForm; + this.joinRateLimit(setting, 'verificationCodeCheckRateLimit'); + this.joinRateLimit(setting, 'verificationCodeSendRateLimit'); + const providers = setting.providers.filter(provider => provider.enable); + providers.forEach(provider => delete provider.enable); + const config = Object.assign(setting, {providers}); + this.twoFaService.saveTwoFaSettings(config).subscribe( + () => { + this.twoFaFormGroup.markAsUntouched(); + this.twoFaFormGroup.markAsPristine(); + } + ); + } else { + Object.keys(this.twoFaFormGroup.controls).forEach(field => { + const control = this.twoFaFormGroup.get(field); + control.markAsTouched({onlySelf: true}); + }); + } } - save() { - const setting = this.twoFaFormGroup.value; - this.twoFaService.saveTwoFaSettings(setting).subscribe( - (twoFactorAuthSettings) => { - this.twoFaFormGroup.patchValue(twoFactorAuthSettings, {emitEvent: false}); - this.twoFaFormGroup.markAsUntouched(); - this.twoFaFormGroup.markAsPristine(); - } - ); + toggleProviders($event: Event): void { + if ($event) { + $event.stopPropagation(); + } + } + + trackByElement(i: number, item: any) { + return item; + } + + get providersForm(): FormArray { + return this.twoFaFormGroup.get('providers') as FormArray; } private build2faSettingsForm(): void { this.twoFaFormGroup = this.fb.group({ - useSystemTwoFactorAuthSettings: [this.isTenantAdmin()], maxVerificationFailuresBeforeUserLockout: [30, [ Validators.required, Validators.pattern(/^\d*$/), @@ -98,91 +114,122 @@ export class TwoFactorAuthSettingsComponent extends PageComponent implements OnI Validators.min(1), Validators.pattern(/^\d*$/) ]], - verificationCodeCheckRateLimit: ['3:900', [Validators.required, Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)]], - verificationCodeSendRateLimit: ['1:60', [Validators.required, Validators.pattern(/^[1-9]\d*:[1-9]\d*$/)]], + verificationCodeCheckRateLimitEnable: [false], + verificationCodeCheckRateLimitNumber: ['3', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]], + verificationCodeCheckRateLimitTime: ['900', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]], + verificationCodeSendRateLimitEnable: [false], + verificationCodeSendRateLimitNumber: ['1', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]], + verificationCodeSendRateLimitTime: ['60', [Validators.required, Validators.min(1), Validators.pattern(/^\d*$/)]], providers: this.fb.array([]) }); - } - - private initTwoFactorAuthForm(settings: TwoFactorAuthSettings) { - settings.providers.forEach(() => { - this.addProvider(); + Object.values(TwoFactorAuthProviderType).forEach(provider => { + this.buildProvidersSettingsForm(provider); + }); + this.twoFaFormGroup.get('verificationCodeCheckRateLimitEnable').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').enable({emitEvent: false}); + this.twoFaFormGroup.get('verificationCodeCheckRateLimitTime').enable({emitEvent: false}); + } else { + this.twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').disable({emitEvent: false}); + this.twoFaFormGroup.get('verificationCodeCheckRateLimitTime').disable({emitEvent: false}); + } + }); + this.twoFaFormGroup.get('verificationCodeSendRateLimitEnable').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.twoFaFormGroup.get('verificationCodeSendRateLimitNumber').enable({emitEvent: false}); + this.twoFaFormGroup.get('verificationCodeSendRateLimitTime').enable({emitEvent: false}); + } else { + this.twoFaFormGroup.get('verificationCodeSendRateLimitNumber').disable({emitEvent: false}); + this.twoFaFormGroup.get('verificationCodeSendRateLimitTime').disable({emitEvent: false}); + } }); - this.twoFaFormGroup.patchValue(settings); - this.twoFaFormGroup.markAsPristine(); } - addProvider() { - const newProviders = this.fb.group({ - providerType: [TwoFactorAuthProviderType.TOTP], - issuerName: ['ThingsBoard', Validators.required], - smsVerificationMessageTemplate: [{ - value: 'Verification code: ${verificationCode}', - disabled: true - }, [ - Validators.required, - Validators.pattern(/\${verificationCode}/) - ]], - verificationCodeLifetime: [{ - value: 120, - disabled: true - }, [ - Validators.required, - Validators.min(1), - Validators.pattern(/^\d*$/) - ]] + private setAuthConfigFormValue(settings: TwoFactorAuthSettings) { + const [checkRateLimitNumber, checkRateLimitTime] = this.splitRateLimit(settings.verificationCodeCheckRateLimit); + const [sendRateLimitNumber, sendRateLimitTime] = this.splitRateLimit(settings.verificationCodeSendRateLimit); + const allowProvidersConfig = settings.providers.map(provider => provider.providerType); + const processFormValue: TwoFactorAuthSettingsForm = Object.assign(deepClone(settings), { + verificationCodeCheckRateLimitEnable: checkRateLimitNumber > 0, + verificationCodeCheckRateLimitNumber: checkRateLimitNumber || 3, + verificationCodeCheckRateLimitTime: checkRateLimitTime || 900, + verificationCodeSendRateLimitEnable: sendRateLimitNumber > 0, + verificationCodeSendRateLimitNumber: sendRateLimitNumber || 1, + verificationCodeSendRateLimitTime: sendRateLimitTime || 60, + providers: [] }); - newProviders.get('providerType').valueChanges.subscribe(type => { - switch (type) { - case TwoFactorAuthProviderType.SMS: - newProviders.get('issuerName').disable({emitEvent: false}); - newProviders.get('smsVerificationMessageTemplate').enable({emitEvent: false}); - newProviders.get('verificationCodeLifetime').enable({emitEvent: false}); - break; - case TwoFactorAuthProviderType.TOTP: - newProviders.get('issuerName').enable({emitEvent: false}); - newProviders.get('smsVerificationMessageTemplate').disable({emitEvent: false}); - newProviders.get('verificationCodeLifetime').disable({emitEvent: false}); - break; - case TwoFactorAuthProviderType.EMAIL: - newProviders.get('issuerName').disable({emitEvent: false}); - newProviders.get('smsVerificationMessageTemplate').disable({emitEvent: false}); - newProviders.get('verificationCodeLifetime').enable({emitEvent: false}); - break; + Object.values(TwoFactorAuthProviderType).forEach(provider => { + const index = allowProvidersConfig.indexOf(provider); + if (index > -1) { + processFormValue.providers.push(Object.assign(settings.providers[index], {enable: true})); + } else { + processFormValue.providers.push({enable: false}); } }); - if (this.providersForm.length) { - const selectedProviderTypes = this.providersForm.value.map(providers => providers.providerType); - const allowProviders = this.twoFactorAuthProviderTypes.filter(provider => !selectedProviderTypes.includes(provider)); - newProviders.get('providerType').setValue(allowProviders[0]); - newProviders.updateValueAndValidity(); - } - this.providersForm.push(newProviders); - this.providersForm.markAsDirty(); + this.twoFaFormGroup.patchValue(processFormValue); } - removeProviders($event: Event, index: number): void { - if ($event) { - $event.stopPropagation(); - $event.preventDefault(); + private buildProvidersSettingsForm(provider: TwoFactorAuthProviderType) { + const formControlConfig: {[key: string]: any} = { + providerType: [provider], + enable: [false] + }; + switch (provider) { + case TwoFactorAuthProviderType.TOTP: + formControlConfig.issuerName = [{value: 'ThingsBoard', disabled: true}, Validators.required]; + break; + case TwoFactorAuthProviderType.SMS: + formControlConfig.smsVerificationMessageTemplate = [{value: 'Verification code: ${verificationCode}', disabled: true}, [ + Validators.required, + Validators.pattern(/\${verificationCode}/) + ]]; + formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, [ + Validators.required, + Validators.min(1), + Validators.pattern(/^\d*$/) + ]]; + break; + case TwoFactorAuthProviderType.EMAIL: + formControlConfig.verificationCodeLifetime = [{value: 120, disabled: true}, [ + Validators.required, + Validators.min(1), + Validators.pattern(/^\d*$/) + ]]; + break; } - this.providersForm.removeAt(index); - this.providersForm.markAsTouched(); - this.providersForm.markAsDirty(); - } - - get providersForm(): FormArray { - return this.twoFaFormGroup.get('providers') as FormArray; + const newProviders = this.fb.group(formControlConfig); + newProviders.get('enable').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + newProviders.enable({emitEvent: false}); + } else { + newProviders.disable({emitEvent: false}); + newProviders.get('enable').enable({emitEvent: false}); + newProviders.get('providerType').enable({emitEvent: false}); + } + }); + this.providersForm.push(newProviders); } - trackByElement(i: number, item: any) { - return item; + private splitRateLimit(setting: string): [number, number] { + if (isNotEmptyStr(setting)) { + const [attemptNumber, time] = setting.split(':'); + return [parseInt(attemptNumber, 10), parseInt(time, 10)]; + } + return [0, 0]; } - selectedTypes(type: TwoFactorAuthProviderType, index: number): boolean { - const selectedProviderTypes: TwoFactorAuthProviderType[] = this.providersForm.value.map(providers => providers.providerType); - selectedProviderTypes.splice(index, 1); - return selectedProviderTypes.includes(type); + private joinRateLimit(processFormValue: TwoFactorAuthSettingsForm, property: string) { + if (processFormValue[`${property}Enable`]) { + processFormValue[property] = [processFormValue[`${property}Number`], processFormValue[`${property}Time`]].join(':'); + } + delete processFormValue[`${property}Enable`]; + delete processFormValue[`${property}Number`]; + delete processFormValue[`${property}Time`]; } - } diff --git a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts index 731c1212e9..57e4e6b409 100644 --- a/ui-ngx/src/app/shared/models/two-factor-auth.models.ts +++ b/ui-ngx/src/app/shared/models/two-factor-auth.models.ts @@ -23,9 +23,22 @@ export interface TwoFactorAuthSettings { verificationCodeSendRateLimit: string; } +export interface TwoFactorAuthSettingsForm extends TwoFactorAuthSettings{ + providers: Array; + verificationCodeCheckRateLimitEnable: boolean; + verificationCodeCheckRateLimitNumber: number; + verificationCodeCheckRateLimitTime: number; + verificationCodeSendRateLimitEnable: boolean; + verificationCodeSendRateLimitNumber: number; + verificationCodeSendRateLimitTime: number; +} + export type TwoFactorAuthProviderConfig = Partial; +export type TwoFactorAuthProviderConfigForm = Partial & TwoFactorAuthProviderFormConfig; + export interface TotpTwoFactorAuthProviderConfig { providerType: TwoFactorAuthProviderType; issuerName: string; @@ -42,6 +55,10 @@ export interface EmailTwoFactorAuthProviderConfig { verificationCodeLifetime: number; } +export interface TwoFactorAuthProviderFormConfig { + enable: boolean; +} + export enum TwoFactorAuthProviderType{ TOTP = 'TOTP', SMS = 'SMS', @@ -69,5 +86,38 @@ export type TwoFactorAuthAccountConfig = TotpTwoFactorAuthAccountConfig | SmsTwo export interface AccountTwoFaSettings { - configs?: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig}; + configs: {TwoFactorAuthProviderType: TwoFactorAuthAccountConfig}; } + +export interface TwoFaProviderInfo { + type: TwoFactorAuthProviderType; + default: boolean; +} + +export interface TwoFactorAuthProviderData { + name: string; + description: string; +} + +export const twoFactorAuthProvidersData = new Map( + [ + [ + TwoFactorAuthProviderType.TOTP, { + name: 'Authentication app', + description: 'Use apps like Google Authenticator, Authy, or Duo on your phone to authenticate. It will generate a security code for logging in.' + } + ], + [ + TwoFactorAuthProviderType.SMS, { + name: 'SMS', + description: 'Use your phone to authenticate. We\'ll send you a security code via SMS message when you log in.' + } + ], + [ + TwoFactorAuthProviderType.EMAIL, { + name: 'Email', + description: 'Use a security code sent to your email address to authenticate.' + } + ], + ] +); diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 72bd583ef8..28ea3e1f7e 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -123,6 +123,7 @@ "number-from-required": "Phone Number From is required.", "number-to": "Phone Number To", "number-to-required": "Phone Number To is required.", + "phone-number": "Phone Number", "phone-number-hint": "Phone Number in E.164 format, ex. +19995550123", "phone-number-hint-twilio": "Phone Number in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX", "phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.", @@ -314,30 +315,32 @@ }, "2fa": { "2fa": "Two-factor authentication", - "add-provider": "Add provider", - "available-providers": "Available providers:", + "available-providers": "Available providers", + "general-setting": "General setting", "issuer-name": "Issuer name", "issuer-name-required": "Issuer name is required.", "max-verification-failures-before-user-lockout": "Max verification failures before user lockout", "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.", "max-verification-failures-before-user-lockout-required": "Max verification failures is required.", + "number-of-attempts": "Number of attempts", + "number-of-attempts-pattern": "Number of attempts must be a positive integer.", + "number-of-attempts-required": "Number of attempts is required.", "provider": "Provider", "total-allowed-time-for-verification": "Total allowed time for verification (sec)", "total-allowed-time-for-verification-pattern": "Total allowed time must be a positive integer.", "total-allowed-time-for-verification-required": "Total allowed time is required.", "use-system-two-factor-auth-settings": "Use system two factor auth settings", "verification-code-check-rate-limit": "Verification code check rate limit", - "verification-code-check-rate-limit-pattern": "Verification code check limit has invalid format", - "verification-code-check-rate-limit-required": "Verification code check rate limit is required.", "verification-code-lifetime": "Verification code lifetime (sec)", "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.", "verification-code-lifetime-required": "Verification code lifetime is required.", "verification-code-send-rate-limit": "Verification code send rate limit", - "verification-code-send-rate-limit-pattern": "Verification code send limit has invalid format", - "verification-code-send-rate-limit-required": "Verification code send rate limit is required.", "verification-message-template": "Verification message template", "verification-message-template-pattern": "Verification message need to contains pattern: ${verificationCode}", - "verification-message-template-required": "Verification message template is required." + "verification-message-template-required": "Verification message template is required.", + "within-time": "Within time (sec)", + "within-time-pattern": "Time must be a positive integer.", + "within-time-required": "Time is required." } }, "alarm": { From a80e83cb1297f3a486fe8114dd38f36728e8c46b Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 17 May 2022 17:38:30 +0300 Subject: [PATCH 35/92] UI: Add security page for user --- .../http/two-factor-authentication.service.ts | 13 +- .../app/modules/home/models/services.map.ts | 4 +- .../modules/home/pages/home-pages.module.ts | 2 + .../email-auth-dialog.component.html | 104 ----------- .../sms-auth-dialog.component.html | 105 ----------- .../sms-auth-dialog.component.scss | 15 -- .../totp-auth-dialog.component.html | 97 ---------- .../totp-auth-dialog.component.scss | 15 -- .../pages/profile/profile-routing.module.ts | 19 +- .../home/pages/profile/profile.component.html | 99 ++-------- .../home/pages/profile/profile.component.scss | 23 +-- .../home/pages/profile/profile.component.ts | 99 +--------- .../home/pages/profile/profile.module.ts | 8 +- .../authentication-dialog.component.scss | 77 ++++++++ .../authentication-dialog.map.ts | 29 +++ .../email-auth-dialog.component.html | 102 +++++++++++ .../email-auth-dialog.component.ts | 40 ++-- .../sms-auth-dialog.component.html | 105 +++++++++++ .../sms-auth-dialog.component.ts | 40 ++-- .../totp-auth-dialog.component.html | 92 ++++++++++ .../totp-auth-dialog.component.ts | 17 +- .../pages/security/security-routing.module.ts | 84 +++++++++ .../pages/security/security.component.html | 80 ++++++++ .../pages/security/security.component.scss | 115 ++++++++++++ .../home/pages/security/security.component.ts | 171 ++++++++++++++++++ .../home/pages/security/security.module.ts | 39 ++++ .../components/user-menu.component.html | 4 + .../shared/components/user-menu.component.ts | 4 + .../shared/models/two-factor-auth.models.ts | 12 +- .../assets/locale/locale.constant-en_US.json | 100 +++++++--- 30 files changed, 1088 insertions(+), 626 deletions(-) delete mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html delete mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html delete mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss delete mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html delete mode 100644 ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html rename ui-ngx/src/app/modules/home/pages/{profile => security}/authentication-dialog/email-auth-dialog.component.ts (71%) create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html rename ui-ngx/src/app/modules/home/pages/{profile => security}/authentication-dialog/sms-auth-dialog.component.ts (71%) create mode 100644 ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html rename ui-ngx/src/app/modules/home/pages/{profile => security}/authentication-dialog/totp-auth-dialog.component.ts (85%) create mode 100644 ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.component.html create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.component.scss create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.component.ts create mode 100644 ui-ngx/src/app/modules/home/pages/security/security.module.ts diff --git a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts index 36183db9d8..c2a7a5235d 100644 --- a/ui-ngx/src/app/core/http/two-factor-authentication.service.ts +++ b/ui-ngx/src/app/core/http/two-factor-authentication.service.ts @@ -39,8 +39,8 @@ export class TwoFactorAuthenticationService { return this.http.get(`/api/2fa/settings`, defaultHttpOptionsFromConfig(config)); } - saveTwoFaSettings(settings: TwoFactorAuthSettings, config?: RequestConfig): Observable { - return this.http.post(`/api/2fa/settings`, settings, defaultHttpOptionsFromConfig(config)); + saveTwoFaSettings(settings: TwoFactorAuthSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/settings`, settings, defaultHttpOptionsFromConfig(config)); } getAvailableTwoFaProviders(config?: RequestConfig): Observable> { @@ -67,8 +67,9 @@ export class TwoFactorAuthenticationService { } verifyAndSaveTwoFaAccountConfig(authConfig: TwoFactorAuthAccountConfig, verificationCode: number, - config?: RequestConfig): Observable { - return this.http.post(`/api/2fa/account/config?verificationCode=${verificationCode}`, authConfig, defaultHttpOptionsFromConfig(config)); + config?: RequestConfig): Observable { + return this.http.post(`/api/2fa/account/config?verificationCode=${verificationCode}`, + authConfig, defaultHttpOptionsFromConfig(config)); } deleteTwoFaAccountConfig(providerType: TwoFactorAuthProviderType, config?: RequestConfig): Observable { @@ -76,4 +77,8 @@ export class TwoFactorAuthenticationService { defaultHttpOptionsFromConfig(config)); } + requestTwoFaVerificationCodeSend(providerType: TwoFactorAuthProviderType, config?: RequestConfig) { + return this.http.post(`/api/auth/2fa/verification/send?providerType=${providerType}`, defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/models/services.map.ts b/ui-ngx/src/app/modules/home/models/services.map.ts index 1f3a2bf5ae..9795dcdeda 100644 --- a/ui-ngx/src/app/modules/home/models/services.map.ts +++ b/ui-ngx/src/app/modules/home/models/services.map.ts @@ -36,6 +36,7 @@ import { BroadcastService } from '@core/services/broadcast.service'; import { ImportExportService } from '@home/components/import-export/import-export.service'; import { DeviceProfileService } from '@core/http/device-profile.service'; import { OtaPackageService } from '@core/http/ota-package.service'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; export const ServicesMap = new Map>( [ @@ -59,6 +60,7 @@ export const ServicesMap = new Map>( ['router', Router], ['importExport', ImportExportService], ['deviceProfileService', DeviceProfileService], - ['otaPackageService', OtaPackageService] + ['otaPackageService', OtaPackageService], + ['twoFactorAuthenticationService', TwoFactorAuthenticationService] ] ); diff --git a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts index 576ab1c9b2..cf3aec6191 100644 --- a/ui-ngx/src/app/modules/home/pages/home-pages.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-pages.module.ts @@ -19,6 +19,7 @@ import { NgModule } from '@angular/core'; import { AdminModule } from './admin/admin.module'; import { HomeLinksModule } from './home-links/home-links.module'; import { ProfileModule } from './profile/profile.module'; +import { SecurityModule } from '@home/pages/security/security.module'; import { TenantModule } from '@modules/home/pages/tenant/tenant.module'; import { CustomerModule } from '@modules/home/pages/customer/customer.module'; import { AuditLogModule } from '@modules/home/pages/audit-log/audit-log.module'; @@ -42,6 +43,7 @@ import { OtaUpdateModule } from '@home/pages/ota-update/ota-update.module'; AdminModule, HomeLinksModule, ProfileModule, + SecurityModule, TenantProfileModule, TenantModule, DeviceProfileModule, diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html deleted file mode 100644 index 46710e6ba0..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.html +++ /dev/null @@ -1,104 +0,0 @@ - - -

Enable email authenticator

- - -
- - -
-
- - - done - - - Add email -
- - user.email - - - {{ 'user.email-required' | translate }} - - - {{ 'user.invalid-email-format' | translate }} - - -
- - -
-
-
- - Authentication verification -
-

Enter the 6-digit code here

-

Enter the 6 digit verification code we just sent to {{ emailConfigForm.get('email').value }}.

- - 6-digit code - - -
- - -
-
-
- - Two-factor authentication activated -
-

Email authentication enabled

-

If we notice an attempted login from a device or browser we don’t recognize, you will be prompted to enter the security code that will be sent to your email address.

-
- -
-
-
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html deleted file mode 100644 index 03c6c7a465..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.html +++ /dev/null @@ -1,105 +0,0 @@ - - -

Enable SMS authenticator

- - -
- - -
-
- - - done - - - Add phone number -
-

Enter the phone number including the area code.

- - Phone number - - - {{ 'admin.number-to-required' | translate }} - - - {{ 'admin.phone-number-pattern' | translate }} - - -
- - -
-
-
- - Authentication verification -
-

Enter the 6-digit code here

-

Enter the 6 digit verification code we just sent to {{ smsConfigForm.get('phone').value }}.

- - 6-digit code - - -
- - -
-
-
- - Two-factor authentication activated -
-

SMS authentication enabled

-

If we notice an attempted login from a device or browser we don’t recognize, you will be prompted to enter the security code that will be sent to the phone number.

-
- -
-
-
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss deleted file mode 100644 index ec6f008a80..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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. - */ diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html deleted file mode 100644 index 09afbb2de0..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.html +++ /dev/null @@ -1,97 +0,0 @@ - - -

Enable authenticator app

- - -
- - -
-
- - - done - - - Get app -
-

You'll need to use a verification app such as Google Authenticator, Authy, or Duo. Install from your app store

-
- - -
-
-
- - Authentication verification -
-

1. Scan this QR code with your verification app

-

Once your app reads the QR code, you'll get a 6-digit code.

- -

2. Enter the 6-digit code here

-

Enter the code from the app below. Once connected, we'll remember your phone so you can use it each time you log in.

- - 6-digit code - - -
- - -
-
-
- - Two-factor authentication activated -
-

Success

-

The next time you login from an unrecognized browser or device, you will need to provide a two-factor authentication code.

-
- -
-
-
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss deleted file mode 100644 index ec6f008a80..0000000000 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.scss +++ /dev/null @@ -1,15 +0,0 @@ -/** - * 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. - */ diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts index e7dae87288..440db606fd 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile-routing.module.ts @@ -26,8 +26,6 @@ import { AppState } from '@core/core.state'; import { UserService } from '@core/http/user.service'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Observable } from 'rxjs'; -import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; -import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; @Injectable() export class UserProfileResolver implements Resolve { @@ -42,17 +40,6 @@ export class UserProfileResolver implements Resolve { } } -@Injectable() -export class UserTwoFAProvidersResolver implements Resolve> { - - constructor(private twoFactorAuthService: TwoFactorAuthenticationService) { - } - - resolve(): Observable> { - return this.twoFactorAuthService.getAvailableTwoFaProviders(); - } -} - const routes: Routes = [ { path: 'profile', @@ -67,8 +54,7 @@ const routes: Routes = [ } }, resolve: { - user: UserProfileResolver, - providers: UserTwoFAProvidersResolver + user: UserProfileResolver } } ]; @@ -77,8 +63,7 @@ const routes: Routes = [ imports: [RouterModule.forChild(routes)], exports: [RouterModule], providers: [ - UserProfileResolver, - UserTwoFAProvidersResolver + UserProfileResolver ] }) export class ProfileRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index a1e9ec0523..7300f7ea7c 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -18,12 +18,13 @@
-
+
profile.profile {{ profile ? profile.get('email').value : '' }}
-
+
profile.last-login-time
@@ -77,21 +78,23 @@ {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} -
- -
-
- - {{ expirationJwtData }} +
+
+ +
+
+ +
{{ expirationJwtData }}
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss index f3f41c41d8..737c7c0999 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.scss @@ -39,8 +39,10 @@ font-weight: 400; } .profile-btn-subtext { - opacity: 0.7; - padding: 10px; + font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.25px; + opacity: 0.6; + padding: 8px 0; } .tb-home-dashboard { tb-dashboard-autocomplete { @@ -59,21 +61,4 @@ } } } - .description { - padding-bottom: 8px; - color: #808080; - margin-right: 8px; - } - - .provider { - padding: 14px 0; - - .mat-h3 { - margin-bottom: 8px; - } - - .checkbox-label { - font-size: 14px; - } - } } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts index 0c676aca46..76187b58c2 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, OnInit, ViewChild } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { UserService } from '@core/http/user.service'; import { AuthUser, User } from '@shared/models/user.model'; import { Authority } from '@shared/models/authority.enum'; @@ -37,12 +37,6 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { DatePipe } from '@angular/common'; import { ClipboardService } from 'ngx-clipboard'; -import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; -import { AccountTwoFaSettings, TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; -import { MatSlideToggle } from '@angular/material/slide-toggle'; -import { TotpAuthDialogComponent } from '@home/pages/profile/authentication-dialog/totp-auth-dialog.component'; -import { SMSAuthDialogComponent } from '@home/pages/profile/authentication-dialog/sms-auth-dialog.component'; -import { EmailAuthDialogComponent, } from '@home/pages/profile/authentication-dialog/email-auth-dialog.component'; @Component({ selector: 'tb-profile', @@ -53,25 +47,8 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir authorities = Authority; profile: FormGroup; - twoFactorAuth: FormGroup; user: User; languageList = env.supportedLangs; - allowTwoFactorAuth = false; - allowSMS2faProvider = false; - allowTOTP2faProvider = false; - allowEmail2faProvider = false; - twoFactorAuthProviderType = TwoFactorAuthProviderType; - - @ViewChild('totp') totp: MatSlideToggle; - - private authDialogMap = new Map( -[ - [TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent], - [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent], - [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent] - ] - ); - private readonly authUser: AuthUser; get jwtToken(): string { @@ -92,7 +69,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir private userService: UserService, private authService: AuthService, private translate: TranslateService, - private twoFaService: TwoFactorAuthenticationService, public dialog: MatDialog, public dialogService: DialogService, public fb: FormBuilder, @@ -104,9 +80,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir ngOnInit() { this.buildProfileForm(); - this.buildTwoFactorForm(); this.userLoaded(this.route.snapshot.data.user); - this.twoFactorLoad(this.route.snapshot.data.providers); } private buildProfileForm() { @@ -120,19 +94,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir }); } - private buildTwoFactorForm() { - this.twoFactorAuth = this.fb.group({ - TOTP: [false], - SMS: [false], - EMAIL: [false], - useByDefault: [null] - }); - this.twoFactorAuth.get('useByDefault').valueChanges.subscribe(value => { - this.twoFaService.updateTwoFaAccountConfig(value, true, {ignoreLoading: true}) - .subscribe(data => this.processTwoFactorAuthConfig(data)); - }); - } - save(): void { this.user = {...this.user, ...this.profile.value}; if (!this.user.additionalInfo) { @@ -190,37 +151,6 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir this.profile.get('homeDashboardHideToolbar').setValue(homeDashboardHideToolbar); } - private twoFactorLoad(providers: TwoFactorAuthProviderType[]) { - if (providers.length) { - this.allowTwoFactorAuth = true; - this.twoFaService.getAccountTwoFaSettings().subscribe(data => this.processTwoFactorAuthConfig(data)); - providers.forEach(provider => { - switch (provider) { - case TwoFactorAuthProviderType.SMS: - this.allowSMS2faProvider = true; - break; - case TwoFactorAuthProviderType.TOTP: - this.allowTOTP2faProvider = true; - break; - case TwoFactorAuthProviderType.EMAIL: - this.allowEmail2faProvider = true; - break; - } - }); - } - } - - private processTwoFactorAuthConfig(setting?: AccountTwoFaSettings) { - if (setting) { - Object.values(setting.configs).forEach(config => { - this.twoFactorAuth.get(config.providerType).setValue(true); - if (config.useByDefault) { - this.twoFactorAuth.get('useByDefault').setValue(config.providerType, {emitEvent: false}); - } - }); - } - } - confirmForm(): FormGroup { return this.profile; } @@ -249,31 +179,4 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir })); } } - - confirm2FAChange(event: MouseEvent, provider: TwoFactorAuthProviderType) { - event.stopPropagation(); - event.preventDefault(); - const providerName = provider === TwoFactorAuthProviderType.TOTP ? 'authenticator app' : `${provider.toLowerCase()} authentication`; - if (this.twoFactorAuth.get(provider).value) { - this.dialogService.confirm(`Are you sure you want to disable ${providerName}?`, - `Disabling ${providerName} will make your account less secure`).subscribe(res => { - if (res) { - this.twoFaService.deleteTwoFaAccountConfig(provider).subscribe(data => this.processTwoFactorAuthConfig(data)); - } - }); - } else { - this.dialog.open(this.authDialogMap.get(provider), { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], - data: { - email: this.user.email - } - }).afterClosed().subscribe(res => { - if (res) { - this.twoFactorAuth.get(provider).setValue(res); - this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); - } - }); - } - } } diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts index 5210158537..4a8bcab074 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.module.ts @@ -20,17 +20,11 @@ import { ProfileComponent } from './profile.component'; import { SharedModule } from '@shared/shared.module'; import { ProfileRoutingModule } from './profile-routing.module'; import { ChangePasswordDialogComponent } from '@modules/home/pages/profile/change-password-dialog.component'; -import { TotpAuthDialogComponent } from './authentication-dialog/totp-auth-dialog.component'; -import { SMSAuthDialogComponent } from '@home/pages/profile/authentication-dialog/sms-auth-dialog.component'; -import { EmailAuthDialogComponent } from '@home/pages/profile/authentication-dialog/email-auth-dialog.component'; @NgModule({ declarations: [ ProfileComponent, - ChangePasswordDialogComponent, - TotpAuthDialogComponent, - SMSAuthDialogComponent, - EmailAuthDialogComponent + ChangePasswordDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss new file mode 100644 index 0000000000..9b8b17b102 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.component.scss @@ -0,0 +1,77 @@ +/** + * 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. + */ +:host{ + .mat-toolbar > h2 { + font-weight: 400; + letter-spacing: 0.25px; + } + + form { + display: block; + } + + .mat-body-1 { + margin-bottom: 0; + letter-spacing: 0.25px; + + &:not(:first-of-type) { + margin: 0 0 8px; + } + } + + .input-container { + max-width: 290px; + } + + .code-container { + max-width: 170px; + } + + .result-title { + font: 500 18px / 24px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.1px; + margin: 8px 0; + text-align: center; + } + + .result-description { + text-align: center; + margin: 0 0 16px; + letter-spacing: 0.25px; + max-width: 500px; + } + + .step-description { + max-width: 450px; + &.input { + margin: 12px 0 0; + } + } + + .qr-code-description { + text-align: center; + max-width: 180px; + letter-spacing: 0.25px; + color: rgba(0, 0, 0, 0.87); + margin-bottom: 0; + } + + & ::ng-deep { + .mat-horizontal-stepper-header{ + pointer-events: none !important; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts new file mode 100644 index 0000000000..503fdd69a6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/authentication-dialog.map.ts @@ -0,0 +1,29 @@ +/// +/// 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. +/// + +import { Type } from '@angular/core'; +import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { TotpAuthDialogComponent } from './totp-auth-dialog.component'; +import { SMSAuthDialogComponent } from './sms-auth-dialog.component'; +import { EmailAuthDialogComponent } from './email-auth-dialog.component'; + +export const authenticationDialogMap = new Map>( + [ + [TwoFactorAuthProviderType.TOTP, TotpAuthDialogComponent], + [TwoFactorAuthProviderType.SMS, SMSAuthDialogComponent], + [TwoFactorAuthProviderType.EMAIL, EmailAuthDialogComponent] + ] +); diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html new file mode 100644 index 0000000000..d591dc7261 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.html @@ -0,0 +1,102 @@ + + +

security.2fa.dialog.enable-email-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.email-step-label' | translate }} +
+

security.2fa.dialog.email-step-description

+
+ + + + + {{ 'user.email-required' | translate }} + + + {{ 'user.invalid-email-format' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

+ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: emailConfigForm.get('email').value} }} +

+
+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-email

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts similarity index 71% rename from ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts rename to ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts index be5976c78b..166ff799b7 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/email-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/email-auth-dialog.component.ts @@ -32,7 +32,7 @@ export interface EmailAuthDialogData { @Component({ selector: 'tb-email-auth-dialog', templateUrl: './email-auth-dialog.component.html', - styleUrls: ['./email-auth-dialog.component.scss'] + styleUrls: ['./authentication-dialog.component.scss'] }) export class EmailAuthDialogComponent extends DialogComponent { @@ -68,20 +68,28 @@ export class EmailAuthDialogComponent extends DialogComponent { - this.stepper.next(); - }); + if (this.emailConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.EMAIL, + useByDefault: true, + email: this.emailConfigForm.get('email').value as string + }; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.emailConfigForm); + } break; case 1: - this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, - this.emailVerificationForm.get('verificationCode').value).subscribe(() => { - this.stepper.next(); - }); + if (this.emailVerificationForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.emailVerificationForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.emailVerificationForm); + } break; } } @@ -90,4 +98,10 @@ export class EmailAuthDialogComponent extends DialogComponent 1); } + private showFormErrors(form: FormGroup) { + Object.keys(form.controls).forEach(field => { + const control = form.get(field); + control.markAsTouched({onlySelf: true}); + }); + } } diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html new file mode 100644 index 0000000000..61b093dd00 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.html @@ -0,0 +1,105 @@ + + +

security.2fa.dialog.enable-sms-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.sms-step-label' | translate }} +
+

security.2fa.dialog.sms-step-description

+
+ + + + + {{ 'admin.number-to-required' | translate }} + + + {{ 'admin.phone-number-pattern' | translate }} + + + + +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

+ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: smsConfigForm.get('phone').value} }} +

+
+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + + +
+
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-sms

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts similarity index 71% rename from ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts rename to ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts index ed707922eb..030b67fd7a 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/sms-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/sms-auth-dialog.component.ts @@ -29,7 +29,7 @@ import { MatStepper } from '@angular/material/stepper'; @Component({ selector: 'tb-sms-auth-dialog', templateUrl: './sms-auth-dialog.component.html', - styleUrls: ['./sms-auth-dialog.component.scss'] + styleUrls: ['./authentication-dialog.component.scss'] }) export class SMSAuthDialogComponent extends DialogComponent { @@ -66,20 +66,28 @@ export class SMSAuthDialogComponent extends DialogComponent { - this.stepper.next(); - }); + if (this.smsConfigForm.valid) { + this.authAccountConfig = { + providerType: TwoFactorAuthProviderType.SMS, + useByDefault: true, + phoneNumber: this.smsConfigForm.get('phone').value as string + }; + this.twoFaService.submitTwoFaAccountConfig(this.authAccountConfig).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.smsConfigForm); + } break; case 1: - this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, - this.smsVerificationForm.get('verificationCode').value).subscribe(() => { - this.stepper.next(); - }); + if (this.smsVerificationForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.smsVerificationForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + } else { + this.showFormErrors(this.smsVerificationForm); + } break; } } @@ -88,4 +96,10 @@ export class SMSAuthDialogComponent extends DialogComponent 1); } + private showFormErrors(form: FormGroup) { + Object.keys(form.controls).forEach(field => { + const control = form.get(field); + control.markAsTouched({onlySelf: true}); + }); + } } diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html new file mode 100644 index 0000000000..6de8259011 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.html @@ -0,0 +1,92 @@ + + +

security.2fa.dialog.enable-totp-title

+ + +
+ + +
+
+ + + done + + + {{ 'security.2fa.dialog.totp-step-label' | translate }} +
+

security.2fa.dialog.totp-step-description-open

+

security.2fa.dialog.totp-step-description-install

+
+ +
+
+
+ + {{ 'security.2fa.dialog.verification-step-label' | translate }} +
+

security.2fa.dialog.scan-qr-code

+ +

security.2fa.dialog.enter-verification-code

+ + + + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + +
+
+ +
+
+ + {{ 'security.2fa.dialog.activation-step-label' | translate }} +
+

security.2fa.dialog.success

+

security.2fa.dialog.activation-step-description-totp

+
+ +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts similarity index 85% rename from ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts rename to ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts index 86b596ac2b..52aab77b57 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/authentication-dialog/totp-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts @@ -28,7 +28,7 @@ import { MatStepper } from '@angular/material/stepper'; @Component({ selector: 'tb-totp-auth-dialog', templateUrl: './totp-auth-dialog.component.html', - styleUrls: ['./totp-auth-dialog.component.scss'] + styleUrls: ['./authentication-dialog.component.scss'] }) export class TotpAuthDialogComponent extends DialogComponent { @@ -67,10 +67,17 @@ export class TotpAuthDialogComponent extends DialogComponent { - this.stepper.next(); - }); + if (this.totpConfigForm.valid) { + this.twoFaService.verifyAndSaveTwoFaAccountConfig(this.authAccountConfig, + this.totpConfigForm.get('verificationCode').value).subscribe(() => { + this.stepper.next(); + }); + } else { + Object.keys(this.totpConfigForm.controls).forEach(field => { + const control = this.totpConfigForm.get(field); + control.markAsTouched({onlySelf: true}); + }); + } } closeDialog() { diff --git a/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts b/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts new file mode 100644 index 0000000000..430635cd8e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security-routing.module.ts @@ -0,0 +1,84 @@ +/// +/// 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. +/// + +import { Injectable, NgModule } from '@angular/core'; +import { Resolve, RouterModule, Routes } from '@angular/router'; + +import { SecurityComponent } from './security.component'; +import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; +import { Authority } from '@shared/models/authority.enum'; +import { User } from '@shared/models/user.model'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UserService } from '@core/http/user.service'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; +import { Observable } from 'rxjs'; +import { TwoFactorAuthProviderType } from '@shared/models/two-factor-auth.models'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; + +@Injectable() +export class UserProfileResolver implements Resolve { + + constructor(private store: Store, + private userService: UserService) { + } + + resolve(): Observable { + const userId = getCurrentAuthUser(this.store).userId; + return this.userService.getUser(userId); + } +} + +@Injectable() +export class UserTwoFAProvidersResolver implements Resolve> { + + constructor(private twoFactorAuthService: TwoFactorAuthenticationService) { + } + + resolve(): Observable> { + return this.twoFactorAuthService.getAvailableTwoFaProviders(); + } +} + +const routes: Routes = [ + { + path: 'security', + component: SecurityComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + auth: [Authority.SYS_ADMIN, Authority.TENANT_ADMIN, Authority.CUSTOMER_USER], + title: 'security.security', + breadcrumb: { + label: 'security.security', + icon: 'lock' + } + }, + resolve: { + user: UserProfileResolver, + providers: UserTwoFAProvidersResolver + } + } +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [ + UserProfileResolver, + UserTwoFAProvidersResolver + ] +}) +export class SecurityRoutingModule { } diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html new file mode 100644 index 0000000000..19d414535e --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -0,0 +1,80 @@ + +
+ + +
+
+ security.security +
+
+ profile.last-login-time + +
+
+
+ +
+ +
{{ expirationJwtData }}
+
+
+
+ + + admin.2fa.2fa + + +
security.2fa.2fa-description
+
+ +

security.2fa.authenticate-with

+
+
+ + +
+

{{ providersData.get(provider).name | translate }}

+
+
+ {{ providersData.get(provider).description | translate }} +
+ + +
+ + security.2fa.main-2fa-method + +
+ +
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.scss b/ui-ngx/src/app/modules/home/pages/security/security.component.scss new file mode 100644 index 0000000000..b14b2ebd18 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.scss @@ -0,0 +1,115 @@ +/** + * 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. + */ +@import "../../../../../scss/constants"; + +:host { + .profile-container { + padding: 8px; + } + mat-card.profile-card { + @media #{$mat-gt-sm} { + width: 70%; + } + @media #{$mat-gt-md} { + width: 50%; + } + @media #{$mat-gt-xl} { + width: 45%; + } + .title-container { + margin: 0; + } + .profile-email { + font-size: 16px; + font-weight: 400; + } + .mat-subheader { + line-height: 24px; + color: rgba(0,0,0,0.54); + font-size: 14px; + font-weight: 400; + } + .profile-last-login-ts { + font-size: 16px; + font-weight: 400; + } + .profile-btn-subtext { + font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.25px; + opacity: 0.6; + padding: 8px 0; + } + .tb-home-dashboard { + tb-dashboard-autocomplete { + @media #{$mat-gt-sm} { + padding-right: 12px; + } + + @media #{$mat-lt-md} { + padding-bottom: 12px; + } + } + mat-checkbox { + @media #{$mat-gt-sm} { + margin-top: 16px; + } + } + } + } + + .description { + color: rgba(0, 0, 0, 0.54); + letter-spacing: 0.25px; + margin-right: 8px; + } + + .auth-title { + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + margin: 0; + } + + .mat-divider-horizontal { + left: 16px; + right: 16px; + width: auto; + } + + .provider { + padding: 24px 0; + + .provider-title { + font: 400 14px / 16px Roboto, "Helvetica Neue", sans-serif; + letter-spacing: 0.25px; + margin: 0 0 8px; + } + + .description { + max-width: 85%; + } + + .checkbox-label { + font-size: 14px; + letter-spacing: 0.25px; + opacity: 0.87; + } + + .mat-radio-button { + margin-top: 8px; + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts new file mode 100644 index 0000000000..d5ded5f4f8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -0,0 +1,171 @@ +/// +/// 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. +/// + +import { Component, OnInit } from '@angular/core'; +import { User } from '@shared/models/user.model'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { ActivatedRoute } from '@angular/router'; +import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { DatePipe } from '@angular/common'; +import { ClipboardService } from 'ngx-clipboard'; +import { TwoFactorAuthenticationService } from '@core/http/two-factor-authentication.service'; +import { + AccountTwoFaSettings, + twoFactorAuthProvidersData, + TwoFactorAuthProviderType +} from '@shared/models/two-factor-auth.models'; +import { authenticationDialogMap } from '@home/pages/security/authentication-dialog/authentication-dialog.map'; + +@Component({ + selector: 'tb-security', + templateUrl: './security.component.html', + styleUrls: ['./security.component.scss'] +}) +export class SecurityComponent extends PageComponent implements OnInit { + + twoFactorAuth: FormGroup; + user: User; + allowTwoFactorProviders: TwoFactorAuthProviderType[] = []; + providersData = twoFactorAuthProvidersData; + + get jwtToken(): string { + return `Bearer ${localStorage.getItem('jwt_token')}`; + } + + get jwtTokenExpiration(): string { + return localStorage.getItem('jwt_token_expiration'); + } + + get expirationJwtData(): string { + const expirationData = this.datePipe.transform(this.jwtTokenExpiration, 'yyyy-MM-dd HH:mm:ss'); + return this.translate.instant('profile.valid-till', { expirationData }); + } + + constructor(protected store: Store, + private route: ActivatedRoute, + private translate: TranslateService, + private twoFaService: TwoFactorAuthenticationService, + public dialog: MatDialog, + public dialogService: DialogService, + public fb: FormBuilder, + private datePipe: DatePipe, + private clipboardService: ClipboardService) { + super(store); + } + + ngOnInit() { + this.buildTwoFactorForm(); + this.user = this.route.snapshot.data.user; + this.twoFactorLoad(this.route.snapshot.data.providers); + } + + private buildTwoFactorForm() { + this.twoFactorAuth = this.fb.group({ + TOTP: [false], + SMS: [false], + EMAIL: [false], + useByDefault: [null] + }); + this.twoFactorAuth.get('useByDefault').valueChanges.subscribe(value => { + this.twoFaService.updateTwoFaAccountConfig(value, true, {ignoreLoading: true}) + .subscribe(data => this.processTwoFactorAuthConfig(data)); + }); + } + + private twoFactorLoad(providers: TwoFactorAuthProviderType[]) { + if (providers.length) { + this.twoFaService.getAccountTwoFaSettings().subscribe(data => this.processTwoFactorAuthConfig(data)); + Object.values(TwoFactorAuthProviderType).forEach(type => { + if (providers.includes(type)) { + this.allowTwoFactorProviders.push(type); + } + }); + } + } + + private processTwoFactorAuthConfig(setting: AccountTwoFaSettings) { + const configs = setting.configs; + Object.values(TwoFactorAuthProviderType).forEach(provider => { + if (configs[provider]) { + this.twoFactorAuth.get(provider).setValue(true); + if (configs[provider].useByDefault) { + this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); + } + } else { + this.twoFactorAuth.get(provider).setValue(false); + } + }); + } + + trackByProvider(i: number, provider: TwoFactorAuthProviderType) { + return provider; + } + + copyToken() { + if (+this.jwtTokenExpiration < Date.now()) { + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('profile.tokenCopiedWarnMessage'), + type: 'warn', + duration: 1500, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } else { + this.clipboardService.copyFromContent(this.jwtToken); + this.store.dispatch(new ActionNotificationShow({ + message: this.translate.instant('profile.tokenCopiedSuccessMessage'), + type: 'success', + duration: 750, + verticalPosition: 'bottom', + horizontalPosition: 'right' + })); + } + } + + confirm2FAChange(event: MouseEvent, provider: TwoFactorAuthProviderType) { + event.stopPropagation(); + event.preventDefault(); + if (this.twoFactorAuth.get(provider).value) { + const providerName = this.translate.instant(`security.2fa.provider.${provider.toLowerCase()}`); + this.dialogService.confirm( + this.translate.instant('security.2fa.disable-2fa-provider-title', {name: providerName}), + this.translate.instant('security.2fa.disable-2fa-provider-text', {name: providerName}), + ).subscribe(res => { + if (res) { + this.twoFaService.deleteTwoFaAccountConfig(provider).subscribe(data => this.processTwoFactorAuthConfig(data)); + } + }); + } else { + const dialogData = provider === TwoFactorAuthProviderType.EMAIL ? {email: this.user.email} : {}; + this.dialog.open(authenticationDialogMap.get(provider), { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: dialogData + }).afterClosed().subscribe(res => { + if (res) { + this.twoFactorAuth.get(provider).setValue(res); + this.twoFactorAuth.get('useByDefault').setValue(provider, {emitEvent: false}); + } + }); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/security/security.module.ts b/ui-ngx/src/app/modules/home/pages/security/security.module.ts new file mode 100644 index 0000000000..2df54747a7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/security/security.module.ts @@ -0,0 +1,39 @@ +/// +/// 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. +/// + +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SecurityComponent } from './security.component'; +import { SharedModule } from '@shared/shared.module'; +import { SecurityRoutingModule } from './security-routing.module'; +import { TotpAuthDialogComponent } from './authentication-dialog/totp-auth-dialog.component'; +import { SMSAuthDialogComponent } from '@home/pages/security/authentication-dialog/sms-auth-dialog.component'; +import { EmailAuthDialogComponent } from '@home/pages/security/authentication-dialog/email-auth-dialog.component'; + +@NgModule({ + declarations: [ + SecurityComponent, + TotpAuthDialogComponent, + SMSAuthDialogComponent, + EmailAuthDialogComponent + ], + imports: [ + CommonModule, + SharedModule, + SecurityRoutingModule + ] +}) +export class SecurityModule { } diff --git a/ui-ngx/src/app/shared/components/user-menu.component.html b/ui-ngx/src/app/shared/components/user-menu.component.html index 5dfcebf3f7..ffe9cdc158 100644 --- a/ui-ngx/src/app/shared/components/user-menu.component.html +++ b/ui-ngx/src/app/shared/components/user-menu.component.html @@ -32,6 +32,10 @@ account_circle home.profile +