From e2c9a5ffdf2c86f3267d752398a0e024113ebca7 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Thu, 10 Mar 2022 17:59:59 +0200 Subject: [PATCH] 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); }