2FA backup codes

This commit is contained in:
Viacheslav Klimov 2022-05-23 16:40:27 +03:00
parent 0bb9d531cc
commit 8eb75f6e16
14 changed files with 199 additions and 21 deletions

View File

@ -140,14 +140,18 @@ public class TwoFaConfigController extends BaseController {
@PostMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig,
@ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS or email message in case of SMS or EMAIL 2FA", required = true)
@RequestParam String verificationCode) throws Exception {
@RequestParam(required = false) String verificationCode) throws Exception {
SecurityUser user = getCurrentUser();
if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) {
throw new IllegalArgumentException("2FA provider is already configured");
}
boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false);
boolean verificationSuccess;
if (accountConfig.getProviderType() != TwoFaProviderType.BACKUP_CODE) {
verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false);
} else {
verificationSuccess = true;
}
if (verificationSuccess) {
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
} else {

View File

@ -16,7 +16,6 @@
package org.thingsboard.server.controller;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -34,11 +33,11 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.EmailTwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.account.SmsTwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.dao.user.UserService;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService;
import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails;
import org.thingsboard.server.service.security.model.JwtTokenPair;
import org.thingsboard.server.service.security.model.SecurityUser;
@ -46,7 +45,6 @@ import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import org.thingsboard.server.service.security.system.SystemSecurityService;
import javax.servlet.http.HttpServletRequest;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
@ -90,7 +88,6 @@ public class TwoFactorAuthController extends BaseController {
@PostMapping("/verification/check")
@PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')")
public JwtTokenPair checkTwoFaVerificationCode(@RequestParam TwoFaProviderType providerType,
@ApiParam(value = "6-digit verification code", required = true)
@RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception {
SecurityUser user = getCurrentUser();
boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true);

View File

@ -122,7 +122,7 @@ public class DefaultTwoFactorAuthService implements TwoFactorAuthService {
.orElseThrow(() -> PROVIDER_NOT_CONFIGURED_ERROR);
boolean verificationSuccess;
if (StringUtils.isNumeric(verificationCode) && verificationCode.length() == 6) {
if (StringUtils.isNotBlank(verificationCode)) {
verificationSuccess = getTwoFaProvider(accountConfig.getProviderType()).checkVerificationCode(user, verificationCode, providerConfig, accountConfig);
} else {
verificationSuccess = false;

View File

@ -30,7 +30,6 @@ import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoF
import org.thingsboard.server.common.data.security.model.mfa.account.TwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.dao.attributes.AttributesService;
import org.thingsboard.server.dao.service.ConstraintValidator;
import org.thingsboard.server.dao.settings.AdminSettingsDao;
import org.thingsboard.server.dao.settings.AdminSettingsService;
@ -39,6 +38,7 @@ import org.thingsboard.server.service.security.auth.mfa.TwoFactorAuthService;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
@Service
@ -74,7 +74,9 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
return newUserAuthSettings;
});
userAuthSettings.setTwoFaSettings(settings);
settings.getConfigs().values().forEach(accountConfig -> accountConfig.setSerializeHiddenFields(true));
userAuthSettingsDao.save(tenantId, userAuthSettings);
settings.getConfigs().values().forEach(accountConfig -> accountConfig.setSerializeHiddenFields(false));
return settings;
}
@ -96,12 +98,13 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
newSettings.setConfigs(new LinkedHashMap<>());
return newSettings;
});
Map<TwoFaProviderType, TwoFaAccountConfig> configs = settings.getConfigs();
if (accountConfig.isUseByDefault()) {
settings.getConfigs().values().forEach(config -> config.setUseByDefault(false));
configs.values().forEach(config -> config.setUseByDefault(false));
}
settings.getConfigs().put(accountConfig.getProviderType(), accountConfig);
if (settings.getConfigs().values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) {
settings.getConfigs().values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true));
configs.put(accountConfig.getProviderType(), accountConfig);
if (configs.values().stream().noneMatch(TwoFaAccountConfig::isUseByDefault)) {
configs.values().stream().findFirst().ifPresent(config -> config.setUseByDefault(true));
}
return saveAccountTwoFaSettings(tenantId, userId, settings);
}

View File

@ -29,7 +29,7 @@ public interface TwoFaProvider<C extends TwoFaProviderConfig, A extends TwoFaAcc
default void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) throws ThingsboardException {}
boolean checkVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig);
boolean checkVerificationCode(SecurityUser user, String code, C providerConfig, A accountConfig);
default void check(TenantId tenantId) throws ThingsboardException {};

View File

@ -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 org.apache.commons.lang3.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.CollectionsUtil;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.security.model.mfa.account.BackupCodeTwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.BackupCodeTwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.auth.mfa.config.TwoFaConfigManager;
import org.thingsboard.server.service.security.auth.mfa.provider.TwoFaProvider;
import org.thingsboard.server.service.security.model.SecurityUser;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@TbCoreComponent
public class BackupCodeTwoFaProvider implements TwoFaProvider<BackupCodeTwoFaProviderConfig, BackupCodeTwoFaAccountConfig> {
@Autowired @Lazy
private TwoFaConfigManager twoFaConfigManager;
@Override
public BackupCodeTwoFaAccountConfig generateNewAccountConfig(User user, BackupCodeTwoFaProviderConfig providerConfig) {
BackupCodeTwoFaAccountConfig config = new BackupCodeTwoFaAccountConfig();
config.setCodes(generateCodes(providerConfig.getCodesQuantity(), 8));
config.setSerializeHiddenFields(true);
return config;
}
private static Set<String> generateCodes(int count, int length) {
return Stream.generate(() -> RandomStringUtils.random(length, "0123456789abcdef"))
.distinct().limit(count)
.collect(Collectors.toSet());
}
@Override
public boolean checkVerificationCode(SecurityUser user, String code, BackupCodeTwoFaProviderConfig providerConfig, BackupCodeTwoFaAccountConfig accountConfig) {
if (CollectionsUtil.contains(accountConfig.getCodes(), code)) {
accountConfig.getCodes().remove(code);
twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
return true;
} else {
return false;
}
}
@Override
public TwoFaProviderType getType() {
return TwoFaProviderType.BACKUP_CODE;
}
}

