Email 2FA provider; 2FA API improvements

This commit is contained in:
Viacheslav Klimov 2022-05-05 18:08:54 +03:00
parent bcc736991e
commit 4480badd78
21 changed files with 313 additions and 122 deletions

View File

@ -33,7 +33,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.mfa.PlatformTwoFaSettings;
import org.thingsboard.server.common.data.security.model.mfa.account.AccountTwoFaSettings;
@ -51,6 +50,7 @@ import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static org.thingsboard.server.controller.ControllerConstants.NEW_LINE;
@ -65,20 +65,15 @@ public class TwoFaConfigController extends BaseController {
private final TwoFactorAuthService twoFactorAuthService;
@ApiOperation(value = "Get 2FA account config (getTwoFaAccountConfig)",
notes = "Get user's account 2FA configuration. Returns empty result if user did not configured 2FA, " +
"or if a provider for previously set up account config is not now configured." + NEW_LINE +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE +
"Response example for TOTP 2FA: " + NEW_LINE +
"```\n{\n" +
" \"providerType\": \"TOTP\",\n" +
" \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" +
"}\n```" + NEW_LINE +
"Response example for SMS 2FA: " + NEW_LINE +
"```\n{\n" +
" \"providerType\": \"SMS\",\n" +
" \"phoneNumber\": \"+380505005050\"\n" +
"}\n```")
@ApiOperation(value = "Get account 2FA settings (getAccountTwoFaSettings)",
notes = "Get user's account 2FA configuration. Configuration contains configs for different 2FA providers." + NEW_LINE +
"Example:\n" +
"```\n{\n \"configs\": {\n" +
" \"EMAIL\": {\n \"providerType\": \"EMAIL\",\n \"useByDefault\": true,\n \"email\": \"tenant@thingsboard.org\"\n },\n" +
" \"TOTP\": {\n \"providerType\": \"TOTP\",\n \"useByDefault\": false,\n \"authUrl\": \"otpauth://totp/TB%202FA:tenant@thingsboard.org?issuer=TB+2FA&secret=P6Z2TLYTASOGP6LCJZAD24ETT5DACNNX\"\n },\n" +
" \"SMS\": {\n \"providerType\": \"SMS\",\n \"useByDefault\": false,\n \"phoneNumber\": \"+380501253652\"\n }\n" +
" }\n}\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@GetMapping("/account/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings getAccountTwoFaSettings() throws ThingsboardException {
@ -88,24 +83,29 @@ public class TwoFaConfigController extends BaseController {
@ApiOperation(value = "Generate 2FA account config (generateTwoFaAccountConfig)",
notes = "Generate new 2FA account config for specified provider type. " +
"This method is only useful for TOTP 2FA, as there is nothing to generate for other provider types. " +
notes = "Generate new 2FA account config template for specified provider type. " + NEW_LINE +
"For TOTP, this will return a corresponding account config template " +
"with a generated OTP auth URL (with new random secret key for each API call) that can be then " +
"converted to a QR code to scan with an authenticator app. " +
"For other provider types, this method will return an empty config. " + NEW_LINE +
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER + NEW_LINE +
"Example of a generated account config for TOTP 2FA: " + NEW_LINE +
"converted to a QR code to scan with an authenticator app. Example:\n" +
"```\n{\n" +
" \"providerType\": \"TOTP\",\n" +
" \"authUrl\": \"otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII\"\n" +
" \"useByDefault\": false,\n" +
" \"authUrl\": \"otpauth://totp/TB%202FA:tenant@thingsboard.org?issuer=TB+2FA&secret=PNJDNWJVAK4ZTUYT7RFGPQLXA7XGU7PX\"\n" +
"}\n```" + NEW_LINE +
"For SMS provider type it will return something like: " + NEW_LINE +
"For EMAIL, the generated config will contain email from user's account:\n" +
"```\n{\n" +
" \"providerType\": \"EMAIL\",\n" +
" \"useByDefault\": false,\n" +
" \"email\": \"tenant@thingsboard.org\"\n" +
"}\n```" + NEW_LINE +
"For SMS 2FA this method will just return a config with empty/default values as there is nothing to generate/preset:\n" +
"```\n{\n" +
" \"providerType\": \"SMS\",\n" +
" \"useByDefault\": false,\n" +
" \"phoneNumber\": null\n" +
"}\n```")
"}\n```" + NEW_LINE +
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config/generate")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public TwoFaAccountConfig generateTwoFaAccountConfig(@ApiParam(value = "2FA provider type to generate new account config for", defaultValue = "TOTP", required = true)
@ -133,51 +133,84 @@ public class TwoFaConfigController extends BaseController {
notes = "Submit 2FA account config to prepare for a future verification. " +
"Basically, this method will send a verification code for a given account config, if this has " +
"sense for a chosen 2FA provider. This code is needed to then verify and save the account config." + NEW_LINE +
"Example of EMAIL 2FA account config:\n" +
"```\n{\n" +
" \"providerType\": \"EMAIL\",\n" +
" \"useByDefault\": true,\n" +
" \"email\": \"separate-email-for-2fa@thingsboard.org\"\n" +
"}\n```" + NEW_LINE +
"Example of SMS 2FA account config:\n" +
"```\n{\n" +
" \"providerType\": \"SMS\",\n" +
" \"useByDefault\": false,\n" +
" \"phoneNumber\": \"+38012312321\"\n" +
"}\n```" + NEW_LINE +
"For TOTP this method does nothing." + NEW_LINE +
"Will throw an error (Bad Request) if submitted account config is not valid, " +
"or if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config/submit")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public void submitTwoFaAccountConfig(@ApiParam(value = "2FA account config value. For TOTP 2FA config, authUrl value must not be blank and must match specific pattern. " +
"For SMS 2FA, phoneNumber property must not be blank and must be of E.164 phone number format.", required = true)
@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception {
public void submitTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig) throws Exception {
SecurityUser user = getCurrentUser();
twoFactorAuthService.prepareVerificationCode(user, accountConfig, false);
}
@ApiOperation(value = "Verify and save 2FA account config (verifyAndSaveTwoFaAccountConfig)",
notes = "Checks the verification code for submitted config, and if it is correct, saves the provided account config. " +
"The config is stored in the user's additionalInfo. " + NEW_LINE +
notes = "Checks the verification code for submitted config, and if it is correct, saves the provided account config. " + NEW_LINE +
"Returns whole account's 2FA settings object.\n" +
"Will throw an error (Bad Request) if the provider is not configured for usage. " +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PostMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@ApiParam(value = "2FA account config to save. Validation rules are the same as in submitTwoFaAccountConfig API method", required = true)
@Valid @RequestBody TwoFaAccountConfig accountConfig,
@ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS message in case of SMS 2FA", required = true)
public AccountTwoFaSettings verifyAndSaveTwoFaAccountConfig(@Valid @RequestBody TwoFaAccountConfig accountConfig,
@ApiParam(value = "6-digit code from an authenticator app in case of TOTP 2FA, or the one sent via an SMS or email message in case of SMS or EMAIL 2FA", required = true)
@RequestParam String verificationCode) throws Exception {
SecurityUser user = getCurrentUser();
if (twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig.getProviderType()).isPresent()) {
throw new IllegalArgumentException("2FA provider is already configured");
}
boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, verificationCode, accountConfig, false);
if (verificationSuccess) {
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
} else {
throw new ThingsboardException("Verification code is incorrect", ThingsboardErrorCode.INVALID_ARGUMENTS);
throw new IllegalArgumentException("Verification code is incorrect");
}
}
@ApiOperation(value = "Update 2FA account config (updateTwoFaAccountConfig)", notes =
"Update config for a given provider type. \n" +
"Update request example:\n" +
"```\n{\n \"useByDefault\": true\n}\n```\n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@PutMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings updateTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType,
@RequestBody TwoFaAccountConfigUpdateRequest updateRequest) throws ThingsboardException {
SecurityUser user = getCurrentUser();
TwoFaAccountConfig accountConfig = twoFaConfigManager.getTwoFaAccountConfig(user.getTenantId(), user.getId(), providerType)
.orElseThrow(() -> new IllegalArgumentException("No 2FA config for provider " + providerType));
AccountTwoFaSettings settings = twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user.getId())
.orElseThrow(() -> new IllegalArgumentException("No 2FA config found"));
Map<TwoFaProviderType, TwoFaAccountConfig> configs = settings.getConfigs();
TwoFaAccountConfig accountConfig;
if ((accountConfig = configs.get(providerType)) == null) {
throw new IllegalArgumentException("Config for " + providerType + " 2FA provider not found");
}
if (updateRequest.isUseByDefault()) {
configs.values().forEach(config -> config.setUseByDefault(false));
}
accountConfig.setUseByDefault(updateRequest.isUseByDefault());
return twoFaConfigManager.saveTwoFaAccountConfig(user.getTenantId(), user.getId(), accountConfig);
return twoFaConfigManager.saveAccountTwoFaSettings(user.getTenantId(), user.getId(), settings);
}
@ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)",
notes = "Delete user's 2FA config. " + ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@ApiOperation(value = "Delete 2FA account config (deleteTwoFaAccountConfig)", notes =
"Delete 2FA config for a given 2FA provider type. \n" +
"Returns whole account's 2FA settings object.\n" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER)
@DeleteMapping("/account/config")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public AccountTwoFaSettings deleteTwoFaAccountConfig(@RequestParam TwoFaProviderType providerType) throws ThingsboardException {
@ -186,6 +219,12 @@ public class TwoFaConfigController extends BaseController {
}
@ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes =
"Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" +
"Example of response:\n" +
"```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" +
ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER
)
@GetMapping("/providers")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
public List<TwoFaProviderType> getAvailableTwoFaProviders() throws ThingsboardException {
@ -196,8 +235,9 @@ public class TwoFaConfigController extends BaseController {
}
@ApiOperation(value = "Get 2FA settings (getTwoFaSettings)", // FIXME [viacheslav]
notes = "Get settings for 2FA. If 2FA is not configured, then an empty response will be returned." +
@ApiOperation(value = "Get platform 2FA settings (getPlatformTwoFaSettings)",
notes = "Get platform settings for 2FA. The settings are described for savePlatformTwoFaSettings API method. " +
"If 2FA is not configured, then an empty response will be returned." +
ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@GetMapping("/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")
@ -205,9 +245,49 @@ public class TwoFaConfigController extends BaseController {
return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), false).orElse(null);
}
@ApiOperation(value = "Save 2FA settings (saveTwoFaSettings)",
notes = "Save settings for 2FA. If a user is sysadmin - the settings are saved as AdminSettings; " +
"if it is a tenant admin - as a tenant attribute." +
@ApiOperation(value = "Save platform 2FA settings (savePlatformTwoFaSettings)",
notes = "Save 2FA settings for platform. The settings have following properties:\n" +
"- `useSystemTwoFactorAuthSettings` - option for tenant admins to use 2FA settings configured by sysadmin. " +
"If this param is set to true, then the settings will not be validated for constraints (if it is a tenant admin; for sysadmin this param is ignored).\n" +
"- `providers` - the list of 2FA providers' configs. Users will only be allowed to use 2FA providers from this list. \n\n" +
"- `verificationCodeSendRateLimit` - rate limit configuration for verification code sending. " +
"The format is standard: 'amountOfRequests:periodInSeconds'. The value of '1:60' would limit verification " +
"code sending requests to one per minute.\n" +
"- `verificationCodeCheckRateLimit` - rate limit configuration for verification code checking.\n" +
"- `maxVerificationFailuresBeforeUserLockout` - maximum number of verification failures before a user gets disabled.\n" +
"- `totalAllowedTimeForVerification` - total amount of time in seconds allotted for verification. " +
"Basically, this property sets a lifetime for pre-verification token. If not set, default value of 30 minutes is used.\n" + NEW_LINE +
"TOTP 2FA provider config has following settings:\n" +
"- `issuerName` - issuer name that will be displayed in an authenticator app near a username. Must not be blank.\n\n" +
"For SMS 2FA provider:\n" +
"- `smsVerificationMessageTemplate` - verification message template. Available template variables " +
"are ${verificationCode} and ${userEmail}. It must not be blank and must contain verification code variable.\n" +
"- `verificationCodeLifetime` - verification code lifetime in seconds. Required to be positive.\n\n" +
"For EMAIL provider type:\n" +
"- `verificationCodeLifetime` - the same as for SMS." + NEW_LINE +
"Example of the settings:\n" +
"```\n{\n" +
" \"useSystemTwoFactorAuthSettings\": false,\n" +
" \"providers\": [\n" +
" {\n" +
" \"providerType\": \"TOTP\",\n" +
" \"issuerName\": \"TB\"\n" +
" },\n" +
" {\n" +
" \"providerType\": \"EMAIL\",\n" +
" \"verificationCodeLifetime\": 60\n" +
" },\n" +
" {\n" +
" \"providerType\": \"SMS\",\n" +
" \"verificationCodeLifetime\": 60,\n" +
" \"smsVerificationMessageTemplate\": \"Here is your verification code: ${verificationCode}\"\n" +
" }\n" +
" ],\n" +
" \"verificationCodeSendRateLimit\": \"1:60\",\n" +
" \"verificationCodeCheckRateLimit\": \"3:900\",\n" +
" \"maxVerificationFailuresBeforeUserLockout\": 10,\n" +
" \"totalAllowedTimeForVerification\": 600\n" +
"}\n```" +
ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH)
@PostMapping("/settings")
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')")

View File

@ -84,8 +84,8 @@ public class TwoFactorAuthController extends BaseController {
"and Too Many Requests error if rate limits are exceeded.")
@PostMapping("/verification/check")
@PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')")
public JwtTokenPair checkTwoFaVerificationCode(@ApiParam(value = "6-digit verification code", required = true)
@RequestParam TwoFaProviderType providerType,
public JwtTokenPair checkTwoFaVerificationCode(@RequestParam TwoFaProviderType providerType,
@ApiParam(value = "6-digit verification code", required = true)
@RequestParam String verificationCode, HttpServletRequest servletRequest) throws Exception {
SecurityUser user = getCurrentUser();
boolean verificationSuccess = twoFactorAuthService.checkVerificationCode(user, providerType, verificationCode, true);
@ -101,6 +101,13 @@ public class TwoFactorAuthController extends BaseController {
}
@ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes =
"Get the list of 2FA provider infos available for user to use. Example:\n" +
"```\n[\n" +
" {\n \"type\": \"EMAIL\",\n \"default\": true\n },\n" +
" {\n \"type\": \"TOTP\",\n \"default\": false\n },\n" +
" {\n \"type\": \"SMS\",\n \"default\": false\n }\n" +
"]\n```")
@GetMapping("/providers")
@PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')")
public List<TwoFaProviderInfo> getAvailableTwoFaProviders() throws ThingsboardException {

View File

@ -40,7 +40,6 @@ import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.settings.AdminSettingsService;
@ -311,6 +310,14 @@ public class DefaultMailService implements MailService {
sendMail(mailSender, mailFrom, email, subject, message);
}
@Override
public void sendTwoFaVerificationEmail(String email, String verificationCode) throws ThingsboardException { // TODO [viacheslav]: mail template
String subject = "ThingsBoard two-factor authentication";
String message = "Your 2FA verification code: " + verificationCode;
sendMail(mailSender, mailFrom, email, subject, message);
}
@Override
public void sendApiFeatureStateEmail(ApiFeature apiFeature, ApiUsageStateValue stateValue, String email, ApiUsageStateMailMessage msg) throws ThingsboardException {
String subject = messages.getMessage("api.usage.state", null, Locale.US);

View File

@ -68,6 +68,20 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
});
}
@Override
public AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings) {
UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId))
.orElseGet(() -> {
UserAuthSettings newUserAuthSettings = new UserAuthSettings();
newUserAuthSettings.setUserId(userId);
return newUserAuthSettings;
});
userAuthSettings.setTwoFaSettings(settings);
userAuthSettingsDao.save(tenantId, userAuthSettings);
return settings;
}
@Override
public Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) {
return getAccountTwoFaSettings(tenantId, userId)
@ -80,33 +94,21 @@ public class DefaultTwoFaConfigManager implements TwoFaConfigManager {
getTwoFaProviderConfig(tenantId, accountConfig.getProviderType())
.orElseThrow(() -> new IllegalArgumentException("2FA provider is not configured"));
return createOrUpdateAccountTwoFaSettings(tenantId, userId, accountTwoFaSettings -> {
Map<TwoFaProviderType, TwoFaAccountConfig> configs = accountTwoFaSettings.getConfigs();
configs.put(accountConfig.getProviderType(), accountConfig);
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId).orElseGet(() -> {
AccountTwoFaSettings newSettings = new AccountTwoFaSettings();
newSettings.setConfigs(new LinkedHashMap<>());
return newSettings;
});
settings.getConfigs().put(accountConfig.getProviderType(), accountConfig);
return saveAccountTwoFaSettings(tenantId, userId, settings);
}
@Override
public AccountTwoFaSettings deleteTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType) {
return createOrUpdateAccountTwoFaSettings(tenantId, userId, accountTwoFaSettings -> {
accountTwoFaSettings.getConfigs().keySet().removeIf(providerType::equals);
});
}
private AccountTwoFaSettings createOrUpdateAccountTwoFaSettings(TenantId tenantId, UserId userId, Consumer<AccountTwoFaSettings> updater) {
UserAuthSettings userAuthSettings = Optional.ofNullable(userAuthSettingsDao.findByUserId(userId))
.orElseGet(() -> {
UserAuthSettings newUserAuthSettings = new UserAuthSettings();
newUserAuthSettings.setUserId(userId);
AccountTwoFaSettings newAccountTwoFaSettings = new AccountTwoFaSettings();
newAccountTwoFaSettings.setConfigs(new LinkedHashMap<>());
newUserAuthSettings.setTwoFaSettings(newAccountTwoFaSettings);
return newUserAuthSettings;
});
updater.accept(userAuthSettings.getTwoFaSettings());
userAuthSettingsDao.save(tenantId, userAuthSettings);
return userAuthSettings.getTwoFaSettings();
AccountTwoFaSettings settings = getAccountTwoFaSettings(tenantId, userId)
.orElseThrow(() -> new IllegalArgumentException("2FA not configured"));
settings.getConfigs().remove(providerType);
return saveAccountTwoFaSettings(tenantId, userId, settings);
}

View File

@ -26,9 +26,10 @@ import java.util.Optional;
public interface TwoFaConfigManager {
Optional<AccountTwoFaSettings> getAccountTwoFaSettings(TenantId tenantId, UserId userId);
AccountTwoFaSettings saveAccountTwoFaSettings(TenantId tenantId, UserId userId, AccountTwoFaSettings settings);
Optional<TwoFaAccountConfig> getTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaProviderType providerType);
AccountTwoFaSettings saveTwoFaAccountConfig(TenantId tenantId, UserId userId, TwoFaAccountConfig accountConfig);

View File

@ -26,9 +26,9 @@ public interface TwoFaProvider<C extends TwoFaProviderConfig, A extends TwoFaAcc
A generateNewAccountConfig(User user, C providerConfig);
default void prepareVerificationCode(SecurityUser securityUser, C providerConfig, A accountConfig) throws ThingsboardException {}
default void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) throws ThingsboardException {}
boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, C providerConfig, A accountConfig);
boolean checkVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig);
TwoFaProviderType getType();

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.service.security.auth.mfa.provider.impl;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.security.model.mfa.account.EmailTwoFaAccountConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.EmailTwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.security.model.SecurityUser;
@Service
@TbCoreComponent
public class EmailTwoFaProvider extends OtpBasedTwoFaProvider<EmailTwoFaProviderConfig, EmailTwoFaAccountConfig> {
private final MailService mailService;
protected EmailTwoFaProvider(CacheManager cacheManager, MailService mailService) {
super(cacheManager);
this.mailService = mailService;
}
@Override
public EmailTwoFaAccountConfig generateNewAccountConfig(User user, EmailTwoFaProviderConfig providerConfig) {
EmailTwoFaAccountConfig config = new EmailTwoFaAccountConfig();
config.setEmail(user.getEmail());
return config;
}
@Override
protected void sendVerificationCode(SecurityUser user, String verificationCode, EmailTwoFaProviderConfig providerConfig, EmailTwoFaAccountConfig accountConfig) throws ThingsboardException {
mailService.sendTwoFaVerificationEmail(accountConfig.getEmail(), verificationCode);
}
@Override
public TwoFaProviderType getType() {
return TwoFaProviderType.EMAIL;
}
}

