Springboot实现接口加密的示例代码
作者:小u
Springboot实现一个接口加密
首先来看效果
这个主要是为了防止篡改请求的。
我们这里采用的是一个AOP的拦截,在有需要这样的接口上添加了加密处理。
下面是一些功能
防篡改 | HMAC-SHA256 参数签名 | 密钥仅客户端 & 服务器持有 |
---|---|---|
防重放 | 秒级时间戳 + 有效窗口校验 | 默认允许 ±5 分钟 |
防窃听 | AES/CBC/PKCS5Padding 加密业务体 | 对称密钥 16/24/32 字符 |
最小侵入 | Spring AOP + 自定义注解 | @SecureApi 一行即可启用 |
前后端交互流程
前端:在请求拦截器里自动
- 生成
timestamp
- 将业务 JSON → AES 加密得到
data
- 按字典序拼接
timestamp=data
,用 HMAC-SHA256 生成sign
- 生成
后端切面:仅拦截被
@SecureApi
标记的方法/类- 解析三字段 → 校验时间窗口
- 移除
sign
再验签 - 成功后解密
data
→ 注入request.setAttribute("secureData", plaintext)
源码部分
首先是定义一个注解。
/** * 在 Controller 方法或类上添加该注解后,将启用参数签名、时间戳校验和 AES 解密校验。 */ @Target({ ElementType.METHOD, ElementType.TYPE }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface SecureApi { }
最主要的拦截器
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自动配置
@Configuration @ConditionalOnClass(WebMvcConfigurer.class) @AutoConfigureAfter(name = "org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration") public class SecureAutoConfiguration { @Bean @ConditionalOnMissingBean public SecureProperties secureProperties() { return new SecureProperties(); } }
动态配置 当然也可以用静态的
/** * 安全模块配置 */ @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; } }
工具类:
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); } } }
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…
使用流程
在需要的接口上添加注解
@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. 安装依赖
npm i crypto-js
2. 编写工具 (src/utils/secure.js)
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(); }
封装
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 }
调用
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)
注入进去,业务代码直接拿就行。
整体上,这个方案是为了在不增加太多开发成本的前提下,做到参数不可篡改、请求不可复用、敏感数据不可明文传输。
流程图
到此这篇关于Springboot实现接口加密的示例代码的文章就介绍到这了,更多相关Springboot 接口加密内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!