fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验 - 优化设备相关查询,避免重复访问数据源 - 补充并记录并发测试与审查修复实施计划
This commit is contained in:
12
.idea/compiler.xml
generated
12
.idea/compiler.xml
generated
@@ -25,6 +25,18 @@
|
|||||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
||||||
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
||||||
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc-scribe/0.15.0/therapi-runtime-javadoc-scribe-0.15.0.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/com/github/therapi/therapi-runtime-javadoc/0.15.0/therapi-runtime-javadoc-0.15.0.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/org/springframework/boot/spring-boot-configuration-processor/3.5.12/spring-boot-configuration-processor-3.5.12.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-processor/1.5.0/mapstruct-plus-processor-1.5.0.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus/1.5.0/mapstruct-plus-1.5.0.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct/1.6.3/mapstruct-1.6.3.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/io/github/linpeilie/mapstruct-plus-object-convert/1.5.0/mapstruct-plus-object-convert-1.5.0.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/cn/easii/tutelary-repackage-javapoet/1.0.5/tutelary-repackage-javapoet-1.0.5.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/org/mapstruct/tools/gem/gem-api/1.0.0.Alpha3/gem-api-1.0.0.Alpha3.jar" />
|
||||||
|
<entry name="$PROJECT_DIR$/../../repository/org/projectlombok/lombok-mapstruct-binding/0.2.0/lombok-mapstruct-binding-0.2.0.jar" />
|
||||||
</processorPath>
|
</processorPath>
|
||||||
<module name="water-app" />
|
<module name="water-app" />
|
||||||
<module name="water-common-excel" />
|
<module name="water-common-excel" />
|
||||||
|
|||||||
@@ -67,26 +67,32 @@ public class CaptchaController {
|
|||||||
@RateLimiter(key = "#username", time = 60, count = 1)
|
@RateLimiter(key = "#username", time = 60, count = 1)
|
||||||
@GetMapping("/resource/code")
|
@GetMapping("/resource/code")
|
||||||
public R<Void> code(@NotBlank(message = "{user.username.not.blank}") String username) {
|
public R<Void> code(@NotBlank(message = "{user.username.not.blank}") String username) {
|
||||||
String key = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
if (Validator.isMobile(username)) {
|
||||||
String code = RandomUtil.randomNumbers(6);
|
return sendSmsCode(username, username);
|
||||||
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
|
}
|
||||||
|
if (Validator.isEmail(username)) {
|
||||||
|
return sendEmailCode(username, username);
|
||||||
|
}
|
||||||
|
|
||||||
if (Validator.isMobile(username)){
|
SysUserVo user = userService.selectUserByUserName(username);
|
||||||
// 验证码模板id 自行处理 (查数据库或写死均可)
|
if (user == null) {
|
||||||
String templateId = "SMS_333877107";
|
return R.fail("账号未注册");
|
||||||
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
|
}
|
||||||
map.put("code", code);
|
if (Validator.isMobile(user.getPhonenumber())) {
|
||||||
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
|
return sendSmsCode(user.getPhonenumber(), username);
|
||||||
SmsResponse smsResponse = smsBlend.sendMessage(username, templateId, map);
|
}
|
||||||
if (!smsResponse.isSuccess()) {
|
if (Validator.isEmail(user.getEmail())) {
|
||||||
log.error("验证码短信发送异常 => {}", smsResponse);
|
return sendEmailCode(user.getEmail(), username);
|
||||||
return R.fail(smsResponse.getData().toString());
|
}
|
||||||
}
|
return R.fail("当前账号未绑定手机号或邮箱");
|
||||||
}else {
|
}
|
||||||
emailCodeImpl(username);
|
|
||||||
}
|
|
||||||
|
|
||||||
return R.ok(code);
|
private R<Void> sendEmailCode(String email, String cacheKey) {
|
||||||
|
if (!mailProperties.getEnabled()) {
|
||||||
|
return R.fail("当前系统没有开启邮箱功能!");
|
||||||
|
}
|
||||||
|
emailCodeImpl(email, cacheKey);
|
||||||
|
return R.ok("操作成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
package org.dromara.web.service;
|
package org.dromara.web.service;
|
||||||
|
|
||||||
import cn.hutool.core.lang.Validator;
|
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
|
||||||
import cn.hutool.crypto.digest.BCrypt;
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.dromara.common.core.constant.GlobalConstants;
|
|
||||||
import org.dromara.common.core.domain.model.ForgotLoginBody;
|
import org.dromara.common.core.domain.model.ForgotLoginBody;
|
||||||
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
|
||||||
import org.dromara.common.core.exception.user.UserException;
|
|
||||||
import org.dromara.common.core.utils.MapstructUtils;
|
|
||||||
import org.dromara.common.core.utils.StringUtils;
|
import org.dromara.common.core.utils.StringUtils;
|
||||||
import org.dromara.common.redis.utils.RedisUtils;
|
import org.dromara.system.service.ISysUserService;
|
||||||
import org.dromara.system.domain.SysUser;
|
|
||||||
import org.dromara.system.domain.vo.SysUserVo;
|
|
||||||
import org.dromara.system.mapper.SysUserMapper;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
|
||||||
@@ -22,41 +11,14 @@ import org.springframework.stereotype.Service;
|
|||||||
@Service
|
@Service
|
||||||
public class ForgotPasswordService {
|
public class ForgotPasswordService {
|
||||||
|
|
||||||
private final SysUserMapper userMapper;
|
private final ISysUserService userService;
|
||||||
|
|
||||||
|
|
||||||
public boolean forgotPasswordService(ForgotLoginBody loginBody) {
|
public boolean forgotPasswordService(ForgotLoginBody loginBody) {
|
||||||
SysUserVo appUserVo =new SysUserVo();
|
String verificationCode = StringUtils.isNotBlank(loginBody.getSmsCode())
|
||||||
if(Validator.isEmail(loginBody.getUsername())){
|
? loginBody.getSmsCode()
|
||||||
userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, loginBody.getUsername()));
|
: loginBody.getCode();
|
||||||
}else if (Validator.isMobile(loginBody.getUsername())){
|
return userService.resetPasswordByVerificationCode(
|
||||||
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhonenumber, loginBody.getUsername()));
|
loginBody.getUsername(), verificationCode, loginBody.getPassword());
|
||||||
}else {
|
|
||||||
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUserName, loginBody.getUsername()));
|
|
||||||
}
|
|
||||||
if (ObjectUtil.isNull(appUserVo)){
|
|
||||||
throw new UserException("账号未注册");
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean validateFlag = validateSmsCode(loginBody.getUsername(), loginBody.getSmsCode());
|
|
||||||
if (!validateFlag){
|
|
||||||
throw new UserException("验证码无效");
|
|
||||||
}
|
|
||||||
|
|
||||||
appUserVo.setPassword(BCrypt.hashpw(loginBody.getPassword()));
|
|
||||||
SysUser update = MapstructUtils.convert(appUserVo, SysUser.class);
|
|
||||||
userMapper.updateById(update);
|
|
||||||
return userMapper.updateById(update) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验短信验证码
|
|
||||||
*/
|
|
||||||
private boolean validateSmsCode(String username, String smsCode) {
|
|
||||||
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + username);
|
|
||||||
if (StringUtils.isBlank(code)) {
|
|
||||||
throw new CaptchaExpireException();
|
|
||||||
}
|
|
||||||
return code.equals(smsCode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ security:
|
|||||||
- /*/api-docs
|
- /*/api-docs
|
||||||
- /*/api-docs/**
|
- /*/api-docs/**
|
||||||
- /warm-flow-ui/config
|
- /warm-flow-ui/config
|
||||||
|
- /app/v1/retrievePassword
|
||||||
|
|
||||||
# 多租户配置
|
# 多租户配置
|
||||||
tenant:
|
tenant:
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package org.dromara.web.config;
|
||||||
|
|
||||||
|
import org.dromara.common.security.config.SecurityConfig;
|
||||||
|
import org.dromara.common.security.config.properties.SecurityProperties;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.context.properties.bind.Bindable;
|
||||||
|
import org.springframework.boot.context.properties.bind.Binder;
|
||||||
|
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||||
|
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||||
|
import org.springframework.core.env.MutablePropertySources;
|
||||||
|
import org.springframework.core.env.PropertySource;
|
||||||
|
import org.springframework.core.io.ClassPathResource;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.handler.MappedInterceptor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@Tag("dev")
|
||||||
|
class SecurityExcludesConfigUnitTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retrievePassword_isExcludedFromLoginAuthentication() throws Exception {
|
||||||
|
List<PropertySource<?>> sources = new YamlPropertySourceLoader()
|
||||||
|
.load("application", new ClassPathResource("application.yml"));
|
||||||
|
MutablePropertySources propertySources = new MutablePropertySources();
|
||||||
|
for (PropertySource<?> source : sources) {
|
||||||
|
propertySources.addLast(source);
|
||||||
|
}
|
||||||
|
SecurityProperties properties = new Binder(ConfigurationPropertySources.from(propertySources))
|
||||||
|
.bind("security", Bindable.of(SecurityProperties.class))
|
||||||
|
.orElseThrow(() -> new AssertionError("security 配置未绑定"));
|
||||||
|
|
||||||
|
assertThat(properties.getExcludes()).contains("/app/v1/retrievePassword");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void securityInterceptor_alwaysExcludesRetrievePasswordWithoutExternalConfiguration() {
|
||||||
|
SecurityProperties properties = new SecurityProperties();
|
||||||
|
properties.setExcludes(new String[0]);
|
||||||
|
SecurityConfig config = new SecurityConfig(properties);
|
||||||
|
ReflectionTestUtils.setField(config, "ssePath", "/resource/sse");
|
||||||
|
ExposedInterceptorRegistry registry = new ExposedInterceptorRegistry();
|
||||||
|
|
||||||
|
config.addInterceptors(registry);
|
||||||
|
|
||||||
|
MappedInterceptor interceptor = (MappedInterceptor) registry.getRegisteredInterceptors().get(0);
|
||||||
|
assertThat(interceptor.getExcludePathPatterns()).contains("/app/v1/retrievePassword");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ExposedInterceptorRegistry extends InterceptorRegistry {
|
||||||
|
|
||||||
|
private List<Object> getRegisteredInterceptors() {
|
||||||
|
return super.getInterceptors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,52 @@ class CaptchaControllerUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private ISysUserService userService;
|
private ISysUserService userService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void code_resolvesAccountPhoneAndUsesAccountAsCacheKey() {
|
||||||
|
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||||
|
SysUserVo user = new SysUserVo();
|
||||||
|
user.setPhonenumber("13305376054");
|
||||||
|
when(userService.selectUserByUserName("alice")).thenReturn(user);
|
||||||
|
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "alice");
|
||||||
|
|
||||||
|
R<Void> result = controller.code("alice");
|
||||||
|
|
||||||
|
assertThat(result.getCode()).isEqualTo(200);
|
||||||
|
assertThat(result.getData()).isNull();
|
||||||
|
verify(userService).selectUserByUserName("alice");
|
||||||
|
verify(controller).sendSmsCode("13305376054", "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void code_resolvesAccountEmailAndUsesAccountAsCacheKey() {
|
||||||
|
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||||
|
SysUserVo user = new SysUserVo();
|
||||||
|
user.setEmail("alice@example.com");
|
||||||
|
when(userService.selectUserByUserName("alice")).thenReturn(user);
|
||||||
|
when(mailProperties.getEnabled()).thenReturn(true);
|
||||||
|
doNothing().when(controller).emailCodeImpl("alice@example.com", "alice");
|
||||||
|
|
||||||
|
R<Void> result = controller.code("alice");
|
||||||
|
|
||||||
|
assertThat(result.getCode()).isEqualTo(200);
|
||||||
|
assertThat(result.getData()).isNull();
|
||||||
|
verify(userService).selectUserByUserName("alice");
|
||||||
|
verify(controller).emailCodeImpl("alice@example.com", "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void code_keepsDirectMobileBehaviorWithoutReturningPlainCode() {
|
||||||
|
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||||
|
doReturn(R.ok("操作成功")).when(controller).sendSmsCode("13305376054", "13305376054");
|
||||||
|
|
||||||
|
R<Void> result = controller.code("13305376054");
|
||||||
|
|
||||||
|
assertThat(result.getCode()).isEqualTo(200);
|
||||||
|
assertThat(result.getData()).isNull();
|
||||||
|
verify(controller).sendSmsCode("13305376054", "13305376054");
|
||||||
|
verifyNoInteractions(userService);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
|
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
|
||||||
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
CaptchaController controller = spy(new CaptchaController(null, mailProperties, userService));
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package org.dromara.web.service;
|
||||||
|
|
||||||
|
import org.dromara.common.core.domain.model.ForgotLoginBody;
|
||||||
|
import org.dromara.system.service.ISysUserService;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@Tag("dev")
|
||||||
|
class ForgotPasswordServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ISysUserService userService;
|
||||||
|
@InjectMocks
|
||||||
|
private ForgotPasswordService forgotPasswordService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void forgotPasswordService_delegatesVerificationAndResetToUserService() {
|
||||||
|
ForgotLoginBody body = new ForgotLoginBody();
|
||||||
|
body.setUsername("alice");
|
||||||
|
body.setSmsCode("123456");
|
||||||
|
body.setPassword("newPassword");
|
||||||
|
when(userService.resetPasswordByVerificationCode("alice", "123456", "newPassword"))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
boolean result = forgotPasswordService.forgotPasswordService(body);
|
||||||
|
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
verify(userService).resetPasswordByVerificationCode("alice", "123456", "newPassword");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,14 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler {
|
|||||||
log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
|
log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (Boolean.FALSE.equals(command.getRetryEnabled())) {
|
||||||
|
command.setRetryCount(command.getRetryCount() + 1);
|
||||||
|
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
|
||||||
|
savePending(command);
|
||||||
|
log.debug("[MQTT] 命令已关闭自动重发,等待 ACK 或到期清理 设备编号={} 命令编号={} 等待次数={}",
|
||||||
|
command.getDeviceNo(), commandId, command.getRetryCount());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!isDeviceOnline(command.getDeviceNo())) {
|
if (!isDeviceOnline(command.getDeviceNo())) {
|
||||||
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
|
command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs());
|
||||||
savePending(command);
|
savePending(command);
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ import java.util.LinkedHashSet;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -251,6 +251,127 @@ class MqttCommandAckServiceTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryExpiredCommandsAdvancesRetryDisabledCommandsWithoutPublishing() throws Exception {
|
||||||
|
MqttProperties properties = new MqttProperties();
|
||||||
|
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||||
|
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||||
|
RLock commandLock = mock(RLock.class);
|
||||||
|
RSet<String> pendingIds = mock(RSet.class);
|
||||||
|
MqttClientManager mqttClientManager = mock(MqttClientManager.class);
|
||||||
|
|
||||||
|
DeviceCommand pending = new DeviceCommand();
|
||||||
|
pending.setCommandId("cmd-1");
|
||||||
|
pending.setDeviceNo("D01");
|
||||||
|
pending.setTopic("/aa:bb:cc/subscriber/cmd");
|
||||||
|
pending.setRetryEnabled(false);
|
||||||
|
pending.setNextRetryAt(0);
|
||||||
|
|
||||||
|
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||||
|
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1"));
|
||||||
|
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||||
|
when(commandLock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||||
|
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||||
|
|
||||||
|
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||||
|
ReflectionTestUtils.setField(service, "mqttClientManager", mqttClientManager);
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||||
|
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||||
|
|
||||||
|
service.retryExpiredCommands();
|
||||||
|
|
||||||
|
verify(mqttClientManager, never()).publish(any(String.class), any(String.class));
|
||||||
|
assertThat(pending.getRetryCount()).isEqualTo(1);
|
||||||
|
assertThat(pending.getNextRetryAt()).isGreaterThan(0);
|
||||||
|
redis.verify(() -> RedisUtils.setCacheObject(
|
||||||
|
eq("mqtt:command:pending:cmd-1"),
|
||||||
|
eq(pending),
|
||||||
|
eq(Duration.ofSeconds(86400))
|
||||||
|
));
|
||||||
|
verify(pendingIds).add("cmd-1");
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject(any(String.class)), never());
|
||||||
|
verify(commandLock).unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryExpiredCommandsDeletesRetryDisabledCommandsAtRetryLimit() throws Exception {
|
||||||
|
MqttProperties properties = new MqttProperties();
|
||||||
|
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||||
|
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||||
|
RLock commandLock = mock(RLock.class);
|
||||||
|
RSet<String> pendingIds = mock(RSet.class);
|
||||||
|
MqttClientManager mqttClientManager = mock(MqttClientManager.class);
|
||||||
|
|
||||||
|
DeviceCommand pending = new DeviceCommand();
|
||||||
|
pending.setCommandId("cmd-1");
|
||||||
|
pending.setDeviceNo("D01");
|
||||||
|
pending.setTopic("/aa:bb:cc/subscriber/cmd");
|
||||||
|
pending.setRetryEnabled(false);
|
||||||
|
pending.setRetryCount(properties.getCommandAck().getMaxRetryCount());
|
||||||
|
pending.setNextRetryAt(0);
|
||||||
|
|
||||||
|
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||||
|
when(pendingIds.readAll()).thenReturn(Set.of("cmd-1"));
|
||||||
|
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||||
|
when(commandLock.tryLock(0, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||||
|
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||||
|
|
||||||
|
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||||
|
ReflectionTestUtils.setField(service, "mqttClientManager", mqttClientManager);
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||||
|
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||||
|
|
||||||
|
service.retryExpiredCommands();
|
||||||
|
|
||||||
|
verify(mqttClientManager, never()).publish(any(String.class), any(String.class));
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||||
|
verify(pendingIds).remove("cmd-1");
|
||||||
|
verify(commandLock).unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void handleAckDeletesRetryDisabledPendingCommand() throws Exception {
|
||||||
|
MqttProperties properties = new MqttProperties();
|
||||||
|
AppDeviceMapper appDeviceMapper = mock(AppDeviceMapper.class);
|
||||||
|
RedissonClient redissonClient = mock(RedissonClient.class);
|
||||||
|
RLock commandLock = mock(RLock.class);
|
||||||
|
RLock statusLock = mock(RLock.class);
|
||||||
|
RSet<String> pendingIds = mock(RSet.class);
|
||||||
|
|
||||||
|
DeviceCommand pending = new DeviceCommand();
|
||||||
|
pending.setCommandId("cmd-1");
|
||||||
|
pending.setDeviceNo("D01");
|
||||||
|
pending.setRetryEnabled(false);
|
||||||
|
|
||||||
|
when(redissonClient.getLock("lock:mqtt:command:retry:cmd-1")).thenReturn(commandLock);
|
||||||
|
when(commandLock.tryLock(3000, 30000, TimeUnit.MILLISECONDS)).thenReturn(true);
|
||||||
|
when(commandLock.isHeldByCurrentThread()).thenReturn(true);
|
||||||
|
when(redissonClient.<String>getSet("mqtt:command:pending:ids")).thenReturn(pendingIds);
|
||||||
|
when(redissonClient.getLock("lock:mqtt:device:status:D01")).thenReturn(statusLock);
|
||||||
|
when(statusLock.tryLock(0, 10, TimeUnit.SECONDS)).thenReturn(false);
|
||||||
|
|
||||||
|
MqttCommandAckService service = new MqttCommandAckService(properties, appDeviceMapper);
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(RedisUtils::getClient).thenReturn(redissonClient);
|
||||||
|
redis.when(() -> RedisUtils.getCacheObject("mqtt:command:pending:cmd-1")).thenReturn(pending);
|
||||||
|
|
||||||
|
service.handleAck("D01", "{\"commandId\":\"cmd-1\",\"status\":\"1\"}");
|
||||||
|
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject("mqtt:command:pending:cmd-1"));
|
||||||
|
verify(pendingIds).remove("cmd-1");
|
||||||
|
redis.verify(() -> RedisUtils.setCacheObject(
|
||||||
|
eq("mqtt:command:ack:cmd-1"),
|
||||||
|
any(DeviceCommandAck.class),
|
||||||
|
eq(Duration.ofSeconds(86400))
|
||||||
|
));
|
||||||
|
verify(commandLock).unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
|
void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception {
|
||||||
MqttProperties properties = new MqttProperties();
|
MqttProperties properties = new MqttProperties();
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class SecurityConfig implements WebMvcConfigurer {
|
public class SecurityConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private static final String APP_RETRIEVE_PASSWORD_PATH = "/app/v1/retrievePassword";
|
||||||
|
|
||||||
private final SecurityProperties securityProperties;
|
private final SecurityProperties securityProperties;
|
||||||
@Value("${sse.path}")
|
@Value("${sse.path}")
|
||||||
private String ssePath;
|
private String ssePath;
|
||||||
@@ -83,6 +85,7 @@ public class SecurityConfig implements WebMvcConfigurer {
|
|||||||
})).addPathPatterns("/**")
|
})).addPathPatterns("/**")
|
||||||
// 排除不需要拦截的路径
|
// 排除不需要拦截的路径
|
||||||
.excludePathPatterns(securityProperties.getExcludes())
|
.excludePathPatterns(securityProperties.getExcludes())
|
||||||
|
.excludePathPatterns(APP_RETRIEVE_PASSWORD_PATH)
|
||||||
.excludePathPatterns(ssePath);
|
.excludePathPatterns(ssePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package org.dromara.app.controller;
|
package org.dromara.app.controller;
|
||||||
|
|
||||||
|
|
||||||
|
import cn.dev33.satoken.annotation.SaIgnore;
|
||||||
import cn.hutool.core.io.FileTypeUtil;
|
import cn.hutool.core.io.FileTypeUtil;
|
||||||
import cn.hutool.core.io.FileUtil;
|
import cn.hutool.core.io.FileUtil;
|
||||||
import cn.hutool.core.lang.Validator;
|
|
||||||
import cn.hutool.core.util.ObjectUtil;
|
import cn.hutool.core.util.ObjectUtil;
|
||||||
import cn.hutool.crypto.digest.BCrypt;
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
import cn.hutool.json.JSONArray;
|
import cn.hutool.json.JSONArray;
|
||||||
@@ -13,10 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
|
|||||||
import jakarta.validation.constraints.NotNull;
|
import jakarta.validation.constraints.NotNull;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
import org.dromara.app.domain.bo.*;
|
||||||
import org.dromara.app.domain.bo.AppScheduleBo;
|
|
||||||
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
|
|
||||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
|
||||||
import org.dromara.app.domain.vo.*;
|
import org.dromara.app.domain.vo.*;
|
||||||
import org.dromara.app.service.*;
|
import org.dromara.app.service.*;
|
||||||
import org.dromara.common.core.domain.R;
|
import org.dromara.common.core.domain.R;
|
||||||
@@ -732,25 +729,12 @@ public class AppController extends BaseController {
|
|||||||
/**
|
/**
|
||||||
* app用户手机号/邮箱找回忘记密码
|
* app用户手机号/邮箱找回忘记密码
|
||||||
*/
|
*/
|
||||||
|
@SaIgnore
|
||||||
@ApiEncrypt
|
@ApiEncrypt
|
||||||
@PostMapping("/retrievePassword")
|
@PostMapping("/retrievePassword")
|
||||||
public R<Void> forgot(@RequestBody String body) {
|
public R<Void> forgot(@Validated @RequestBody AppForgotPasswordBo request) {
|
||||||
SysUserBo bo = JsonUtils.parseObject(body, SysUserBo.class);
|
return toAjax(userService.resetPasswordByVerificationCode(
|
||||||
bo.setUserId(LoginHelper.getUserId());
|
request.getUsername(), request.getCode(), request.getPassword()));
|
||||||
|
|
||||||
if(Validator.isEmail(bo.getUserName())){
|
|
||||||
boolean checkPhoneFlag = userService.checkEmailUnique(bo);
|
|
||||||
if (!checkPhoneFlag){
|
|
||||||
throw new UserException("user.email.not.username");
|
|
||||||
}
|
|
||||||
}else if (Validator.isMobile(bo.getUserName())){
|
|
||||||
boolean checkPhoneFlag = userService.checkPhoneUnique(bo);
|
|
||||||
if (!checkPhoneFlag){
|
|
||||||
throw new UserException("user.mobile.phone.number.not.username");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return toAjax(userService.updateUserPas(bo));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private String formatSecondTime(Date time) {
|
private String formatSecondTime(Date time) {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package org.dromara.app.domain.bo;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* APP 忘记密码请求。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AppForgotPasswordBo {
|
||||||
|
|
||||||
|
@JsonAlias("userName")
|
||||||
|
@NotBlank(message = "账号不能为空")
|
||||||
|
@Length(min = 2, max = 64, message = "账号长度必须在2到64个字符之间")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@JsonAlias("smsCode")
|
||||||
|
@NotBlank(message = "验证码不能为空")
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@NotBlank(message = "新密码不能为空")
|
||||||
|
@Length(min = 5, max = 30, message = "密码长度必须在5到30个字符之间")
|
||||||
|
private String password;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public class DeviceCommand implements Serializable {
|
|||||||
private String commandType;
|
private String commandType;
|
||||||
private String topic;
|
private String topic;
|
||||||
private Map<String, Object> payload = new HashMap<>();
|
private Map<String, Object> payload = new HashMap<>();
|
||||||
|
private Boolean retryEnabled = Boolean.TRUE;
|
||||||
private int retryCount;
|
private int retryCount;
|
||||||
private long createdAt;
|
private long createdAt;
|
||||||
private long lastSentAt;
|
private long lastSentAt;
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
|
|||||||
command.setDeviceMac(deviceMac);
|
command.setDeviceMac(deviceMac);
|
||||||
command.setCommandType("registerDeviceNo");
|
command.setCommandType("registerDeviceNo");
|
||||||
command.setTopic("/" + deviceMac.trim().toLowerCase(Locale.ROOT) + "/subscriber/cmd");
|
command.setTopic("/" + deviceMac.trim().toLowerCase(Locale.ROOT) + "/subscriber/cmd");
|
||||||
|
command.setRetryEnabled(false);
|
||||||
command.getPayload().put("deviceNo", deviceNo);
|
command.getPayload().put("deviceNo", deviceNo);
|
||||||
command.getPayload().put("deviceMac", deviceMac);
|
command.getPayload().put("deviceMac", deviceMac);
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ package org.dromara.app.controller;
|
|||||||
import cn.hutool.extra.spring.SpringUtil;
|
import cn.hutool.extra.spring.SpringUtil;
|
||||||
import cn.hutool.json.JSONObject;
|
import cn.hutool.json.JSONObject;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.dromara.app.domain.bo.AppDeviceBo;
|
import org.dromara.app.domain.bo.*;
|
||||||
import org.dromara.app.domain.bo.AppScheduleBo;
|
|
||||||
import org.dromara.app.domain.bo.AppSchedulingDeviceBo;
|
|
||||||
import org.dromara.app.domain.bo.AppWateringLogBo;
|
|
||||||
import org.dromara.app.domain.vo.*;
|
import org.dromara.app.domain.vo.*;
|
||||||
import org.dromara.app.service.*;
|
import org.dromara.app.service.*;
|
||||||
import org.dromara.common.core.domain.R;
|
import org.dromara.common.core.domain.R;
|
||||||
@@ -129,6 +126,41 @@ public class AppControllerTest {
|
|||||||
.hasMessage("版本配置不存在");
|
.hasMessage("版本配置不存在");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retrievePassword_resetsPasswordWithoutReadingLoginState() {
|
||||||
|
AppController controller = newController();
|
||||||
|
AppForgotPasswordBo request = new AppForgotPasswordBo();
|
||||||
|
request.setUsername("alice@example.com");
|
||||||
|
request.setCode("123456");
|
||||||
|
request.setPassword("newPassword");
|
||||||
|
when(userService.resetPasswordByVerificationCode("alice@example.com", "123456", "newPassword"))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
try (MockedStatic<LoginHelper> loginHelper = mockStatic(LoginHelper.class)) {
|
||||||
|
R<Void> result = controller.forgot(request);
|
||||||
|
|
||||||
|
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
|
||||||
|
loginHelper.verifyNoInteractions();
|
||||||
|
}
|
||||||
|
verify(userService).resetPasswordByVerificationCode(
|
||||||
|
"alice@example.com", "123456", "newPassword");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void retrievePassword_acceptsLegacyRequestFieldAliases() throws Exception {
|
||||||
|
AppController controller = newController();
|
||||||
|
AppForgotPasswordBo request = new ObjectMapper().readValue(
|
||||||
|
"{\"userName\":\"13305376054\",\"smsCode\":\"654321\",\"password\":\"newPassword\"}",
|
||||||
|
AppForgotPasswordBo.class);
|
||||||
|
when(userService.resetPasswordByVerificationCode("13305376054", "654321", "newPassword"))
|
||||||
|
.thenReturn(true);
|
||||||
|
|
||||||
|
R<Void> result = controller.forgot(request);
|
||||||
|
|
||||||
|
assertThat(result.getCode()).as(result.getMsg()).isEqualTo(200);
|
||||||
|
verify(userService).resetPasswordByVerificationCode("13305376054", "654321", "newPassword");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
public void uploadImage_uploadsToOssAndReturnsBackendVisibleInfo() {
|
public void uploadImage_uploadsToOssAndReturnsBackendVisibleInfo() {
|
||||||
AppController controller = newController();
|
AppController controller = newController();
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class DeviceRegisterHandlerTest {
|
|||||||
assertThat(captor.getValue().getTopic()).isEqualTo("/aa:bb:cc/subscriber/cmd");
|
assertThat(captor.getValue().getTopic()).isEqualTo("/aa:bb:cc/subscriber/cmd");
|
||||||
assertThat(captor.getValue().getCommandType()).isEqualTo("registerDeviceNo");
|
assertThat(captor.getValue().getCommandType()).isEqualTo("registerDeviceNo");
|
||||||
assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
|
assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
|
||||||
|
assertThat(captor.getValue().getRetryEnabled()).isFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -100,6 +100,12 @@
|
|||||||
<artifactId>water-common-sse</artifactId>
|
<artifactId>water-common-sse</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -232,4 +232,14 @@ public interface ISysUserService {
|
|||||||
int updateAppUser(SysUserBo user);
|
int updateAppUser(SysUserBo user);
|
||||||
|
|
||||||
int updateUserPas(SysUserBo userBo);
|
int updateUserPas(SysUserBo userBo);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通过账号标识和验证码重置密码。
|
||||||
|
*
|
||||||
|
* @param username 手机号、邮箱或账号名
|
||||||
|
* @param code 验证码
|
||||||
|
* @param password 新密码明文
|
||||||
|
* @return 是否重置成功
|
||||||
|
*/
|
||||||
|
boolean resetPasswordByVerificationCode(String username, String code, String password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,17 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.dromara.common.core.constant.CacheNames;
|
import org.dromara.common.core.constant.CacheNames;
|
||||||
import org.dromara.common.core.constant.Constants;
|
|
||||||
import org.dromara.common.core.constant.GlobalConstants;
|
import org.dromara.common.core.constant.GlobalConstants;
|
||||||
import org.dromara.common.core.constant.SystemConstants;
|
import org.dromara.common.core.constant.SystemConstants;
|
||||||
import org.dromara.common.core.domain.dto.UserDTO;
|
import org.dromara.common.core.domain.dto.UserDTO;
|
||||||
import org.dromara.common.core.exception.ServiceException;
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
||||||
|
import org.dromara.common.core.exception.user.UserException;
|
||||||
import org.dromara.common.core.service.UserService;
|
import org.dromara.common.core.service.UserService;
|
||||||
import org.dromara.common.core.utils.*;
|
import org.dromara.common.core.utils.*;
|
||||||
import org.dromara.common.mybatis.core.page.PageQuery;
|
import org.dromara.common.mybatis.core.page.PageQuery;
|
||||||
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
import org.dromara.common.mybatis.core.page.TableDataInfo;
|
||||||
|
import org.dromara.common.mybatis.helper.DataPermissionHelper;
|
||||||
import org.dromara.common.redis.utils.RedisUtils;
|
import org.dromara.common.redis.utils.RedisUtils;
|
||||||
import org.dromara.common.satoken.utils.LoginHelper;
|
import org.dromara.common.satoken.utils.LoginHelper;
|
||||||
import org.dromara.system.domain.SysUser;
|
import org.dromara.system.domain.SysUser;
|
||||||
@@ -414,6 +415,54 @@ public class SysUserServiceImpl implements ISysUserService, UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public boolean resetPasswordByVerificationCode(String username, String code, String password) {
|
||||||
|
if (StringUtils.isBlank(username)) {
|
||||||
|
throw new ServiceException("账号不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(code)) {
|
||||||
|
throw new ServiceException("验证码不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(password)) {
|
||||||
|
throw new ServiceException("新密码不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
LambdaQueryWrapper<SysUser> query = Wrappers.lambdaQuery();
|
||||||
|
query.eq(SysUser::getDelFlag, SystemConstants.NORMAL);
|
||||||
|
if (Validator.isEmail(username)) {
|
||||||
|
query.eq(SysUser::getEmail, username);
|
||||||
|
} else if (Validator.isMobile(username)) {
|
||||||
|
query.eq(SysUser::getPhonenumber, username);
|
||||||
|
} else {
|
||||||
|
query.eq(SysUser::getUserName, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
SysUser user = baseMapper.selectOne(query);
|
||||||
|
if (user == null) {
|
||||||
|
throw new UserException("账号未注册");
|
||||||
|
}
|
||||||
|
|
||||||
|
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||||
|
String cachedCode = RedisUtils.getCacheObject(cacheKey);
|
||||||
|
if (StringUtils.isBlank(cachedCode)) {
|
||||||
|
throw new CaptchaExpireException();
|
||||||
|
}
|
||||||
|
if (!StringUtils.equals(cachedCode, code)) {
|
||||||
|
throw new UserException("验证码无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
SysUser update = new SysUser();
|
||||||
|
update.setUserId(user.getUserId());
|
||||||
|
update.setPassword(BCrypt.hashpw(password));
|
||||||
|
int updatedRows = DataPermissionHelper.ignore(() -> baseMapper.updateById(update));
|
||||||
|
if (updatedRows < 1) {
|
||||||
|
throw new ServiceException("忘记密码修改失败");
|
||||||
|
}
|
||||||
|
RedisUtils.deleteObject(cacheKey);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验短信验证码
|
* 校验短信验证码
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package org.dromara.system.service.impl;
|
||||||
|
|
||||||
|
import cn.hutool.crypto.digest.BCrypt;
|
||||||
|
import cn.hutool.extra.spring.SpringUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.baomidou.mybatisplus.core.plugins.InterceptorIgnoreHelper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.apache.ibatis.session.Configuration;
|
||||||
|
import org.dromara.common.core.constant.GlobalConstants;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.common.core.exception.user.CaptchaExpireException;
|
||||||
|
import org.dromara.common.core.exception.user.UserException;
|
||||||
|
import org.dromara.common.redis.utils.RedisUtils;
|
||||||
|
import org.dromara.system.domain.SysUser;
|
||||||
|
import org.dromara.system.mapper.*;
|
||||||
|
import org.junit.jupiter.api.AfterAll;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Captor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.MockedStatic;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.redisson.api.RedissonClient;
|
||||||
|
import org.springframework.context.support.GenericApplicationContext;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@Tag("dev")
|
||||||
|
class SysUserServiceImplPasswordRecoveryTest {
|
||||||
|
|
||||||
|
private static GenericApplicationContext applicationContext;
|
||||||
|
|
||||||
|
@Mock private SysUserMapper userMapper;
|
||||||
|
@Mock private SysDeptMapper deptMapper;
|
||||||
|
@Mock private SysRoleMapper roleMapper;
|
||||||
|
@Mock private SysPostMapper postMapper;
|
||||||
|
@Mock private SysUserRoleMapper userRoleMapper;
|
||||||
|
@Mock private SysUserPostMapper userPostMapper;
|
||||||
|
@Captor private ArgumentCaptor<LambdaQueryWrapper<SysUser>> queryCaptor;
|
||||||
|
@Captor private ArgumentCaptor<SysUser> userCaptor;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initializeInfrastructure() {
|
||||||
|
if (TableInfoHelper.getTableInfo(SysUser.class) == null) {
|
||||||
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), ""), SysUser.class);
|
||||||
|
}
|
||||||
|
applicationContext = new GenericApplicationContext();
|
||||||
|
applicationContext.registerBean(RedissonClient.class, () -> mock(RedissonClient.class));
|
||||||
|
applicationContext.refresh();
|
||||||
|
new SpringUtil().setApplicationContext(applicationContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterAll
|
||||||
|
static void closeInfrastructure() {
|
||||||
|
applicationContext.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@CsvSource({
|
||||||
|
"alice@example.com,email",
|
||||||
|
"13305376054,phonenumber",
|
||||||
|
"alice,userName"
|
||||||
|
})
|
||||||
|
void resetPasswordByVerificationCode_supportsAllAccountIdentifiers(
|
||||||
|
String username, String expectedColumn) {
|
||||||
|
SysUserServiceImpl service = newService();
|
||||||
|
SysUser user = user(10L, username);
|
||||||
|
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||||
|
when(userMapper.selectOne(any())).thenReturn(user);
|
||||||
|
when(userMapper.updateById(any(SysUser.class))).thenReturn(1);
|
||||||
|
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
|
||||||
|
redis.when(() -> RedisUtils.deleteObject(cacheKey)).thenReturn(true);
|
||||||
|
|
||||||
|
boolean result = service.resetPasswordByVerificationCode(username, "123456", "newPassword");
|
||||||
|
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
verify(userMapper).selectOne(queryCaptor.capture());
|
||||||
|
assertThat(queryCaptor.getValue().getSqlSegment()).contains(expectedColumn);
|
||||||
|
assertThat(queryCaptor.getValue().getParamNameValuePairs()).containsValue(username);
|
||||||
|
verify(userMapper).updateById(userCaptor.capture());
|
||||||
|
assertThat(userCaptor.getValue().getUserId()).isEqualTo(10L);
|
||||||
|
assertThat(BCrypt.checkpw("newPassword", userCaptor.getValue().getPassword())).isTrue();
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject(cacheKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPasswordByVerificationCode_rejectsWrongCodeWithoutUpdatingOrDeleting() {
|
||||||
|
SysUserServiceImpl service = newService();
|
||||||
|
String username = "13305376054";
|
||||||
|
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||||
|
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
|
||||||
|
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
service.resetPasswordByVerificationCode(username, "000000", "newPassword"))
|
||||||
|
.isInstanceOf(UserException.class);
|
||||||
|
|
||||||
|
verify(userMapper, never()).updateById(any(SysUser.class));
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPasswordByVerificationCode_rejectsExpiredCodeWithoutUpdating() {
|
||||||
|
SysUserServiceImpl service = newService();
|
||||||
|
String username = "alice@example.com";
|
||||||
|
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||||
|
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
|
||||||
|
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
service.resetPasswordByVerificationCode(username, "123456", "newPassword"))
|
||||||
|
.isInstanceOf(CaptchaExpireException.class);
|
||||||
|
|
||||||
|
verify(userMapper, never()).updateById(any(SysUser.class));
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPasswordByVerificationCode_keepsCodeWhenDatabaseUpdateFails() {
|
||||||
|
SysUserServiceImpl service = newService();
|
||||||
|
String username = "alice";
|
||||||
|
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||||
|
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
|
||||||
|
when(userMapper.updateById(any(SysUser.class))).thenReturn(0);
|
||||||
|
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
|
||||||
|
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
service.resetPasswordByVerificationCode(username, "123456", "newPassword"))
|
||||||
|
.isInstanceOf(ServiceException.class);
|
||||||
|
|
||||||
|
redis.verify(() -> RedisUtils.deleteObject(cacheKey), never());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPasswordByVerificationCode_ignoresLoginBasedDataPermissionDuringUpdate() {
|
||||||
|
SysUserServiceImpl service = newService();
|
||||||
|
String username = "alice";
|
||||||
|
String cacheKey = GlobalConstants.CAPTCHA_CODE_KEY + username;
|
||||||
|
when(userMapper.selectOne(any())).thenReturn(user(10L, username));
|
||||||
|
when(userMapper.updateById(any(SysUser.class))).thenAnswer(invocation -> {
|
||||||
|
assertThat(InterceptorIgnoreHelper.willIgnoreDataPermission("SysUserMapper.updateById"))
|
||||||
|
.isTrue();
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
redis.when(() -> RedisUtils.<String>getCacheObject(cacheKey)).thenReturn("123456");
|
||||||
|
redis.when(() -> RedisUtils.deleteObject(cacheKey)).thenReturn(true);
|
||||||
|
|
||||||
|
assertThat(service.resetPasswordByVerificationCode(
|
||||||
|
username, "123456", "newPassword")).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void resetPasswordByVerificationCode_rejectsUnknownAccountBeforeReadingCode() {
|
||||||
|
SysUserServiceImpl service = newService();
|
||||||
|
when(userMapper.selectOne(any())).thenReturn(null);
|
||||||
|
|
||||||
|
try (MockedStatic<RedisUtils> redis = mockStatic(RedisUtils.class)) {
|
||||||
|
assertThatThrownBy(() ->
|
||||||
|
service.resetPasswordByVerificationCode("missing", "123456", "newPassword"))
|
||||||
|
.isInstanceOf(UserException.class);
|
||||||
|
|
||||||
|
redis.verifyNoInteractions();
|
||||||
|
verify(userMapper, never()).updateById(any(SysUser.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysUserServiceImpl newService() {
|
||||||
|
return new SysUserServiceImpl(
|
||||||
|
userMapper, deptMapper, roleMapper, postMapper, userRoleMapper, userPostMapper);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SysUser user(Long userId, String username) {
|
||||||
|
SysUser user = new SysUser();
|
||||||
|
user.setUserId(userId);
|
||||||
|
user.setUserName(username);
|
||||||
|
user.setEmail(username);
|
||||||
|
user.setPhonenumber(username);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user