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

@@ -67,26 +67,32 @@ public class CaptchaController {
@RateLimiter(key = "#username", time = 60, count = 1)
@GetMapping("/resource/code")
public R<Void> code(@NotBlank(message = "{user.username.not.blank}") String username) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + username;
String code = RandomUtil.randomNumbers(6);
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
if (Validator.isMobile(username)) {
return sendSmsCode(username, username);
}
if (Validator.isEmail(username)) {
return sendEmailCode(username, username);
}
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 {
emailCodeImpl(username);
}
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("当前账号未绑定手机号或邮箱");
}
return R.ok(code);
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;
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 org.dromara.common.core.constant.GlobalConstants;
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.redis.utils.RedisUtils;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.system.service.ISysUserService;
import org.springframework.stereotype.Service;
@@ -22,41 +11,14 @@ import org.springframework.stereotype.Service;
@Service
public class ForgotPasswordService {
private final SysUserMapper userMapper;
private final ISysUserService userService;
public boolean forgotPasswordService(ForgotLoginBody loginBody) {
SysUserVo appUserVo =new SysUserVo();
if(Validator.isEmail(loginBody.getUsername())){
userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, loginBody.getUsername()));
}else if (Validator.isMobile(loginBody.getUsername())){
appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhonenumber, loginBody.getUsername()));
}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);
String verificationCode = StringUtils.isNotBlank(loginBody.getSmsCode())
? loginBody.getSmsCode()
: loginBody.getCode();
return userService.resetPasswordByVerificationCode(
loginBody.getUsername(), verificationCode, loginBody.getPassword());
}
}

View File

@@ -172,6 +172,7 @@ security:
- /*/api-docs
- /*/api-docs/**
- /warm-flow-ui/config
- /app/v1/retrievePassword
# 多租户配置
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
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
void accountCancelCode_sendsSmsCodeWhenCurrentUserHasPhone() {
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");
}
}