Springboot实现一个接口加密

Springboot实现一个接口加密

首先来看效果

这个主要是为了防止篡改请求的。

我们这里采用的是一个AOP的拦截,在有需要这样的接口上添加了加密处理。

下面是一些功能

防篡改 HMAC-SHA256 参数签名 密钥仅客户端 & 服务器持有
防重放 秒级时间戳 + 有效窗口校验 默认允许 ±5 分钟
防窃听 AES/CBC/PKCS5Padding 加密业务体 对称密钥 16/24/32 字符
最小侵入 Spring AOP + 自定义注解 @SecureApi 一行即可启用

前后端交互流程

  1. 前端:在请求拦截器里自动

    • 生成 timestamp
    • 将业务 JSON → AES 加密得到 data
    • 按字典序拼接 timestamp=data,用 HMAC-SHA256 生成 sign
  2. 后端切面 :仅拦截被 @SecureApi 标记的方法/类

    • 解析三字段 → 校验时间窗口
    • 移除 sign 再验签
    • 成功后解密 data → 注入 request.setAttribute("secureData", plaintext)

源码部分

首先是定义一个注解。

less 复制代码
/**
 * 在 Controller 方法或类上添加该注解后,将启用参数签名、时间戳校验和 AES 解密校验。
 */
@Target({ ElementType.METHOD, ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface SecureApi {
}

最主要的拦截器

java 复制代码
package com.xiaou.secure.aspect;
​
import com.xiaou.secure.exception.SecureException;
import com.xiaou.secure.properties.SecureProperties;
import com.xiaou.secure.util.AESUtil;
import com.xiaou.secure.util.SignUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
​
import java.io.BufferedReader;
import java.io.IOException;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
​
/**
 * 安全校验切面
 */
@Aspect
@Component
public class SecureAspect {
​
    private static final Logger log = LoggerFactory.getLogger(SecureAspect.class);
​
    @Autowired
    private SecureProperties properties;
​
    @Around("@annotation(com.xiaou.secure.annotation.SecureApi)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs == null) {
            return pjp.proceed();
        }
        HttpServletRequest request = attrs.getRequest();
​
        Map<String, String> params = extractParams(request);
​
        // 1. 时间戳校验
        validateTimestamp(params.get("timestamp"));
​
        // 2. 签名校验
        validateSign(params);
​
        // 3. AES 解密 data 字段
        if (params.containsKey("data")) {
            String plaintext = AESUtil.decrypt(params.get("data"), properties.getAesKey());
            // 把解密后的内容放到 request attribute,方便业务层读取
            request.setAttribute("secureData", plaintext);
        }
​
        return pjp.proceed();
    }
​
    private Map<String, String> extractParams(HttpServletRequest request) throws IOException {
        Map<String, String[]> parameterMap = request.getParameterMap();
        Map<String, String> params = new HashMap<>();
        parameterMap.forEach((k, v) -> params.put(k, v[0]));
​
        // 如果没有参数,但可能是 JSON body,需要读取 body
        if (params.isEmpty() && request.getContentType() != null
                && request.getContentType().startsWith("application/json")) {
            String body = readBody(request);
            if (body != null && !body.isEmpty()) {
                try {
                    com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
                    Map<String, Object> jsonMap = mapper.readValue(body, Map.class);
                    jsonMap.forEach((k, v) -> params.put(k, v == null ? null : v.toString()));
                } catch (Exception e) {
                    // 回退到原始 & 分隔的解析方式,兼容 x-www-form-urlencoded 字符串
                    Arrays.stream(body.split("&")).forEach(kv -> {
                        String[] kvArr = kv.split("=", 2);
                        if (kvArr.length == 2) {
                            params.put(kvArr[0], kvArr[1]);
                        }
                    });
                }
            }
        }
        return params;
    }
​
    private String readBody(HttpServletRequest request) throws IOException {
        StringBuilder sb = new StringBuilder();
        try (BufferedReader reader = request.getReader()) {
            String line;
            while ((line = reader.readLine()) != null) {
                sb.append(line);
            }
        }
        return sb.toString();
    }
​
    private void validateTimestamp(String timestampStr) {
        if (timestampStr == null) {
            throw new SecureException("timestamp missing");
        }
        long ts;
        try {
            ts = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            throw new SecureException("timestamp invalid");
        }
        long now = Instant.now().getEpochSecond();
        if (Math.abs(now - ts) > properties.getAllowedTimestampOffset()) {
            throw new SecureException("timestamp expired");
        }
    }
​
    private void validateSign(Map<String, String> params) {
        String sign = params.remove("sign");
        if (sign == null) {
            throw new SecureException("sign missing");
        }
        // 排序
        Map<String, String> sorted = params.entrySet().stream()
                .sorted(Map.Entry.comparingByKey())
                .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> b, LinkedHashMap::new));
        String expected = SignUtil.sign(sorted, properties.getSignSecret());
        if (!Objects.equals(expected, sign)) {
            throw new SecureException("sign invalid");
        }
    }
}

