Browse Source

1、增加篡改的注解
2、优化人才保存的个人优势字段(不为空才更新)

rayson 8 months ago
parent
commit
6d76d06989

+ 7 - 0
citu-framework/citu-spring-boot-starter-protection/pom.xml

@@ -35,6 +35,13 @@
             <artifactId>lock4j-redisson-spring-boot-starter</artifactId>
             <optional>true</optional>
         </dependency>
+
+        <!-- Test 测试相关 -->
+        <dependency>
+            <groupId>com.citu</groupId>
+            <artifactId>citu-spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
 </project>

+ 27 - 0
citu-framework/citu-spring-boot-starter-protection/src/main/java/com/citu/framework/signature/config/CituApiSignatureAutoConfiguration.java

@@ -0,0 +1,27 @@
+package com.citu.framework.signature.config;
+
+import com.citu.framework.redis.config.CituRedisAutoConfiguration;
+import com.citu.framework.signature.core.aop.ApiSignatureAspect;
+import com.citu.framework.signature.core.redis.ApiSignatureRedisDAO;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+/**
+ * HTTP API 签名的自动配置类
+ *
+ * @author Zhougang
+ */
+@AutoConfiguration(after = CituRedisAutoConfiguration.class)
+public class CituApiSignatureAutoConfiguration {
+
+    @Bean
+    public ApiSignatureAspect signatureAspect(ApiSignatureRedisDAO signatureRedisDAO) {
+        return new ApiSignatureAspect(signatureRedisDAO);
+    }
+
+    @Bean
+    public ApiSignatureRedisDAO signatureRedisDAO(StringRedisTemplate stringRedisTemplate) {
+        return new ApiSignatureRedisDAO(stringRedisTemplate);
+    }
+}

+ 59 - 0
citu-framework/citu-spring-boot-starter-protection/src/main/java/com/citu/framework/signature/core/annotation/ApiSignature.java

@@ -0,0 +1,59 @@
+package com.citu.framework.signature.core.annotation;
+
+
+import com.citu.framework.common.exception.enums.GlobalErrorCodeConstants;
+
+import java.lang.annotation.*;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * HTTP API 签名注解
+ *
+ * @author Zhougang
+ */
+@Inherited
+@Documented
+@Target({ElementType.METHOD, ElementType.TYPE})
+@Retention(RetentionPolicy.RUNTIME)
+public @interface ApiSignature {
+
+    /**
+     * 同一个请求多长时间内有效 默认 60 秒
+     */
+    int timeout() default 60;
+
+    /**
+     * 时间单位,默认为 SECONDS 秒
+     */
+    TimeUnit timeUnit() default TimeUnit.SECONDS;
+
+    // ========================== 签名参数 ==========================
+
+    /**
+     * 提示信息,签名失败的提示
+     *
+     * @see GlobalErrorCodeConstants#BAD_REQUEST
+     */
+    String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示
+
+    /**
+     * 签名字段:appId 应用ID
+     */
+    String appId() default "appId";
+
+    /**
+     * 签名字段:timestamp 时间戳
+     */
+    String timestamp() default "timestamp";
+
+    /**
+     * 签名字段:nonce 随机数,10 位以上
+     */
+    String nonce() default "nonce";
+
+    /**
+     * sign 客户端签名
+     */
+    String sign() default "sign";
+
+}

+ 170 - 0
citu-framework/citu-spring-boot-starter-protection/src/main/java/com/citu/framework/signature/core/aop/ApiSignatureAspect.java

