HTTP 接口签名(防篡改)

39 阅读4分钟

本文在原作者基础上解析文章

1. 实现原理

在 Controller 的方法上,添加 @ApiSignature 注解,声明它需要签名。然后,通过 AOP 切面,ApiSignatureAspect 对这些方法进行拦截,校验签名是否正确。

@GetMapping("/can-out/{id}")
@ApiSignature
public CommonResult<Boolean> canOut(@PathVariable("id") String customerNumber) {
    return success(customerService.getOutStockStatus(customerNumber));
}

ApiSignatureAspect 拦截

  1. 注解
/**
 * HTTP API 签名注解
 */
@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";

}
  1. ApiSignatureAspect
/**
 * 拦截声明了 {@link ApiSignature} 注解的方法,实现签名
 */
@Aspect
@Slf4j
@AllArgsConstructor
public class ApiSignatureAspect {

    private final ApiSignatureRedisDAO signatureRedisDAO;

    @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());
        if (BooleanUtil.isFalse(signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit()))) {
            String timestamp = request.getHeader(signature.timestamp());
            log.info("[verifySignature][appId({}) timestamp({}) nonce({}) sign({}) 存在重复请求]", appId, timestamp, nonce, clientSignature);
            throw new ServiceException(GlobalErrorCodeConstants.REPEATED_REQUESTS.getCode(), "存在重复请求");
        }
        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;
    }

    /**
     * 获取请求头加签参数 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;
    }

3.ApiSignatureRedisDAO

/**
 * HTTP API 签名 Redis DAO
 */
@AllArgsConstructor
public class ApiSignatureRedisDAO {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 验签随机数
     * <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";

    // ========== 验签随机数 ==========

    public String getNonce(String appId, String nonce) {
        return stringRedisTemplate.opsForValue().get(formatNonceKey(appId, nonce));
    }

    public Boolean setNonce(String appId, String nonce, int time, TimeUnit timeUnit) {
        return stringRedisTemplate.opsForValue().setIfAbsent(formatNonceKey(appId, nonce), "", time, timeUnit);
    }

    private static String formatNonceKey(String appId, String nonce) {
        return String.format(SIGNATURE_NONCE, appId, nonce);
    }

    // ========== 签名密钥 ==========

    public String getAppSecret(String appId) {
        return (String) stringRedisTemplate.opsForHash().get(SIGNATURE_APPID, appId);
    }
    public void setAppSecret(String appId, String appSecret) {
        stringRedisTemplate.opsForHash().put(SIGNATURE_APPID, appId, appSecret);
    }

单元测试

**
 * {@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 = "jdwl";
        String appSecret = "123456";
        // 准备请求参数
        Map<String, String[]> parameterMap = MapUtil.<String, String[]>builder()
                .put("v1", new String[]{"k1"})
                .put("k1", new String[]{"v1"})
                .build();
        // 准备请求体
        String requestBody = "test";
        
        // 模拟 ApiSignature 注解
        ApiSignature apiSignature = mockApiSignature();

        // 模拟 HttpServletRequest
        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.getParameterMap()).thenReturn(parameterMap);
        when(request.getContentType()).thenReturn("application/json");
        when(request.getReader()).thenReturn(new BufferedReader(new StringReader(requestBody)));

        // 模拟 RedisDAO
        when(signatureRedisDAO.getAppSecret(eq(appId))).thenReturn(appSecret);
        when(signatureRedisDAO.setNonce(eq(appId), eq(nonce), eq(120), eq(TimeUnit.SECONDS))).thenReturn(true);

        // 按照实际代码的逻辑构建签名
        SortedMap<String, String> parameterMapSorted = new TreeMap<>();
        parameterMap.forEach((key, values) -> parameterMapSorted.put(key, values[0]));

        SortedMap<String, String> headerMap = new TreeMap<>();
        headerMap.put("appId", appId);
        headerMap.put("timestamp", String.valueOf(timestamp));
        headerMap.put("nonce", nonce);

        String serverSignatureString = MapUtil.join(parameterMapSorted, "&", "=")
                + requestBody
                + MapUtil.join(headerMap, "&", "=")
                + appSecret;

        String sign = DigestUtil.sha256Hex(serverSignatureString);
        System.out.println("Generated Signature: " + sign);
        
        // 设置请求头中的签名为正确的值
        when(request.getHeader(eq("sign"))).thenReturn(sign);
        boolean result = apiSignatureAspect.verifySignature(apiSignature, request);
        // 断言结果
        assertTrue(result);
    }
    
    // 模拟 ApiSignature 注解
    private ApiSignature mockApiSignature() {
        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);
        return apiSignature;
    }

    

}

HTTP接口示例

         String validAccessToken = oauthAccessTokenRedisDAO.getValidAccessToken();
         long timeMillis = System.currentTimeMillis();
         String uuid = IdUtil.randomUUID();
         String appSecret = "123456";
         // 请求头
         SortedMap<String, String> headerMap = new TreeMap<>();
         headerMap.put("appId", "jdwl");
         headerMap.put("timestamp", String.valueOf(timeMillis));
         headerMap.put("nonce", uuid);
         // 请求参数
         SortedMap<String, String> parameterMap = new TreeMap<>();
         String requestBody ="";
         String serverSignatureString = MapUtil.join(parameterMap, "&", "=")
                 + requestBody
                 + MapUtil.join(headerMap, "&", "=")
                 + appSecret;
         String sign = DigestUtil.sha256Hex(serverSignatureString);
 ​
         CommonResult<BigDecimal> authorization = webClient.get()
                 .uri("/module-api/wms/customer/can-out/".concat("91440300MACP4LRF5K"))
                 .header("Authorization", "Bearer ".concat(validAccessToken))
                 .header("tenant-id", "0")
                 .header("appId", "jdwl")
                 .header("timestamp", String.valueOf(timeMillis))
                 .header("nonce", uuid)
                 .header("sign", sign)
                 .retrieve()
                 .bodyToMono(new ParameterizedTypeReference<CommonResult<BigDecimal>>() {
                 })
                 .block();