配置方面:

springboot自动配置

less 复制代码
@Configuration
@ConditionalOnClass(WebMvcConfigurer.class)
@AutoConfigureAfter(name = "org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration")
public class SecureAutoConfiguration {
​
    @Bean
    @ConditionalOnMissingBean
    public SecureProperties secureProperties() {
        return new SecureProperties();
    }
}

动态配置 当然也可以用静态的

arduino 复制代码
/**
 * 安全模块配置
 */
@ConfigurationProperties(prefix = "secure")
public class SecureProperties {
​
    /**
     * AES 密钥(16/24/32 位)
     */
    // 默认 16 字符,避免 InvalidKeyException
    private String aesKey = "xiaou-secure-123";
​
    /**
     * 签名密钥
     */
    private String signSecret = "xiaou-sign-secret";
​
    /**
     * 允许的时间差 (秒),默认 300 秒
     */
    private long allowedTimestampOffset = 300;
​
    public String getAesKey() {
        return aesKey;
    }
​
    public void setAesKey(String aesKey) {
        this.aesKey = aesKey;
    }
​
    public String getSignSecret() {
        return signSecret;
    }
​
    public void setSignSecret(String signSecret) {
        this.signSecret = signSecret;
    }
​
    public long getAllowedTimestampOffset() {
        return allowedTimestampOffset;
    }
​
    public void setAllowedTimestampOffset(long allowedTimestampOffset) {
        this.allowedTimestampOffset = allowedTimestampOffset;
    }
}

工具类:

java 复制代码
package com.xiaou.secure.util;
​
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
​
/**
 * AES/CBC/PKCS5Padding 工具类
 */
public class AESUtil {
​
    private static final String AES_CBC_PKCS5 = "AES/CBC/PKCS5Padding";
    private static final String AES = "AES";
​
    private AESUtil() {
    }
​
    public static String encrypt(String data, String key) {
        try {
            Cipher cipher = Cipher.getInstance(AES_CBC_PKCS5);
            SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES);
            IvParameterSpec iv = new IvParameterSpec(key.substring(0, 16).getBytes(StandardCharsets.UTF_8));
            cipher.init(Cipher.ENCRYPT_MODE, skeySpec, iv);
            byte[] encrypted = cipher.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Base64.getEncoder().encodeToString(encrypted);
        } catch (Exception e) {
            throw new RuntimeException("AES encrypt error", e);
        }
    }
​
    public static String decrypt(String cipherText, String key) {
        try {
            Cipher cipher = Cipher.getInstance(AES_CBC_PKCS5);
            SecretKeySpec skeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), AES);
            IvParameterSpec iv = new IvParameterSpec(key.substring(0, 16).getBytes(StandardCharsets.UTF_8));
            cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);
            byte[] original = cipher.doFinal(Base64.getDecoder().decode(cipherText));
            return new String(original, StandardCharsets.UTF_8);
        } catch (Exception e) {
            throw new RuntimeException("AES decrypt error", e);
        }
    }
}
java 复制代码
package com.xiaou.secure.util;
​
import org.apache.commons.codec.digest.HmacAlgorithms;
import org.apache.commons.codec.digest.HmacUtils;
​
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.StringJoiner;
​
/**
 * 签名工具类
 */
public class SignUtil {
​
    private SignUtil() {
    }
​
    /**
     * 生成签名
     * 
     * @param params 不包含 sign 的参数 map,已按字典序排序
     * @param secret 秘钥
     */
    public static String sign(Map<String, String> params, String secret) {
        StringJoiner sj = new StringJoiner("&");
        params.forEach((k, v) -> sj.add(k + "=" + v));
        String data = sj.toString();
        return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secret.getBytes(StandardCharsets.UTF_8)).hmacHex(data);
    }
}

以上就是全部源码

如果想要看具体的一个实现可以参考我的开源项目里面的xiaou-common-secure模块 github.com/xiaou61/U-s...

使用流程

在需要的接口上添加注解

less 复制代码
    @SecureApi                // 生效!
    @PostMapping("/student/save")
    public R<Void> saveStudent(HttpServletRequest request) {
        String json = (String) request.getAttribute("secureData"); // 解密后明文
        StudentDTO dto = JSON.parseObject(json, StudentDTO.class);
        //其他业务操作
        return R.ok();
    }
}

前端接入

1. 安装依赖

css 复制代码
npm i crypto-js

2. 编写工具 (src/utils/secure.js)