@@ -0,0 +1,170 @@
+package com.citu.framework.signature.core.aop;
+
+
+import cn.hutool.core.lang.Assert;
+import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.ObjUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.citu.framework.common.exception.ServiceException;
+import com.citu.framework.common.util.servlet.ServletUtils;
+import com.citu.framework.signature.core.annotation.ApiSignature;
+import com.citu.framework.signature.core.redis.ApiSignatureRedisDAO;
+import lombok.AllArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Before;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.Map;
+import java.util.Objects;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import static com.citu.framework.common.exception.enums.GlobalErrorCodeConstants.BAD_REQUEST;
+
+
+/**
+ * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
+ *
+ * @author Zhougang
+ */
+@Aspect
+@Slf4j
+@AllArgsConstructor
+public class ApiSignatureAspect {
+
+    private final ApiSignatureRedisDAO signatureRedisDAO;
+
+    /**
+     * 获取请求头加签参数 Map
+     *
+     * @param request   请求
+     * @param signature 签名注解
+     * @return signature params
+     */
+    private static SortedMap<String, String> getRequestHeaderMap(ApiSignature signature, HttpServletRequest request) {
+        SortedMap<String, String> sortedMap = new TreeMap<>();
+        sortedMap.put(signature.appId(), request.getHeader(signature.appId()));
+        sortedMap.put(signature.timestamp(), request.getHeader(signature.timestamp()));
+        sortedMap.put(signature.nonce(), request.getHeader(signature.nonce()));
+        return sortedMap;
+    }
+
+    /**
+     * 获取请求参数 Map
+     *
+     * @param request 请求
+     * @return queryParams
+     */
+    private static SortedMap<String, String> getRequestParameterMap(HttpServletRequest request) {
+        SortedMap<String, String> sortedMap = new TreeMap<>();
+        for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
+            sortedMap.put(entry.getKey(), entry.getValue()[0]);
+        }
+        return sortedMap;
+    }
+
+    @Before("@annotation(signature)")
+    public void beforePointCut(JoinPoint joinPoint, ApiSignature signature) {
+        // 1. 验证通过,直接结束
+        if (verifySignature(signature, Objects.requireNonNull(ServletUtils.getRequest()))) {
+            return;
+        }
+
+        // 2. 验证不通过,抛出异常
+        log.error("[beforePointCut][方法{} 参数({}) 签名失败]", joinPoint.getSignature().toString(),
+                joinPoint.getArgs());
+        throw new ServiceException(BAD_REQUEST.getCode(),
+                StrUtil.blankToDefault(signature.message(), BAD_REQUEST.getMsg()));
+    }
+
+    public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
+        // 1.1 校验 Header
+        if (!verifyHeaders(signature, request)) {
+            return false;
+        }
+        // 1.2 校验 appId 是否能获取到对应的 appSecret
+        String appId = request.getHeader(signature.appId());
+        String appSecret = signatureRedisDAO.getAppSecret(appId);
+        Assert.notNull(appSecret, "[appId({})] 找不到对应的 appSecret", appId);
+
+        // 2. 校验签名【重要!】
+        String clientSignature = request.getHeader(signature.sign()); // 客户端签名
+        String serverSignatureString = buildSignatureString(signature, request, appSecret); // 服务端签名字符串
+        String serverSignature = DigestUtil.sha256Hex(serverSignatureString); // 服务端签名
+        if (ObjUtil.notEqual(clientSignature, serverSignature)) {
+            return false;
+        }
+
+        // 3. 将 nonce 记入缓存,防止重复使用(重点二:此处需要将 ttl 设定为允许 timestamp 时间差的值 x 2 )
+        String nonce = request.getHeader(signature.nonce());
+        signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit());
+        return true;
+    }
+
+    /**
+     * 校验请求头加签参数
+     * <p>
+     * 1. appId 是否为空
+     * 2. timestamp 是否为空,请求是否已经超时,默认 10 分钟
+     * 3. nonce 是否为空,随机数是否 10 位以上,是否在规定时间内已经访问过了
+     * 4. sign 是否为空
+     *
+     * @param signature signature
+     * @param request   request
+     * @return 是否校验 Header 通过
+     */
+    private boolean verifyHeaders(ApiSignature signature, HttpServletRequest request) {
+        // 1. 非空校验
+        String appId = request.getHeader(signature.appId());
+        if (StrUtil.isBlank(appId)) {
+            return false;
+        }
+        String timestamp = request.getHeader(signature.timestamp());
+        if (StrUtil.isBlank(timestamp)) {
+            return false;
+        }
+        String nonce = request.getHeader(signature.nonce());
+        if (StrUtil.length(nonce) < 10) {
+            return false;
+        }
+        String sign = request.getHeader(signature.sign());
+        if (StrUtil.isBlank(sign)) {
+            return false;
+        }
+
+        // 2. 检查 timestamp 是否超出允许的范围 (重点一:此处需要取绝对值)
+        long expireTime = signature.timeUnit().toMillis(signature.timeout());
+        long requestTimestamp = Long.parseLong(timestamp);
+        long timestampDisparity = Math.abs(System.currentTimeMillis() - requestTimestamp);
+        if (timestampDisparity > expireTime) {
+            return false;
+        }
+
+        // 3. 检查 nonce 是否存在,有且仅能使用一次
+        return signatureRedisDAO.getNonce(appId, nonce) == null;
+    }
+
+    /**
+     * 构建签名字符串
+     * <p>
+     * 格式为 = 请求参数 + 请求体 + 请求头 + 密钥
+     *
+     * @param signature signature
+     * @param request   request
+     * @param appSecret appSecret
+     * @return 签名字符串
+     */
+    private String buildSignatureString(ApiSignature signature, HttpServletRequest request, String appSecret) {
+        SortedMap<String, String> parameterMap = getRequestParameterMap(request); // 请求头
+        SortedMap<String, String> headerMap = getRequestHeaderMap(signature, request); // 请求参数
+        String requestBody = StrUtil.nullToDefault(ServletUtils.getBody(request), ""); // 请求体
+        return MapUtil.join(parameterMap, "&", "=")
+                + requestBody
+                + MapUtil.join(headerMap, "&", "=")
+                + appSecret;
+    }
+
+}

