From 0c36d4809c0e4d4cf7c680d839b42f5ed4f8d42b Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Sat, 19 Mar 2022 19:34:45 +0200 Subject: [PATCH] 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); }