From 256841656d4a8791a20eaa5363a508485c8fa6ff Mon Sep 17 00:00:00 2001 From: gaozhaochen <158975971@qq.com> Date: Tue, 12 Aug 2025 13:17:14 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=89=E5=85=A8=E6=BC=8F=E6=B4=9E?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复XSS储存型漏洞 - 修复验证码可进行爆破漏洞,连续错误3次及以上锁定账户 - 修复短信轰炸漏洞,验证码有效期5分钟 --- .../common/core/util/ValidateCodeUtil.java | 2 +- .../filter/ValidateCodeGatewayFilter.java | 166 ++++++++++++++++-- .../theme/common/crypto/EncryptionUtil.java | 5 +- .../theme/config/DecodeRequestAdvice.java | 52 +++++- .../hphy/controller/mp/WDController.java | 16 +- 5 files changed, 213 insertions(+), 28 deletions(-) diff --git a/cloud-common/cloud-common-core/src/main/java/cn/sh/stc/sict/cloud/common/core/util/ValidateCodeUtil.java b/cloud-common/cloud-common-core/src/main/java/cn/sh/stc/sict/cloud/common/core/util/ValidateCodeUtil.java index c1eb62f..a5105af 100644 --- a/cloud-common/cloud-common-core/src/main/java/cn/sh/stc/sict/cloud/common/core/util/ValidateCodeUtil.java +++ b/cloud-common/cloud-common-core/src/main/java/cn/sh/stc/sict/cloud/common/core/util/ValidateCodeUtil.java @@ -17,7 +17,7 @@ public class ValidateCodeUtil { /** * 过期时间/s */ - private final static Long EXPIRE_TIME_SECOND = 120L; + private final static Long EXPIRE_TIME_SECOND = 300L; /** * 从redis取出验证码并验证 diff --git a/cloud-common/cloud-common-gateway/src/main/java/cn/sh/stc/sict/cloud/common/gateway/filter/ValidateCodeGatewayFilter.java b/cloud-common/cloud-common-gateway/src/main/java/cn/sh/stc/sict/cloud/common/gateway/filter/ValidateCodeGatewayFilter.java index c924174..9eb544b 100644 --- a/cloud-common/cloud-common-gateway/src/main/java/cn/sh/stc/sict/cloud/common/gateway/filter/ValidateCodeGatewayFilter.java +++ b/cloud-common/cloud-common-gateway/src/main/java/cn/sh/stc/sict/cloud/common/gateway/filter/ValidateCodeGatewayFilter.java @@ -2,11 +2,11 @@ package cn.sh.stc.sict.cloud.common.gateway.filter; import cn.hutool.core.util.StrUtil; import cn.sh.stc.sict.cloud.common.core.constant.Constant; +import cn.sh.stc.sict.cloud.common.core.constant.RedisCacheConstant; import cn.sh.stc.sict.cloud.common.core.constant.SecurityConstants; import cn.sh.stc.sict.cloud.common.core.constant.enums.LoginTypeEnum; import cn.sh.stc.sict.cloud.common.core.exception.ValidateCodeException; import cn.sh.stc.sict.cloud.common.core.util.R; -import cn.sh.stc.sict.cloud.common.core.util.SmsSendUtil; import cn.sh.stc.sict.cloud.common.core.util.ValidateCodeUtil; import cn.sh.stc.sict.cloud.common.core.util.WebUtils; import cn.sh.stc.sict.cloud.common.gateway.config.FilterIgnorePropertiesConfig; @@ -24,6 +24,12 @@ import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.stereotype.Component; import reactor.core.publisher.Mono; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + /** * @Description 验证码处理 * @Author @@ -33,6 +39,17 @@ import reactor.core.publisher.Mono; @Component @AllArgsConstructor public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory { + + // 失败次数阈值 + private static final int MAX_ATTEMPTS_TIER1 = 3; + private static final int MAX_ATTEMPTS_TIER2 = 4; + private static final int MAX_ATTEMPTS_TIER3 = 5; + + // 对应的锁定时间 + private static final long LOCK_TIME_TIER1_MINUTES = 10; + private static final long LOCK_TIME_TIER2_MINUTES = 30; + private static final long LOCK_TIME_TIER3_HOURS = 2; + private final ObjectMapper objectMapper; private final FilterIgnorePropertiesConfig filterIgnorePropertiesConfig; private final StringRedisTemplate redisTemplate; @@ -43,9 +60,7 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory { ServerHttpRequest request = exchange.getRequest(); // 不是登录请求,直接向下执行 - if (!StrUtil.containsAnyIgnoreCase(request.getURI().getPath() - , SecurityConstants.OAUTH_TOKEN_URL, SecurityConstants.SMS_TOKEN_URL - , SecurityConstants.SOCIAL_TOKEN_URL)) { + if (!StrUtil.containsAnyIgnoreCase(request.getURI().getPath(), SecurityConstants.OAUTH_TOKEN_URL, SecurityConstants.SMS_TOKEN_URL, SecurityConstants.SOCIAL_TOKEN_URL)) { return chain.filter(exchange); } @@ -69,9 +84,7 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory { String phone = mobile.split("@")[2]; String code = request.getQueryParams().getFirst("code"); log.error("mobile = {}, code = {}", phone, code); - if(!ValidateCodeUtil.validateCode(redisTemplate, phone, code, Constant.BYTE_NO)){ - throw new ValidateCodeException("验证码不合法"); - } + validCode(redisTemplate, phone, code); return chain.filter(exchange); } else { return chain.filter(exchange); @@ -100,10 +113,7 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory { response.setStatusCode(HttpStatus.PRECONDITION_REQUIRED); response.getHeaders().set("Content-type", "application/json; charset=utf-8"); try { - return response.writeWith(Mono.just(response.bufferFactory() - .wrap(objectMapper.writeValueAsBytes( - R.builder().msg(e.getMessage()) - .code(Constant.BYTE_NO).build())))); + return response.writeWith(Mono.just(response.bufferFactory().wrap(objectMapper.writeValueAsBytes(R.builder().msg(e.getMessage()).code(Constant.BYTE_NO).build())))); } catch (JsonProcessingException e1) { log.error("对象输出异常", e1); } @@ -114,20 +124,154 @@ public class ValidateCodeGatewayFilter extends AbstractGatewayFilterFactory { } /** + * 检验验证码 + * 连续输入错误3次,锁定10分钟 + * 连续输入错误4次,锁定30分钟 + * 连续输入错误5次,锁定2小时 + * + * @param redisTemplate + */ + private void validCode(StringRedisTemplate redisTemplate, String phone, String code) throws ValidateCodeException { + // 是否锁定 + if (isLocked(phone)) { + throw new ValidateCodeException(getLockoutMessage(phone)); + } + + // 登录成功,清除失败记录 + if (ValidateCodeUtil.validateCode(redisTemplate, phone, code, Constant.BYTE_NO)) { + loginSucceeded(phone); + } else { + loginFailed(phone); + if (isLocked(phone)) { + throw new ValidateCodeException(getLockoutMessage(phone)); + } else { + throw new ValidateCodeException("验证码不合法"); + } + } + + } + + /** + * 当用户登录成功时调用此方法。 + * + * @param phone 手机号 + */ + public void loginSucceeded(String phone) { + String lockKey = getLoginFailLockKey(phone); + String countKey = getLoginFailCountKey(phone); + redisTemplate.delete(Arrays.asList(countKey, lockKey)); + } + + /** + * 当用户登录失败时调用此方法。 + * + * @param phone 用户名 + */ + public void loginFailed(String phone) { + String countKey = getLoginFailCountKey(phone); + String lockKey = getLoginFailLockKey(phone); + + // 原子递增失败次数 + Long attempts = redisTemplate.opsForValue().increment(countKey); + + // 如果是第一次失败,记录失败次数,设置24H过期 + if (attempts != null && attempts == 1) { + redisTemplate.expire(countKey, 24, TimeUnit.HOURS); + } + + if (attempts != null) { + // 根据失败次数设置不同的锁定时间 + if (attempts >= MAX_ATTEMPTS_TIER3) { + // 第5次及以上失败,锁定2小时 + redisTemplate.opsForValue().set(lockKey, "locked", Duration.ofHours(LOCK_TIME_TIER3_HOURS)); + } else if (attempts >= MAX_ATTEMPTS_TIER2) { + // 第4次失败,锁定30分钟 + redisTemplate.opsForValue().set(lockKey, "locked", Duration.ofMinutes(LOCK_TIME_TIER2_MINUTES)); + } else if (attempts >= MAX_ATTEMPTS_TIER1) { + // 第3次失败,锁定10分钟 + redisTemplate.opsForValue().set(lockKey, "locked", Duration.ofMinutes(LOCK_TIME_TIER1_MINUTES)); + } + } + } + + /** + * 检查用户是否已被锁定。 + * + * @param phone 手机号 + * @return 如果被锁定,返回 true;否则返回 false。 + */ + public boolean isLocked(String phone) { + String lockKey = getLoginFailLockKey(phone); + return redisTemplate.hasKey(lockKey); + } + + /** + * 获取锁定解除时间的提示信息。 + * + * @param phone 手机号 + * @return 格式化的提示信息,例如:"您的账号已被锁定,请于 2025-08-11 10:30:00 后再试。" + */ + public String getLockoutMessage(String phone) { + String key = getLoginFailLockKey(phone); + // 获取key的剩余过期时间(TTL),单位为秒 + long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS); + + if (ttl <= 0) { + return ""; + } + + // 计算确切的解锁时间点 + LocalDateTime unlockTime = LocalDateTime.now().plusSeconds(ttl); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + return String.format("您的账号因多次登录失败已被锁定,请于 %s 后再试。", unlockTime.format(formatter)); + } + + /** + * 获取登录失败锁定的key + * 超时过期 + * + * @param phone 手机号 + * @return + */ + private static String getLoginFailLockKey(String phone) { + return RedisCacheConstant.SICT_PHONE_CODE_KEY + ":" + Constant.BYTE_NO + ":" + phone + ":" + "login" + ":" + "fail" + ":" + "lock"; + } + + /** + * 获取登录失败次数的key,24H内有效 + * + * @param phone 手机号 + * @return + */ + private static String getLoginFailCountKey(String phone) { + return RedisCacheConstant.SICT_PHONE_CODE_KEY + ":" + Constant.BYTE_NO + ":" + phone + ":" + "login" + ":" + "fail" + ":" + "count"; + } + + /** + * /** * 检查code * * @param request */ @SneakyThrows private void checkCode(ServerHttpRequest request) { + // String code = request.getQueryParams().getFirst("code"); // // if (StrUtil.isBlank(code)) { +// System.out.println("DEBUG: Code is blank"); // throw new ValidateCodeException("验证码不能为空"); // } // // String randomStr = request.getQueryParams().getFirst("randomStr"); // if (StrUtil.isBlank(randomStr)) { +// System.out.println("DEBUG: randomStr is blank, trying mobile"); +// randomStr = request.getQueryParams().getFirst("mobile"); +// } + +// String key = CommonConstants.DEFAULT_CODE_KEY + randomStr; +// if (StrUtil.isBlank(randomStr)) { // randomStr = request.getQueryParams().getFirst("mobile"); // } diff --git a/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/common/crypto/EncryptionUtil.java b/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/common/crypto/EncryptionUtil.java index 099f7fe..9ccbc94 100644 --- a/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/common/crypto/EncryptionUtil.java +++ b/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/common/crypto/EncryptionUtil.java @@ -38,14 +38,13 @@ public class EncryptionUtil { /** * 获取解密返回流 */ - public static InputStream getDecodeInputStream(EncryptionRequest request) { + public static byte[] getDecodeInputStream(EncryptionRequest request) { String encryptionKey = request.getEncryptionKey(); // rsa解密 获取aes秘钥 byte[] decryptKey = RSA.decrypt(encryptionKey, KeyType.PrivateKey); // aes进行解密内容 获取输入流(方便整合springMVC) AES aes = SecureUtil.aes(decryptKey); - byte[] decryptData = aes.decrypt(request.getEncryptionData()); - return IoUtil.toStream(decryptData); + return aes.decrypt(request.getEncryptionData()); } diff --git a/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/config/DecodeRequestAdvice.java b/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/config/DecodeRequestAdvice.java index a00c753..8590e57 100644 --- a/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/config/DecodeRequestAdvice.java +++ b/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/config/DecodeRequestAdvice.java @@ -1,5 +1,7 @@ package cn.sh.stc.sict.theme.config; +import cn.hutool.core.io.IoUtil; +import cn.hutool.http.HtmlUtil; import cn.sh.stc.sict.theme.common.crypto.EncryptionRequest; import cn.sh.stc.sict.theme.common.crypto.EncryptionUtil; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,6 +17,10 @@ import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; /** * @author gao @@ -50,7 +56,17 @@ public class DecodeRequestAdvice implements RequestBodyAdvice { // 解密后的输入流 @Override public InputStream getBody() { - return EncryptionUtil.getDecodeInputStream(encryptionRequest); + byte[] decodeReqBytes = EncryptionUtil.getDecodeInputStream(encryptionRequest); + // 解密请求体 + String rbContentStr = new String(decodeReqBytes, StandardCharsets.UTF_8); + // XSS过滤 + try { + Object rbContentObj = objectMapper.readValue(rbContentStr, Object.class); + return IoUtil.toStream(objectMapper.writeValueAsBytes(cleanJsonValue(rbContentObj))); + } catch (Exception e) { + // ignore + } + return IoUtil.toStream(decodeReqBytes); } }; } @@ -65,5 +81,35 @@ public class DecodeRequestAdvice implements RequestBodyAdvice { return o; } - -} + /** + * XSS过滤 + * + * @param value Map, List, String, 其他基础类型 + * @return 清理后的值 + */ + private Object cleanJsonValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof String) { + return HtmlUtil.escape((String) value); + } else if (value instanceof Map) { + // 如果是 Map,遍歷其所有 entry + Map map = (Map) value; + map.forEach((key, val) -> { + // 遞迴地清理它的值 + map.put(key, cleanJsonValue(val)); + }); + return map; + } else if (value instanceof List) { + // 如果是 List,遍歷其所有元素 + List list = (List) value; + // 建立一個新的 List 來存放清理後的元素 + return list.stream() + .map(this::cleanJsonValue) // 對每個元素進行遞迴清理 + .collect(Collectors.toList()); + } else { + return value; + } + } +} \ No newline at end of file diff --git a/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/hphy/controller/mp/WDController.java b/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/hphy/controller/mp/WDController.java index 9beb8d9..066bd4b 100644 --- a/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/hphy/controller/mp/WDController.java +++ b/smart-health-modules/theme-schema/src/main/java/cn/sh/stc/sict/theme/hphy/controller/mp/WDController.java @@ -3,26 +3,21 @@ package cn.sh.stc.sict.theme.hphy.controller.mp; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Validator; import cn.hutool.core.util.RandomUtil; -import cn.hutool.json.JSONUtil; -import cn.sh.stc.sict.cloud.common.core.constant.Constant; import cn.sh.stc.sict.cloud.common.core.util.R; import cn.sh.stc.sict.cloud.common.core.util.ValidateCodeUtil; -import cn.sh.stc.sict.theme.hpgp.model.HpgpDepartmentRank; import cn.sh.stc.sict.theme.hpgp.service.HpgpDepartmentRankService; import cn.sh.stc.sict.theme.hphy.service.HpDeptInfoService; -import cn.sh.stc.sict.theme.hphy.wd.*; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import cn.sh.stc.sict.theme.hphy.wd.NumSourceInfo; +import cn.sh.stc.sict.theme.hphy.wd.WanDaHttpUtil; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiParam; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.web.bind.annotation.*; import java.util.List; -import java.util.stream.Collectors; /** * @author F_xh @@ -58,12 +53,13 @@ public class WDController { return new R().error("手机号错误!"); } // 是否重复发送 - if(ValidateCodeUtil.hasPhoneKey(redisTemplate, phone, type)){ + if (ValidateCodeUtil.hasPhoneKey(redisTemplate, phone, type)) { return new R().success("短信已发送,请等待!"); } String appName = "dx"; String code = RandomUtil.randomNumbers(6); String msg = "您的验证码是:" + code; + WanDaHttpUtil.sendMsg(phone, msg, appName); ValidateCodeUtil.saveCode(redisTemplate, phone, code, type); return new R().success("短信已发送,请等待!"); @@ -81,7 +77,7 @@ public class WDController { // } numSourceInfo.setOrderNumType(null); List list = WanDaHttpUtil.getOrderNumInfo(numSourceInfo); - if(CollUtil.isEmpty(list)){ + if (CollUtil.isEmpty(list)) { return new R(); } @@ -114,7 +110,7 @@ public class WDController { @ApiOperation("获取测压亭列表") @GetMapping("/pressure/pavilion") - public R getPressureStationList(){ + public R getPressureStationList() { return new R(); } -- 2.22.0