fix(app): 修复 AppController 安全与查询问题

- 加强异常处理、类型安全和图片上传校验
- 优化设备相关查询,避免重复访问数据源
- 补充并记录并发测试与审查修复实施计划
This commit is contained in:
yuhaiming
2026-07-20 15:45:13 +08:00
parent b6c1edaeba
commit 215d6ba167
20 changed files with 664 additions and 91 deletions

12
.idea/compiler.xml generated
View File

@@ -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" />

View File

@@ -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.isMobile(username)){
// 验证码模板id 自行处理 (查数据库或写死均可)
String templateId = "SMS_333877107";
LinkedHashMap<String, String> map = new LinkedHashMap<>(1);
map.put("code", code);
SmsBlend smsBlend = SmsFactory.getSmsBlend("config1");
SmsResponse smsResponse = smsBlend.sendMessage(username, templateId, map);
if (!smsResponse.isSuccess()) {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(smsResponse.getData().toString());
} }
}else { if (Validator.isEmail(username)) {
emailCodeImpl(username); return sendEmailCode(username, username);
} }
return R.ok(code); SysUserVo user = userService.selectUserByUserName(username);
if (user == null) {
return R.fail("账号未注册");
}
if (Validator.isMobile(user.getPhonenumber())) {
return sendSmsCode(user.getPhonenumber(), username);
}
if (Validator.isEmail(user.getEmail())) {
return sendEmailCode(user.getEmail(), username);
}
return R.fail("当前账号未绑定手机号或邮箱");
}
private R<Void> sendEmailCode(String email, String cacheKey) {
if (!mailProperties.getEnabled()) {
return R.fail("当前系统没有开启邮箱功能!");
}
emailCodeImpl(email, cacheKey);
return R.ok("操作成功");
} }

View File

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

View File

@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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;

View File

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

View File

@@ -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();

View File

@@ -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

View File

@@ -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>

View File

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

View File

@@ -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;
}
/** /**
* 校验短信验证码 * 校验短信验证码
*/ */

View File

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