首先來看效果
這個主要是為了防止篡改請求的。
我們這里采用的是一個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);@Autowiredprivate 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,需要讀取 bodyif (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@ConditionalOnMissingBeanpublic SecureProperties secureProperties() {return new SecureProperties();}
}
動態配置 當然也可以用靜態的
/*** 安全模塊配置*/
@ConfigurationProperties(prefix = "secure")
public class SecureProperties {/*** AES 密鑰(16/24/32 位)*/// 默認 16 字符,避免 InvalidKeyExceptionprivate 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模塊 https://github.com/xiaou61/U-space
使用流程
在需要的接口上添加注解
@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)// 發送 JSONreturn 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)
注入進去,業務代碼直接拿就行。
整體上,這個方案是為了在不增加太多開發成本的前提下,做到參數不可篡改、請求不可復用、敏感數據不可明文傳輸。
流程圖
高清流程圖
https://yxy7auidhk0.feishu.cn/wiki/LuXjwlXjxiFk4tkgrUEc0Ppbn4n?from=from_copylink