javascript 复制代码
import CryptoJS from 'crypto-js';
​
const AES_KEY  = import.meta.env.VITE_AES_KEY;      // 16/24/32 字符,与后端保持一致
const SIGN_KEY = import.meta.env.VITE_SIGN_SECRET;  // 与后端 sign-secret 一致
​
// AES/CBC/PKCS5Padding 加密 → Base64
export function aesEncrypt(plainText) {
  const key = CryptoJS.enc.Utf8.parse(AES_KEY);
  const iv  = CryptoJS.enc.Utf8.parse(AES_KEY.slice(0, 16));
  const encrypted = CryptoJS.AES.encrypt(plainText, key, {
    iv,
    mode: CryptoJS.mode.CBC,
    padding: CryptoJS.pad.Pkcs7
  });
  return encrypted.ciphertext.toString(CryptoJS.enc.Base64);
}
​
// 生成签名:字典序拼接后做 HMAC-SHA256
export function sign(params) {
  const sortedStr = Object.keys(params)
    .sort()
    .map(k => `${k}=${params[k]}`)
    .join('&');
  return CryptoJS.HmacSHA256(sortedStr, SIGN_KEY).toString();
}

封装

javascript 复制代码
import http from './request'
import { aesEncrypt, sign as genSign } from './secure'
​
// securePost 重新实现:封装 { timestamp, data: cipher, sign }
​
export async function securePost (url, bizData = {}, { encrypt = true } = {}) {
  const timestamp = Math.floor(Date.now() / 1000) // 秒级时间戳,和后端配置一致
​
  // 若开启加密,将 bizData 加密为 Base64 字符串
  const cipherText = encrypt ? aesEncrypt(bizData) : JSON.stringify(bizData)
​
  // 组装待签名参数
  const payload = {
    timestamp,
    data: cipherText
  }
​
  // 生成签名
  payload.sign = genSign(payload)
​
  // 发送 JSON
  return http.post(url, payload, {
    headers: {
      'Content-Type': 'application/json'
    }
  })
}
​
// 向后兼容:导出旧别名
export { securePost as securePostV2 } 

调用

kotlin 复制代码
export const login = (data) => {
  // 学生登录接口使用新的 securePost (AES/CBC + HMAC-SHA256)
  return securePost('/student/auth/login', data)
}

原理解析

这个接口加密机制的出发点其实很简单:

我们不希望别人伪造请求或者直接看到请求内容。尤其是在登录、提交表单这种接口上,如果不做处理,参数一旦被篡改或者被抓包,后果可能挺严重。

所以我们在请求中加了一些"安全三件套":

第一是签名 。前端每次发请求的时候,会把参数(主要是 timestamp 和加密后的 data)按字典序拼起来,然后用我们双方约定好的一个密钥生成一个签名(HMAC-SHA256 算法 )。后端拿到请求后,同样的算法再生成一遍签名,两个对不上就直接拒绝。这个方式能有效防止参数被篡改。

第二是时间戳 。我们不允许别人把一两分钟前抓到的请求再发一次,所以前端在请求里带上当前时间(秒级)。后端检查这个时间是否还在允许的时间窗口(比如前后 5 分钟)内,超了就拒绝。这个能防止重放攻击。

第三是加密 。我们不希望别人看到业务参数,比如手机号、密码、验证码这类字段,所以前端用 AES(CBC 模式)把整个业务数据 JSON 加密成密文,后端收到后再解密拿出真实参数。密钥是我们自己设定的,别人拿不到。

整套逻辑通过 Spring AOP 实现,不需要每个接口去写重复代码,只要在 Controller 上加一个 @SecureApi 注解就行了。请求数据校验通过后,解密出来的原始 JSON 会通过 request.setAttribute("secureData", plaintext) 注入进去,业务代码直接拿就行。

整体上,这个方案是为了在不增加太多开发成本的前提下,做到参数不可篡改、请求不可复用、敏感数据不可明文传输。

流程图

高清流程图

yxy7auidhk0.feishu.cn/wiki/LuXjwl...

相关推荐
掘金码甲哥1 小时前
Golang 文本模板,你指定没用过!
后端
lwb_01181 小时前
【springcloud】快速搭建一套分布式服务springcloudalibaba(四)
后端·spring·spring cloud
张先shen3 小时前
Spring Boot集成Redis:从配置到实战的完整指南
spring boot·redis·后端
Dolphin_海豚3 小时前
一文理清 node.js 模块查找策略
javascript·后端·前端工程化
EyeDropLyq4 小时前
线上事故处理记录
后端·架构
MarkGosling6 小时前
【开源项目】网络诊断告别命令行!NetSonar:开源多协议网络诊断利器
运维·后端·自动化运维
Codebee6 小时前
OneCode3.0 VFS分布式文件管理API速查手册
后端·架构·开源
_新一6 小时前
Go 调度器(二):一个线程的执行流程
后端
estarlee6 小时前
腾讯云轻量服务器创建镜像免费API接口教程
后端
风流 少年7 小时前
Cursor创建Spring Boot项目
java·spring boot·后端