View File

@ -38,27 +38,27 @@ public abstract class OtpBasedTwoFaProvider<C extends OtpBasedTwoFaProviderConfi
@Override
public final void prepareVerificationCode(SecurityUser securityUser, C providerConfig, A accountConfig) throws ThingsboardException {
public final void prepareVerificationCode(SecurityUser user, C providerConfig, A accountConfig) throws ThingsboardException {
String verificationCode = RandomStringUtils.randomNumeric(6);
verificationCodesCache.put(securityUser.getId(), new Otp(System.currentTimeMillis(), verificationCode, accountConfig));
sendVerificationCode(securityUser, verificationCode, providerConfig, accountConfig);
sendVerificationCode(user, verificationCode, providerConfig, accountConfig);
verificationCodesCache.put(user.getId(), new Otp(System.currentTimeMillis(), verificationCode, accountConfig));
}
protected abstract void sendVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig) throws ThingsboardException;
@Override
public final boolean checkVerificationCode(SecurityUser securityUser, String verificationCode, C providerConfig, A accountConfig) {
Otp correctVerificationCode = verificationCodesCache.get(securityUser.getId(), Otp.class);
public final boolean checkVerificationCode(SecurityUser user, String verificationCode, C providerConfig, A accountConfig) {
Otp correctVerificationCode = verificationCodesCache.get(user.getId(), Otp.class);
if (correctVerificationCode != null) {
if (System.currentTimeMillis() - correctVerificationCode.getTimestamp()
> TimeUnit.SECONDS.toMillis(providerConfig.getVerificationCodeLifetime())) {
verificationCodesCache.evict(securityUser.getId());
verificationCodesCache.evict(user.getId());
return false;
}
if (verificationCode.equals(correctVerificationCode.getValue())
&& accountConfig.equals(correctVerificationCode.getAccountConfig())) {
verificationCodesCache.evict(securityUser.getId());
verificationCodesCache.evict(user.getId());
return true;
}
}

View File

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

View File

@ -15,11 +15,9 @@
*/
package org.thingsboard.server.common.data.security.model.mfa;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderConfig;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
import javax.validation.Valid;
import javax.validation.constraints.Min;
@ -28,29 +26,18 @@ import java.util.List;
import java.util.Optional;
@Data
@ApiModel
public class PlatformTwoFaSettings {
@ApiModelProperty(value = "Option for tenant admins to use 2FA settings configured by sysadmin. " +
"If this param is set to true, then the settings will not be validated for constraints " +
"(if it is a tenant admin; for sysadmin this param is ignored)")
private boolean useSystemTwoFactorAuthSettings;
@ApiModelProperty(value = "The list of 2FA providers' configs. Users will only be allowed to use 2FA providers from this list.")
@Valid
private List<TwoFaProviderConfig> providers;
@ApiModelProperty(value = "Rate limit configuration for verification code sending. The format is standard: 'amountOfRequests:periodInSeconds'. " +
"The value of '1:60' would limit verification code sending requests to one per minute.", example = "1:60", required = false)
@Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code send rate limit configuration is invalid")
private String verificationCodeSendRateLimit;
@ApiModelProperty(value = "Rate limit configuration for verification code checking.", example = "3:900", required = false)
@Pattern(regexp = "[1-9]\\d*:[1-9]\\d*", message = "verification code check rate limit configuration is invalid")
private String verificationCodeCheckRateLimit;
@ApiModelProperty(value = "Maximum number of verification failures before a user gets disabled.", example = "10", required = false)
@Min(value = 0, message = "maximum number of verification failure before user lockout must be positive")
private int maxVerificationFailuresBeforeUserLockout;
@ApiModelProperty(value = "Total amount of time in seconds allotted for verification. " +
"Basically, this property sets a lifetime for pre-verification token. If not set, default value of 30 minutes is used.", example = "3600", required = false)
@Min(value = 1, message = "total amount of time allotted for verification must be greater than 0")
private Integer totalAllowedTimeForVerification;

View File

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

View File

@ -15,8 +15,6 @@
*/
package org.thingsboard.server.common.data.security.model.mfa.account;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
@ -24,12 +22,10 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
@ApiModel
@EqualsAndHashCode(callSuper = true)
@Data
public class SmsTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig {
@ApiModelProperty(value = "Phone number to use for 2FA. Must no be blank and must be of E.164 number format.", required = true)
@NotBlank(message = "phone number cannot be blank")
@Pattern(regexp = "^\\+[1-9]\\d{1,14}$", message = "phone number is not of E.164 format")
private String phoneNumber;

View File

@ -15,8 +15,6 @@
*/
package org.thingsboard.server.common.data.security.model.mfa.account;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType;
@ -24,13 +22,10 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
@ApiModel // FIXME [viacheslav]
@Data
@EqualsAndHashCode(callSuper = true)
public class TotpTwoFaAccountConfig extends TwoFaAccountConfig {
@ApiModelProperty(value = "OTP auth URL used to generate a QR code to scan with an authenticator app. Must not be blank and must follow specific pattern.",
example = "otpauth://totp/ThingsBoard:tenant@thingsboard.org?issuer=ThingsBoard&secret=FUNBIM3CXFNNGQR6ZIPVWHP65PPFWDII", required = true)
@NotBlank(message = "OTP auth URL cannot be blank")
@Pattern(regexp = "otpauth://totp/(\\S+?):(\\S+?)\\?issuer=(\\S+?)&secret=(\\w+?)", message = "OTP auth url is invalid")
private String authUrl;

View File

@ -29,7 +29,8 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi
property = "providerType")
@JsonSubTypes({
@Type(name = "TOTP", value = TotpTwoFaAccountConfig.class),
@Type(name = "SMS", value = SmsTwoFaAccountConfig.class)
@Type(name = "SMS", value = SmsTwoFaAccountConfig.class),
@Type(name = "EMAIL", value = EmailTwoFaAccountConfig.class)
})
@Data
public abstract class TwoFaAccountConfig {

View File

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

View File

@ -15,15 +15,14 @@
*/
package org.thingsboard.server.common.data.security.model.mfa.provider;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.Min;
@Data
public abstract class OtpBasedTwoFaProviderConfig implements TwoFaProviderConfig {
@ApiModelProperty(value = "Verification code lifetime in seconds. Verification codes with a lifetime bigger than this param " +
"will be considered incorrect", example = "60", required = true)
@Min(value = 1, message = "verification code lifetime is required")
private int verificationCodeLifetime;
}

View File

@ -15,22 +15,16 @@
*/
package org.thingsboard.server.common.data.security.model.mfa.provider;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
@ApiModel(parent = OtpBasedTwoFaProviderConfig.class)
@EqualsAndHashCode(callSuper = true)
@Data
public class SmsTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig {
@ApiModelProperty(value = "SMS verification message template. Available template variables are ${verificationCode} and ${userEmail}. " +
"It must not be blank and must contain verification code variable.",
example = "Here is your verification code: ${verificationCode}", required = true)
@NotBlank(message = "verification message template is required")
@Pattern(regexp = ".*\\$\\{verificationCode}.*", message = "template must contain verification code")
private String smsVerificationMessageTemplate;

View File

@ -15,18 +15,13 @@
*/
package org.thingsboard.server.common.data.security.model.mfa.provider;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
@ApiModel
@Data
public class TotpTwoFaProviderConfig implements TwoFaProviderConfig {
@ApiModelProperty(value = "Issuer name that will be displayed in an authenticator app near a username. " +
"Must not be blank.", example = "ThingsBoard", required = true)
@NotBlank(message = "issuer name must not be blank")
private String issuerName;

View File

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

View File

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

View File

@ -24,8 +24,6 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
import java.util.Map;
public interface MailService {
void updateMailConfiguration();
@ -46,6 +44,8 @@ public interface MailService {
void sendAccountLockoutEmail(String lockoutEmail, String email, Integer maxFailedLoginAttempts) throws ThingsboardException;
void sendTwoFaVerificationEmail(String email, String verificationCode) throws ThingsboardException;
void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail) throws ThingsboardException;
void send(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender) throws ThingsboardException;