diff --git a/.idea/compiler.xml b/.idea/compiler.xml index 84f75d9..2617d3d 100644 --- a/.idea/compiler.xml +++ b/.idea/compiler.xml @@ -25,6 +25,18 @@ + + + + + + + + + + + + diff --git a/water-admin/src/main/java/org/dromara/web/controller/CaptchaController.java b/water-admin/src/main/java/org/dromara/web/controller/CaptchaController.java index 62e69ea..1038d11 100644 --- a/water-admin/src/main/java/org/dromara/web/controller/CaptchaController.java +++ b/water-admin/src/main/java/org/dromara/web/controller/CaptchaController.java @@ -67,26 +67,32 @@ public class CaptchaController { @RateLimiter(key = "#username", time = 60, count = 1) @GetMapping("/resource/code") public R 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 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 sendEmailCode(String email, String cacheKey) { + if (!mailProperties.getEnabled()) { + return R.fail("当前系统没有开启邮箱功能!"); + } + emailCodeImpl(email, cacheKey); + return R.ok("操作成功"); } diff --git a/water-admin/src/main/java/org/dromara/web/service/ForgotPasswordService.java b/water-admin/src/main/java/org/dromara/web/service/ForgotPasswordService.java index e00d39e..f6f1457 100644 --- a/water-admin/src/main/java/org/dromara/web/service/ForgotPasswordService.java +++ b/water-admin/src/main/java/org/dromara/web/service/ForgotPasswordService.java @@ -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().eq(SysUser::getEmail, loginBody.getUsername())); - }else if (Validator.isMobile(loginBody.getUsername())){ - appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper().eq(SysUser::getPhonenumber, loginBody.getUsername())); - }else { - appUserVo = userMapper.selectVoOne(new LambdaQueryWrapper().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()); } } diff --git a/water-admin/src/main/resources/application.yml b/water-admin/src/main/resources/application.yml index 13fb8b2..6c8c966 100644 --- a/water-admin/src/main/resources/application.yml +++ b/water-admin/src/main/resources/application.yml @@ -172,6 +172,7 @@ security: - /*/api-docs - /*/api-docs/** - /warm-flow-ui/config + - /app/v1/retrievePassword # 多租户配置 tenant: diff --git a/water-admin/src/test/java/org/dromara/web/config/SecurityExcludesConfigUnitTest.java b/water-admin/src/test/java/org/dromara/web/config/SecurityExcludesConfigUnitTest.java new file mode 100644 index 0000000..34a370c --- /dev/null +++ b/water-admin/src/test/java/org/dromara/web/config/SecurityExcludesConfigUnitTest.java @@ -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> 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 getRegisteredInterceptors() { + return super.getInterceptors(); + } + } +} diff --git a/water-admin/src/test/java/org/dromara/web/controller/CaptchaControllerUnitTest.java b/water-admin/src/test/java/org/dromara/web/controller/CaptchaControllerUnitTest.java index b633b07..08d7c13 100644 --- a/water-admin/src/test/java/org/dromara/web/controller/CaptchaControllerUnitTest.java +++ b/water-admin/src/test/java/org/dromara/web/controller/CaptchaControllerUnitTest.java @@ -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 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 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 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)); diff --git a/water-admin/src/test/java/org/dromara/web/service/ForgotPasswordServiceTest.java b/water-admin/src/test/java/org/dromara/web/service/ForgotPasswordServiceTest.java new file mode 100644 index 0000000..124359f --- /dev/null +++ b/water-admin/src/test/java/org/dromara/web/service/ForgotPasswordServiceTest.java @@ -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"); + } +} diff --git a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java index 644b76d..f1c9e7a 100644 --- a/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java +++ b/water-common/water-common-mqtt/src/main/java/org/dromara/mqtt/MqttCommandAckService.java @@ -261,6 +261,14 @@ public class MqttCommandAckService implements IDeviceCommandAckHandler { log.warn("[MQTT] 命令重试次数已达上限 设备编号={} 命令编号={}", command.getDeviceNo(), commandId); 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())) { command.setNextRetryAt(now + mqttProperties.getCommandAck().getRetryIntervalMs()); savePending(command); diff --git a/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java b/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java index 8326610..4dbe6f5 100644 --- a/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java +++ b/water-common/water-common-mqtt/src/test/java/org/dromara/mqtt/MqttCommandAckServiceTest.java @@ -27,8 +27,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; 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 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.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 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 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.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 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 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.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 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 void refreshDeviceOnlineRenewsStatusCacheTtl() throws Exception { MqttProperties properties = new MqttProperties(); diff --git a/water-common/water-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java b/water-common/water-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java index df53537..7c2bcf5 100644 --- a/water-common/water-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java +++ b/water-common/water-common-security/src/main/java/org/dromara/common/security/config/SecurityConfig.java @@ -38,6 +38,8 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @RequiredArgsConstructor public class SecurityConfig implements WebMvcConfigurer { + private static final String APP_RETRIEVE_PASSWORD_PATH = "/app/v1/retrievePassword"; + private final SecurityProperties securityProperties; @Value("${sse.path}") private String ssePath; @@ -83,6 +85,7 @@ public class SecurityConfig implements WebMvcConfigurer { })).addPathPatterns("/**") // 排除不需要拦截的路径 .excludePathPatterns(securityProperties.getExcludes()) + .excludePathPatterns(APP_RETRIEVE_PASSWORD_PATH) .excludePathPatterns(ssePath); } diff --git a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java index 4ba90cc..206dcfb 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/controller/AppController.java @@ -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 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 forgot(@Validated @RequestBody AppForgotPasswordBo request) { + return toAjax(userService.resetPasswordByVerificationCode( + request.getUsername(), request.getCode(), request.getPassword())); } private String formatSecondTime(Date time) { diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppForgotPasswordBo.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppForgotPasswordBo.java new file mode 100644 index 0000000..58c1eea --- /dev/null +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/bo/AppForgotPasswordBo.java @@ -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; +} diff --git a/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java b/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java index 2f2f3e6..a185cf4 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/domain/mqtt/DeviceCommand.java @@ -19,6 +19,7 @@ public class DeviceCommand implements Serializable { private String commandType; private String topic; private Map payload = new HashMap<>(); + private Boolean retryEnabled = Boolean.TRUE; private int retryCount; private long createdAt; private long lastSentAt; diff --git a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java index 9e52b68..27bf0bb 100644 --- a/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java +++ b/water-modules/water-app/src/main/java/org/dromara/app/handler/DeviceRegisterHandler.java @@ -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); diff --git a/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java b/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java index 764a084..cffd178 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/controller/AppControllerTest.java @@ -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 = mockStatic(LoginHelper.class)) { + R 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 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(); diff --git a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java index 26f4701..ff7854d 100644 --- a/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java +++ b/water-modules/water-app/src/test/java/org/dromara/app/handler/DeviceRegisterHandlerTest.java @@ -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 diff --git a/water-modules/water-system/pom.xml b/water-modules/water-system/pom.xml index ae8838e..0e58169 100644 --- a/water-modules/water-system/pom.xml +++ b/water-modules/water-system/pom.xml @@ -100,6 +100,12 @@ water-common-sse + + org.springframework.boot + spring-boot-starter-test + test + + diff --git a/water-modules/water-system/src/main/java/org/dromara/system/service/ISysUserService.java b/water-modules/water-system/src/main/java/org/dromara/system/service/ISysUserService.java index 2729bd1..a4a199e 100644 --- a/water-modules/water-system/src/main/java/org/dromara/system/service/ISysUserService.java +++ b/water-modules/water-system/src/main/java/org/dromara/system/service/ISysUserService.java @@ -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); } diff --git a/water-modules/water-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java b/water-modules/water-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java index 0d21235..9c87f68 100644 --- a/water-modules/water-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java +++ b/water-modules/water-system/src/main/java/org/dromara/system/service/impl/SysUserServiceImpl.java @@ -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 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; + } + /** * 校验短信验证码 */ diff --git a/water-modules/water-system/src/test/java/org/dromara/system/service/impl/SysUserServiceImplPasswordRecoveryTest.java b/water-modules/water-system/src/test/java/org/dromara/system/service/impl/SysUserServiceImplPasswordRecoveryTest.java new file mode 100644 index 0000000..fa11654 --- /dev/null +++ b/water-modules/water-system/src/test/java/org/dromara/system/service/impl/SysUserServiceImplPasswordRecoveryTest.java @@ -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> queryCaptor; + @Captor private ArgumentCaptor 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 redis = mockStatic(RedisUtils.class)) { + redis.when(() -> RedisUtils.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 redis = mockStatic(RedisUtils.class)) { + redis.when(() -> RedisUtils.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 redis = mockStatic(RedisUtils.class)) { + redis.when(() -> RedisUtils.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 redis = mockStatic(RedisUtils.class)) { + redis.when(() -> RedisUtils.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 redis = mockStatic(RedisUtils.class)) { + redis.when(() -> RedisUtils.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 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; + } +}