+ 55 - 0
citu-framework/citu-spring-boot-starter-protection/src/main/java/com/citu/framework/signature/core/redis/ApiSignatureRedisDAO.java

@@ -0,0 +1,55 @@
+package com.citu.framework.signature.core.redis;
+
+import lombok.AllArgsConstructor;
+import org.springframework.data.redis.core.StringRedisTemplate;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * HTTP API 签名 Redis DAO
+ *
+ * @author Zhougang
+ */
+@AllArgsConstructor
+public class ApiSignatureRedisDAO {
+
+    /**
+     * 验签随机数
+     * <p>
+     * KEY 格式:signature_nonce:%s // 参数为 随机数
+     * VALUE 格式:String
+     * 过期时间:不固定
+     */
+    private static final String SIGNATURE_NONCE = "api_signature_nonce:%s:%s";
+    /**
+     * 签名密钥
+     * <p>
+     * HASH 结构
+     * KEY 格式:%s // 参数为 appid
+     * VALUE 格式:String
+     * 过期时间:永不过期(预加载到 Redis)
+     */
+    private static final String SIGNATURE_APPID = "api_signature_app";
+    private final StringRedisTemplate stringRedisTemplate;
+
+    // ========== 验签随机数 ==========
+
+    private static String formatNonceKey(String appId, String nonce) {
+        return String.format(SIGNATURE_NONCE, appId, nonce);
+    }
+
+    public String getNonce(String appId, String nonce) {
+        return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
+    }
+
+    public void setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
+        stringRedisTemplate.opsForValue().set(formatNonceKey(appId, nonce), "", time, timeUnit);
+    }
+
+    // ========== 签名密钥 ==========
+
+    public String getAppSecret(String appId) {
+        return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
+    }
+
+}

+ 6 - 0
citu-framework/citu-spring-boot-starter-protection/src/main/java/com/citu/framework/signature/package-info.java

@@ -0,0 +1,6 @@
+/**
+ * HTTP API 签名,校验安全性
+ *
+ * @see <a href="https://pay.weixin.qq.com/wiki/doc/api/jsapi.php?chapter=4_3>微信支付 —— 安全规范</a>
+ */
+package com.citu.framework.signature;

+ 2 - 1
citu-framework/citu-spring-boot-starter-protection/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

@@ -1,3 +1,4 @@
 com.citu.framework.idempotent.config.CituIdempotentConfiguration
 com.citu.framework.lock4j.config.CituLock4jConfiguration
-com.citu.framework.ratelimiter.config.CituRateLimiterConfiguration
+com.citu.framework.ratelimiter.config.CituRateLimiterConfiguration
+com.citu.framework.signature.config.CituApiSignatureAutoConfiguration

+ 76 - 0
citu-framework/citu-spring-boot-starter-protection/src/test/java/com/citu/framework/signature/core/ApiSignatureTest.java

@@ -0,0 +1,76 @@
+package com.citu.framework.signature.core;
+
+import cn.hutool.core.map.MapUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+
+import com.citu.framework.signature.core.annotation.ApiSignature;
+import com.citu.framework.signature.core.aop.ApiSignatureAspect;
+import com.citu.framework.signature.core.redis.ApiSignatureRedisDAO;
+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 javax.servlet.http.HttpServletRequest;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * {@link ApiSignatureTest} 的单元测试
+ */
+@ExtendWith(MockitoExtension.class)
+public class ApiSignatureTest {
+
+    @InjectMocks
+    private ApiSignatureAspect apiSignatureAspect;
+
+    @Mock
+    private ApiSignatureRedisDAO signatureRedisDAO;
+
+    @Test
+    public void testSignatureGet() throws IOException {
+        // 搞一个签名
+        Long timestamp = System.currentTimeMillis();
+        String nonce = IdUtil.randomUUID();
+        String appId = "xxxxxx";
+        String appSecret = "yyyyyy";
+        String signString = "k1=v1&v1=k1testappId=xxxxxx&nonce=" + nonce + "&timestamp=" + timestamp + "yyyyyy";
+        String sign = DigestUtil.sha256Hex(signString);
+
+        // 准备参数
+        ApiSignature apiSignature = mock(ApiSignature.class);
+        when(apiSignature.appId()).thenReturn("appId");
+        when(apiSignature.timestamp()).thenReturn("timestamp");
+        when(apiSignature.nonce()).thenReturn("nonce");
+        when(apiSignature.sign()).thenReturn("sign");
+        when(apiSignature.timeout()).thenReturn(60);
+        when(apiSignature.timeUnit()).thenReturn(TimeUnit.SECONDS);
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getHeader(eq("appId"))).thenReturn(appId);
+        when(request.getHeader(eq("timestamp"))).thenReturn(String.valueOf(timestamp));
+        when(request.getHeader(eq("nonce"))).thenReturn(nonce);
+        when(request.getHeader(eq("sign"))).thenReturn(sign);
+        when(request.getParameterMap()).thenReturn(MapUtil.<String, String[]>builder()
+                .put("v1", new String[]{"k1"}).put("k1", new String[]{"v1"}).build());
+        when(request.getContentType()).thenReturn("application/json");
+        when(request.getReader()).thenReturn(new BufferedReader(new StringReader("test")));
+        // mock 方法
+        when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
+
+        // 调用
+        boolean result = apiSignatureAspect.verifySignature(apiSignature, request);
+        // 断言结果
+        assertTrue(result);
+        // 断言调用
+        verify(signatureRedisDAO).setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS));
+    }
+
+}