View File

@ -48,7 +48,7 @@ public abstract class OtpBasedTwoFaProvider<C extends OtpBasedTwoFaProviderConfi
@Override
public final boolean checkVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig) {
public final boolean checkVerificationCode(SecurityUser user, String code, C providerConfig, A accountConfig) {
Otp correctVerificationCode = verificationCodesCache.get(user.getId(), Otp.class);
if (correctVerificationCode != null) {
if (System.currentTimeMillis() - correctVerificationCode.getTimestamp()
@ -56,7 +56,7 @@ public abstract class OtpBasedTwoFaProvider<C extends OtpBasedTwoFaProviderConfi
verificationCodesCache.evict(user.getId());
return false;
}
if (verificationCode.equals(correctVerificationCode.getValue())
if (code.equals(correctVerificationCode.getValue())
&& accountConfig.equals(correctVerificationCode.getAccountConfig())) {
verificationCodesCache.evict(user.getId());
return true;

View File

@ -45,9 +45,9 @@ public class TotpTwoFaProvider implements TwoFaProvider<TotpTwoFaProviderConfig,
}
@Override
public final boolean checkVerificationCode(SecurityUser user, String verificationCode, TotpTwoFaProviderConfig providerConfig, TotpTwoFaAccountConfig accountConfig) {
public final boolean checkVerificationCode(SecurityUser user, String code, TotpTwoFaProviderConfig providerConfig, TotpTwoFaAccountConfig accountConfig) {
String secretKey = UriComponentsBuilder.fromUriString(accountConfig.getAuthUrl()).build().getQueryParams().getFirst("secret");
return new Totp(secretKey).verify(verificationCode);
return new Totp(secretKey).verify(code);
}
@SneakyThrows

View File

@ -0,0 +1,57 @@
/**
* Copyright © 2016-2022 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.security.model.mfa.account;
import com.fasterxml.jackson.annotation.JsonGetter;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import javax.validation.constraints.NotEmpty;
import java.util.Set;
@Data
@EqualsAndHashCode(callSuper = true)
public class BackupCodeTwoFaAccountConfig extends TwoFaAccountConfig {
@NotEmpty
private Set<String> codes;
@Override
public TwoFaProviderType getProviderType() {
return TwoFaProviderType.BACKUP_CODE;
}
@JsonGetter("codes")
private Set<String> getCodesForJson() {
if (serializeHiddenFields) {
return codes;
} else {
return null;
}
}
@JsonGetter
private Integer getCodesLeft() {
if (codes != null) {
return codes.size();
} else {
return null;
}
}
}

View File

@ -30,13 +30,17 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi
@JsonSubTypes({
@Type(name = "TOTP", value = TotpTwoFaAccountConfig.class),
@Type(name = "SMS", value = SmsTwoFaAccountConfig.class),
@Type(name = "EMAIL", value = EmailTwoFaAccountConfig.class)
@Type(name = "EMAIL", value = EmailTwoFaAccountConfig.class),
@Type(name = "BACKUP_CODE", value = BackupCodeTwoFaAccountConfig.class)
})
@Data
public abstract class TwoFaAccountConfig {
private boolean useByDefault;
@JsonIgnore
protected transient boolean serializeHiddenFields;
@JsonIgnore
public abstract TwoFaProviderType getProviderType();

View File

@ -0,0 +1,33 @@
/**
* Copyright © 2016-2022 The Thingsboard Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.thingsboard.server.common.data.security.model.mfa.provider;
import lombok.Data;
import javax.validation.constraints.Min;
@Data
public class BackupCodeTwoFaProviderConfig implements TwoFaProviderConfig {
@Min(0)
private int codesQuantity;
@Override
public TwoFaProviderType getProviderType() {
return TwoFaProviderType.BACKUP_CODE;
}
}

View File

@ -28,7 +28,8 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
@JsonSubTypes({
@Type(name = "TOTP", value = TotpTwoFaProviderConfig.class),
@Type(name = "SMS", value = SmsTwoFaProviderConfig.class),
@Type(name = "EMAIL", value = EmailTwoFaProviderConfig.class)
@Type(name = "EMAIL", value = EmailTwoFaProviderConfig.class),
@Type(name = "BACKUP_CODE", value = BackupCodeTwoFaProviderConfig.class)
})
public interface TwoFaProviderConfig {

View File

@ -18,5 +18,6 @@ package org.thingsboard.server.common.data.security.model.mfa.provider;
public enum TwoFaProviderType {
TOTP,
SMS,
EMAIL
EMAIL,
BACKUP_CODE
}

View File

@ -34,4 +34,9 @@ public class CollectionsUtil {
public static <T> Set<T> diffSets(Set<T> a, Set<T> b) {
return b.stream().filter(p -> !a.contains(p)).collect(Collectors.toSet());
}
public static <T> boolean contains(Collection<T> collection, T element) {
return isNotEmpty(collection) && collection.contains(element);
}
}