fix(app): 修复 AppController 安全与查询问题
- 加强异常处理、类型安全和图片上传校验 - 优化设备相关查询,避免重复访问数据源 - 补充并记录并发测试与审查修复实施计划
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 topic;
|
||||
private Map<String, Object> payload = new HashMap<>();
|
||||
private Boolean retryEnabled = Boolean.TRUE;
|
||||
private int retryCount;
|
||||
private long createdAt;
|
||||
private long lastSentAt;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验短信验证码
|
||||
*/
|
||||
|
||||
@@ -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