java每日精进 5.30【HTTP接口签名防篡改】

在 Spring Boot 中实现 HTTP 接口签名(防篡改)

在为第三方提供 HTTP API 时,确保数据传输的安全性和调用方的合法性至关重要。HTTP 接口签名是一种有效的安全机制,可以防止请求数据被篡改并验证调用方的身份。

什么是 HTTP 接口签名?

HTTP 接口签名是一种安全技术,用于:

  • 防止篡改:确保请求参数、头和体在传输过程中未被修改。

  • 验证调用方身份:通过共享密钥确认请求来自合法调用方。

  • 防止重放攻击:确保请求不会被恶意重复使用。

这种机制在微信支付、支付宝等 API 中广泛应用,适用于涉及敏感数据交换的场景。

实现原理

步骤 1:添加依赖

在 pom.xml 中添加依赖。如果使用 Yudao 的 starter:

<dependency>
    <groupId>cn.iocoder.boot</groupId>
    <artifactId>yudao spring-boot-starter-protection</artifactId>
    <version>1.0.0</version>
</dependency>

如果自定义实现,添加以下依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-crypto</artifactId>
    <version>5.8.11</version>
</dependency>

步骤 2:配置 Redis

配置 Spring Boot 连接 Redis,并在 Redis 中存储 appId 和 appSecret:

hset api_signature_app test 123456

步骤 3:创建 @ApiSignature 注解

定义注解:

/**
 * HTTP API 签名注解
 * 用于标识需要进行签名验证的API接口方法或类
 * 通过该注解可以配置签名验证的相关参数,确保API请求的合法性和防篡改性
 */
@Inherited  // 允许子类继承该注解
@Documented  // 将该注解包含在JavaDoc中
@Target({ElementType.METHOD, ElementType.TYPE})  // 可应用于方法或类上
@Retention(RetentionPolicy.RUNTIME)  // 运行时保留,以便通过反射获取
public @interface ApiSignature {

    /**
     * 同一个请求多长时间内有效 默认 60 秒
     * 用于防止重放攻击(Replay Attack)
     * 客户端请求的时间戳与服务端当前时间的差值超过此时间将被拒绝
     */
    int timeout() default 60;

    /**
     * 时间单位,默认为 SECONDS 秒
     * 与timeout字段配合使用,指定超时时间的单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    // ========================== 签名参数 ==========================

    /**
     * 提示信息,签名失败的提示
     *
     * @see GlobalErrorCodeConstants#BAD_REQUEST
     * 当签名验证失败时返回给客户端的错误信息
     */
    String message() default "签名不正确"; // 为空时,使用 BAD_REQUEST 错误提示

    /**
     * 签名字段:appId 应用ID
     * 用于标识调用API的应用身份
     * 通常由服务端分配给客户端的唯一标识符
     */
    String appId() default "appId";

    /**
     * 签名字段:timestamp 时间戳
     * 客户端请求生成签名时的时间戳
     * 格式通常为毫秒级时间戳(Long类型字符串)
     */
    String timestamp() default "timestamp";

    /**
     * 签名字段:nonce 随机数,10 位以上
     * 用于保证每次请求的唯一性
     * 通常为随机字符串,防止重放攻击
     */
    String nonce() default "nonce";

    /**
     * sign 客户端签名
     * 客户端根据特定算法生成的签名值
     * 服务端会使用相同算法验证签名的有效性
     */
    String sign() default "sign";

}

步骤 4:实现 AOP 切面

创建 ApiSignatureAspect 验证签名:

/**
 * 拦截声明了 {@link ApiSignature} 注解的方法,实现API签名验证,防止非法调用和重放攻击
 */
@Aspect
@Slf4j
@AllArgsConstructor
public class ApiSignatureAspect {

    private final ApiSignatureRedisDAO signatureRedisDAO;

