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