+ 5 - 2
menduner/menduner-system-biz/src/main/java/com/citu/module/menduner/system/controller/app/common/auth/AppMdeAuthController.java

@@ -3,6 +3,8 @@ package com.citu.module.menduner.system.controller.app.common.auth;
 import cn.hutool.core.util.StrUtil;
 import com.citu.framework.common.enums.UserTypeEnum;
 import com.citu.framework.common.pojo.CommonResult;
+import com.citu.framework.idempotent.core.annotation.Idempotent;
+import com.citu.framework.idempotent.core.keyresolver.impl.UserIdempotentKeyResolver;
 import com.citu.framework.security.config.SecurityProperties;
 import com.citu.framework.security.core.util.SecurityFrameworkUtils;
 import com.citu.module.menduner.common.util.LoginUserContext;
@@ -87,14 +89,15 @@ public class AppMdeAuthController {
 
     @PostMapping("/register")
     @Operation(summary = "注册用户并登录")
-    public CommonResult<AppMdeAuthLoginRespVO>  register(@RequestBody @Valid AppMdeAuthSmsRegisterReqVO reqVO) {
+    public CommonResult<AppMdeAuthLoginRespVO> register(@RequestBody @Valid AppMdeAuthSmsRegisterReqVO reqVO) {
         return success(authService.register(reqVO));
     }
 
     @PostMapping("/send-sms-code")
     @Operation(summary = "发送手机验证码")
+    @Idempotent(timeout = 59, keyResolver = UserIdempotentKeyResolver.class)
     public CommonResult<Boolean> sendSmsCode(@RequestBody @Valid AppMdeAuthSmsSendReqVO reqVO) {
-        authService.sendSmsCode(LoginUserContext.getUserId2(),reqVO);
+        authService.sendSmsCode(LoginUserContext.getUserId2(), reqVO);
         return success(true);
     }
 

+ 21 - 0
menduner/menduner-system-biz/src/main/java/com/citu/module/menduner/system/controller/app/common/test/TestController.java

@@ -1,8 +1,10 @@
 package com.citu.module.menduner.system.controller.app.common.test;
 
 import com.citu.framework.common.pojo.CommonResult;
+import com.citu.framework.signature.core.annotation.ApiSignature;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.Data;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.MessageSource;
 import org.springframework.security.crypto.password.PasswordEncoder;
@@ -12,6 +14,7 @@ import org.springframework.web.bind.annotation.*;
 import javax.annotation.Resource;
 import javax.validation.Valid;
 import java.util.Locale;
+import java.util.Map;
 
 import static com.citu.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static com.citu.module.menduner.system.enums.ErrorCodeConstants.MDE_AREA_HOT_EXISTS;
@@ -47,4 +50,22 @@ public class TestController {
     public String hello(@RequestParam("password") String password) {
         return passwordEncoder.encode(password);
     }
+
+
+    @Operation(summary = "防篡改签名测试POST")
+    @PostMapping("/post/signature")
+    @ApiSignature(timeout = 30)
+    public String signatureTest(@RequestBody @Valid Map<String,Object> test) {
+        return "我是POST:"+test;
+    }
+
+    @Operation(summary = "防篡改签名测试GET")
+    @GetMapping("/get/signature")
+    @ApiSignature(timeout = 30)
+    public String signatureTest2(@RequestParam("name") String name) {
+        return "我是GET:"+name;
+    }
+
+
+
 }

+ 0 - 1
menduner/menduner-system-biz/src/main/java/com/citu/module/menduner/system/dal/dataobject/person/PersonInfoDO.java

@@ -96,7 +96,6 @@ public class PersonInfoDO extends TenantBaseDO {
     /**
      * 人才优势
      */
-    @TableField(updateStrategy = FieldStrategy.ALWAYS)
     private String advantage;
     /**
      * 工作经验