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

View File

@@ -1,9 +1,9 @@
package org.dromara.app.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import cn.hutool.core.io.FileTypeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Validator;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.hutool.json.JSONArray;
@@ -13,10 +13,7 @@ import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.app.domain.bo.AppDeviceBo;
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.bo.*;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
@@ -732,25 +729,12 @@ public class AppController extends BaseController {
/**
* app用户手机号/邮箱找回忘记密码
*/
@SaIgnore
@ApiEncrypt
@PostMapping("/retrievePassword")
public R<Void> forgot(@RequestBody String body) {
SysUserBo bo = JsonUtils.parseObject(body, SysUserBo.class);
bo.setUserId(LoginHelper.getUserId());
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));
public R<Void> forgot(@Validated @RequestBody AppForgotPasswordBo request) {
return toAjax(userService.resetPasswordByVerificationCode(
request.getUsername(), request.getCode(), request.getPassword()));
}
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 topic;
private Map<String, Object> payload = new HashMap<>();
private Boolean retryEnabled = Boolean.TRUE;
private int retryCount;
private long createdAt;
private long lastSentAt;

View File

@@ -123,6 +123,7 @@ DeviceRegisterHandler implements MqttTopicHandler {
command.setDeviceMac(deviceMac);
command.setCommandType("registerDeviceNo");
command.setTopic("/" + deviceMac.trim().toLowerCase(Locale.ROOT) + "/subscriber/cmd");
command.setRetryEnabled(false);
command.getPayload().put("deviceNo", deviceNo);
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.json.JSONObject;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.app.domain.bo.AppDeviceBo;
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.bo.*;
import org.dromara.app.domain.vo.*;
import org.dromara.app.service.*;
import org.dromara.common.core.domain.R;
@@ -129,6 +126,41 @@ public class AppControllerTest {
.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
public void uploadImage_uploadsToOssAndReturnsBackendVisibleInfo() {
AppController controller = newController();

View File

@@ -59,6 +59,7 @@ class DeviceRegisterHandlerTest {
assertThat(captor.getValue().getTopic()).isEqualTo("/aa:bb:cc/subscriber/cmd");
assertThat(captor.getValue().getCommandType()).isEqualTo("registerDeviceNo");
assertThat(captor.getValue().getDeviceNo()).isEqualTo("D01");
assertThat(captor.getValue().getRetryEnabled()).isFalse();
}
@Test

View File

@@ -100,6 +100,12 @@
<artifactId>water-common-sse</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -232,4 +232,14 @@ public interface ISysUserService {
int updateAppUser(SysUserBo user);
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.extern.slf4j.Slf4j;
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.SystemConstants;
import org.dromara.common.core.domain.dto.UserDTO;
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.core.service.UserService;
import org.dromara.common.core.utils.*;
import org.dromara.common.mybatis.core.page.PageQuery;
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.satoken.utils.LoginHelper;
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;
}
}