    /**
     * 前置通知:拦截标注@ApiSignature注解的方法,进行签名验证
     * @param joinPoint 切点信息
     * @param signature 签名注解实例
     */
    @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()));
    }

    /**
     * 核心签名验证逻辑
     * @param signature 签名注解配置
     * @param request HTTP请求对象
     * @return 验证结果(true-通过,false-失败)
     */
    public boolean verifySignature(ApiSignature signature, HttpServletRequest request) {
        // 1.1 校验请求头基础参数(非空、时间戳有效性、nonce唯一性)
        if (!verifyHeaders(signature, request)) {
            return false;
        }
        
        // 1.2 通过appId从Redis获取对应的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存入Redis(过期时间为超时时间的2倍,兼容时钟偏移)
        String nonce = request.getHeader(signature.nonce());
        signatureRedisDAO.setNonce(appId, nonce, signature.timeout() * 2, signature.timeUnit());
        return true;
    }

    /**
     * 校验请求头中的签名基础参数
     * @param signature 签名注解配置
     * @param request HTTP请求对象
     * @return 校验结果
     */
    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) { // nonce需至少10位
            return false;
        }
        String sign = request.getHeader(signature.sign());
        if (StrUtil.isBlank(sign)) {
            return false;
        }

        // 2. 时间戳有效性校验:允许客户端与服务端存在一定时间差
        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唯一性校验:通过Redis检查是否已被使用
        return signatureRedisDAO.getNonce(appId, nonce) == null;
    }

    /**
     * 构建服务端签名字符串(与客户端签名规则一致)
     * 格式:请求参数&请求体&签名头参数&appSecret
     * @param signature 签名注解配置
     * @param request HTTP请求对象
     * @param 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;
    }

    /**
     * 获取签名相关的请求头参数(排序后)
     * @param signature 签名注解配置
     * @param request HTTP请求对象
     * @return 排序后的请求头参数Map
     */
    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;
    }

    /**
     * 获取请求的查询参数(排序后)
     * @param request HTTP请求对象
     * @return 排序后的查询参数Map
     */
    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;
    }
}

步骤 5:标注 API

在 Controller 方法上添加 @ApiSignature:

@RestController
@RequestMapping("/api/example")
public class ExampleController {
    @GetMapping("/data")
    @ApiSignature(timeout = 30, timeUnit = TimeUnit.MINUTES)
    public String getData() {
        return "安全数据";
    }
}

步骤 6:测试 API

发送带有正确请求头的请求:

GET /api/example/data?pageNo=1&pageSize=10 HTTP/1.1
Host: your-api-host
appId: test
timestamp: 1717494535932
nonce: e7eb4265-885d-40eb-ace3-2ecfc34bd639
sign: 01e1c3df4d93eafc862753641ebfc1637e70f853733684a139f8b630af5c84cd

步骤 7:错误处理

添加全局异常处理:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(e.getMessage());
    }
}

全流程图示

graph TD
A[接收请求参数] --> B[verifyHeaders基础校验]
B --> C{校验通过?}
C -- 否 --> D[返回400错误]
C -- 是 --> E[获取appSecret]
E --> F[构建签名字符串]
F --> G[生成服务端签名]
G --> H{与客户端签名比对?}
H -- 否 --> D
H -- 是 --> I[存储nonce防重放]
I --> J[放行到Controller]
sequenceDiagram
    participant 客户端 as 客户端
    participant Controller as UserController
    participant 签名切面 as ApiSignatureAspect
    participant Redis as Redis
    participant Service as UserService
    
    客户端 ->> Controller: GET /api/user/123
    Controller ->> 签名切面: 调用before通知
    签名切面 ->> 签名切面: 校验请求头参数
    签名切面 ->> Redis: getNonce(appId, nonce)
    Redis -->> 签名切面: 返回null(不存在)
    签名切面 ->> Redis: getAppSecret(appId)
    Redis -->> 签名切面: 返回appSecret
    签名切面 ->> 签名切面: 构建签名字符串
    签名切面 ->> 签名切面: 生成SHA-256签名
    签名切面 ->> 签名切面: 对比客户端签名
    签名切面 ->> Redis: setNonce(appId, nonce, 20, MINUTES)
    签名切面 ->> Controller: 放行
    Controller ->> Service: getUserById(123)
    Service -->> Controller: 返回UserDTO
    Controller -->> 客户端: 返回200 OK

结论

通过 HTTP 接口签名机制,可以有效提升 API 的安全性,防止数据篡改并验证调用方身份。借助 Spring Boot 的 AOP 和 Redis,开发者可以轻松在项目中实现此功能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值