写这篇东西的起因特别直白:我接了一个前后端分离的老项目,登录和提交订单的接口全部明文传输。前端把用户手机号、身份证号当查询参数拼在URL里,后端日志里能直接看到完整数据。老板的原话是“来波猛的”,意思就是别再补丁式修修补补,直接把传输链路从明文换成正儿八经的加密方案。这篇文章就是记录这次前后端加密传输数据的完整改造过程,包括前端如何加密、后端如何解密、密钥怎么管理、踩了哪些坑,给同样在“前后端分离项目”里搞“数据加密传输”的人一份能直接抄作业的参考。
我默认读这篇文章的人至少会用Spring Boot和Vue,前后端能正常联调。如果你刚接触“前端传参”和“后端跨域”这类基础问题,建议先把项目跑通再来动加密,否则排查问题时分不清是加密的锅还是接口本身的锅,心态容易崩。
1. 整体设计与思路拆解
1.1 明文的痛点和我踩过的坑
接手的时候我第一件事就是抓包看了几个接口,触目惊心。登录接口的密码用MD5做了一次散列,但MD5早就被撞库撞烂了,而且散列不等于加密,该泄露还是泄露。订单接口更夸张,用户地址、手机号直接在GET请求的Query String里,浏览器的历史记录里都挂着完整数据。还有一次我在排查线上问题,发现后端的access.log里直接打印了用户的身份证号,因为接口把身份证号当作查询参数传进来,日志框架又默认把整个URL打出来了。
这种场景下,仅靠HTTPS是不够的。为什么?因为HTTPS解决的是传输过程中被窃听的问题,但数据到了后端服务器之后,日志、缓存、数据库慢查询日志、第三方埋点都可能把明文数据记录下来。而且很多公司内部链路是HTTP,比如前置Nginx和后端应用之间没走HTTPS,这一段照样裸奔。我见过不止一次,前端已经上了HTTPS,但后端日志里全是明文手机号,等于HTTPS白上。
所以这次改造的核心思路不是“再套一层SSL”,而是“让业务层根本接触不到明文”。前端加密,后端解密,中间即使被截获、被日志记录,也是密文。这才是“钱后端加密传输数据”真正的含义:不是给传输通道加锁,而是让数据本身变成密文。
1.2 选型:为什么选SM4而不是AES
一开始团队里有人提议直接上AES,理由是用例多、资料全。但我否了,原因是:第一,AES的密钥协商要额外设计,前后端写死在代码里的话,一旦泄露就是全军覆没;第二,国密SM4在合规性上更有优势,尤其是涉及个人信息和金融数据的场景,有的等保和行业审查明确要求使用国密算法;第三,SM4的性能和AES差距极小,尤其在短文本加密场景下,体感几乎无差别,没必要为了“熟悉”去选一个未来可能被审查挑战的方案。
我最终选择了SM4-CBC模式,搭配PKCS7填充。为什么用CBC而不是ECB?ECB模式有个经典问题:相同的明文块会产生相同的密文块,数据模式会在密文中显露出来,攻击者可以通过比对块结构判断“这两段明文是否相同”。CBC模式每个块加密前都会和上一个密文块做异或,再加上随机初始向量(IV),同样的明文每次加密结果都不一样,安全性明显更高。
对应的密钥管理方案,我没有把密钥硬编码在前端代码里,而是放在了前端构建时的环境变量中,实际部署时通过Nginx的sub_filter或者后端提供一个临时的配置接口下发。下面我会专门用一章说密钥管理的细节,这块是整幺蛾子最多的地方。
1.3 三段式防线:接口入参、响应返回、链路完整校验
这次改造我做了三条防线,不是只加密请求参数那么简单:
- 入参加密:前端把POST body整体加密(部分GET接口的特殊参数单独处理),后端统一拦截解密,业务层拿到的还是正常对象。
- 响应加密:后端的返回值统一加密回传,前端在axios的响应拦截器里统一解密,页面代码无感知。
- 完整性校验:在密文里附带签名和摘要,后端校验数据是否被篡改,防止中间人改密文(改不了内容但可以改时间戳、重放请求)。
三条线缺一不可。如果只做入参加密,响应数据照样明文,用户敏感信息从后端返回时仍然暴露;如果只做响应加密,请求参数还是裸奔;如果不做完整性校验,攻击者虽然解不开密文,但可以把一段旧请求原样重放,造成重复下单。这个设计思路,放在任何一个“前后端分离项目实战”里都适用,不是只针对我这个项目。
2. 核心细节解析与实操要点
2.1 密钥管理的坑:硬编码是最大的漏洞
这是整个改造里最容易踩坑的地方。最初同事图省事,直接把SM4密钥写在前端的JavaScript代码里,虽然代码经过了webpack打包混淆,但只要用浏览器开发者工具的Sources面板搜索密钥字符串,几分钟就能扒出来。密钥一旦泄露,加密形同虚设。
我最终用的是“构建期注入 + 后端配置下发”的方式:
- 前端在
.env.production里配置VUE_APP_CRYPTO_KEY和VUE_APP_CRYPTO_IV,构建时注入到代码中。 - 后端提供一个
/api/config/crypto接口(需登录态),前端启动时先调用这个接口获取密钥,密钥存放在内存中,不写入localStorage。 - 部署时,Nginx用
sub_filter将前端页面里固定的密钥占位符替换为真正的密钥,这样密钥不会出现在Git仓库中。
听上去有点绕,但实际做起来不复杂。核心原则是:源码仓库里不出现真实密钥,部署环境里通过配置中心或环境变量注入。密钥轮换也要提上日程,我设置的密钥有效期是7天,到期后前端重新拉取配置。这里有一个容易掉的坑:如果密钥轮换时旧请求还没处理完,前后端密钥不一致会导致解密失败,所以我做了“新旧密钥并行期”处理——后端在解密失败时尝试用上一版密钥再解一次,前端则保留旧密钥的缓存,切换时先试新密钥再退回到旧密钥。这套机制在并发高的场景下特别重要。
2.2 网关统一处理还是业务层处理?我选择了网关层
解密逻辑放在哪里是个关键决策。如果放在每一个Controller里,会造成大量重复代码,而且容易漏掉某个接口导致明文数据漏网。我选择了加一个Spring Boot拦截器(HandlerInterceptor),统一处理:
- 前端请求进入Controller之前,拦截器先取body里的密文,解密成明文JSON后重新写入请求流,这样Controller方法签名完全不用改。
- 响应返回之前,通过ResponseBodyAdvice统一对返回对象做加密,前端收到的永远是密文。
- 多线程安全问题必须注意:
RequestBodyAdvice的默认实现是有状态的,我通过ThreadLocal来传递解密后的明文数据,用完立即清理,防止线程池复用导致数据串号。
网关层的另一个好处是,新来的后端同事不需要理解加解密逻辑,他们只管写业务接口,数据安全性由拦截器统一兜底。这个思路和“后端跨域”问题类似——跨域也适合在网关层统一配置,而不是每个接口自己处理。如果你在“若依框架”这类内含多个模块的后端项目上做改造,更应该在公共模块里做统一拦截,而不是每个module写一套。
2.3 前端加密封装:axios拦截器和防调试技巧
前端的核心工作是在请求拦截器里加密数据、在响应拦截器里解密数据。我封装了一个crypto.js模块,对外暴露两个方法:encryptData和decryptData。
import CryptoJS from 'crypto-js' const key = CryptoJS.enc.Utf8.parse(process.env.VUE_APP_CRYPTO_KEY) const iv = CryptoJS.enc.Utf8.parse(process.env.VUE_APP_CRYPTO_IV) export function encryptData(data) { const dataStr = typeof data === 'string' ? data : JSON.stringify(data) const encrypted = CryptoJS.SM4.encrypt(dataStr, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) return encrypted.toString() } export function decryptData(cipherText) { const decrypted = CryptoJS.SM4.decrypt(cipherText, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) return CryptoJS.enc.Utf8.stringify(decrypted) }axios拦截器里这样用:
service.interceptors.request.use(config => { if (config.method === 'post') { config.data = { payload: encryptData(config.data), timestamp: Date.now().toString() } } return config }) service.interceptors.response.use(response => { if (response.data && response.data.payload) { response.data = JSON.parse(decryptData(response.data.payload)) } return response })注意几个细节:
- 加密前用
JSON.stringify把对象转成字符串,否则SM4加密的是对象的toString结果,解密回来会变样。 - 每次请求生成独立的时间戳
timestamp,不仅用于防止完全相同的重放,还在后端校验时间偏差,偏差超过5分钟的请求直接拒绝。 crypto-js在浏览器里是大体积库,建议用webpack的SplitChunks把它单独打成chunk,避免主包过大,拖慢首屏加载。- 前端代码被debugger打断时,加解密逻辑可能会被逐步跟踪,我加了一个简单的防调试循环,但不建议过度,过度会导致用户浏览器卡死,反而伤体验。
3. 实操过程与核心环节实现
3.1 后端加解密工具类的完整实现
后端我用Hutool的SM4工具类做了封装,简洁可靠。核心代码如下:
@Component public class Sm4CryptoUtil { @Value("${crypto.sm4.key}") private String key; @Value("${crypto.sm4.iv}") private String iv; private volatile SM4 sm4; @PostConstruct public void init() { this.sm4 = SmUtil.sm4(key.getBytes(StandardCharsets.UTF_8)); } public String encrypt(String plainText) { return sm4.encryptHex(plainText, iv.getBytes(StandardCharsets.UTF_8)); } public String decrypt(String cipherText) { return sm4.decryptStrHex(cipherText, iv.getBytes(StandardCharsets.UTF_8)); } public String encryptWithRetry(String plainText) { String result = encrypt(plainText); if (result == null || result.length() == 0) { throw new CryptoException("加密结果为空,请检查明文内容"); } return result; } }Hutool的SM4实现默认就是CBC模式,如果你用AES则要在SmUtil.sm4之外额外指定模式和填充。用@PostConstruct初始化SM4实例,是为了避免每个请求都重新创建对象,减少性能损耗。生产环境里,key和iv不要写在application.yml里提交到Git仓库,应该用配置中心(比如Nacos)或者环境变量注入。不要以为写@Value就是安全了,配置文件的来源才是安全的真正关键。
3.2 拦截器实现:请求解密 + 响应加密 + 幂等性校验
拦截器是这一套的核心入口。我做了一个CryptoInterceptor,实现HandlerInterceptor接口,在preHandle里解密请求,在postHandle之后通过ResponseBodyAdvice统一加密响应。这里只展示请求解密部分的逻辑:
@Component public class CryptoInterceptor implements HandlerInterceptor { private static final ThreadLocal<String> DECRYPTED_BODY = new ThreadLocal<>(); @Autowired private Sm4CryptoUtil sm4CryptoUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } // 读取body密文 String cipherBody = getRequestBody(request); if (StringUtils.isBlank(cipherBody)) { throw new CryptoException("请求体不能为空"); } // 校验幂等性摘要 JSONObject bodyJson = JSON.parseObject(cipherBody); String timestamp = bodyJson.getString("timestamp"); if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > 300_000) { throw new CryptoException("请求时间戳过期"); } // 解密payload String plainText = sm4CryptoUtil.decrypt(bodyJson.getString("payload")); // 校验完整性 if (!verifyIntegrity(bodyJson, plainText)) { throw new CryptoException("数据完整性校验失败"); } DECRYPTED_BODY.set(plainText); // 重新包装request,让Controller能正常读取到明文 RequestWrapper requestWrapper = new RequestWrapper(request); requestWrapper.setBody(plainText.getBytes(StandardCharsets.UTF_8)); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { DECRYPTED_BODY.remove(); } private String getRequestBody(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 boolean verifyIntegrity(JSONObject bodyJson, String plainText) { // 前端在payload里追加了一个sign字段,值为对明文内容做的HMAC-SHA256摘要 String expectedSign = bodyJson.getString("sign"); String actualSign = hmacSha256(plainText, secretKey); return expectedSign.equals(actualSign); } }幂等性校验这里我做了两层:时间戳和HMAC。时间戳防重放,HMAC防篡改。HMAC和SM4共用了同一个密钥,这其实不是最优解,理想情况是SM4和HMAC分别用不同的密钥,但从工程复杂度角度考虑,在中小项目里共用一个密钥是权衡后的选择,我心里有数,等后续有精力再拆。
3.3 加密粒度控制:浓缩与拆分
不是所有接口都需要全量加密。有些接口是GET请求,参数直接在URL上,前端不太好改;有些接口返回的是大列表,加密后流量膨胀,反而影响性能。我做了一张表,按场景区分加密策略:
| 场景 | 是否加密 | 原因 |
|---|---|---|
| 登录接口、注册接口 | 加密 | 密码和手机号是敏感数据,绝对不裸奔 |
| 用户信息查询/修改 | 加密 | 涉及身份证、地址 |
| 订单列表(分页) | 加密 | 地址和联系方式是敏感信息 |
| 商品列表(公开数据) | 不用加密 | 加密纯属浪费CPU |
| 图形验证码接口 | 不用加密 | 验证码本来就是给机器看的,加密没有意义 |
| 文件上传接口 | 单独处理 | 体积大,加密后Multipart解析会出问题,一般只对文件名做加密 |
文件上传接口是我踩过的一个特殊坑。如果直接对整个Multipart body做SM4加密,后端解析时会直接报错,因为multipart的boundary结构被破坏了。我最终的做法是:文件名和业务参数走加密JSON,文件本身单独走HTTPS上传,两者用同一个uploadId关联。这个方案既保证了元数据安全,又不会把大文件加密导致内存压力爆炸。如果你处理的是大文件上传,可以进一步用分片加密,但要考虑分片校验和组装的复杂度。
3.4 密钥下发与前后端联调:实测过程中的细节
联调阶段我遇到了整个改造过程中最折磨人的问题:前端加密后的JSON结构后端解析不对。排查了很久发现是前端在构造请求体时,JSON的键顺序不确定,而后端用的是@RequestBody,一旦某个字段缺失,直接解析失败。解决方案是前端固定一个请求体模板:
service.interceptors.request.use(config => { if (config.method === 'post') { config.data = { payload: encryptData(config.data), timestamp: Date.now().toString(), sign: signData(JSON.stringify(config.data)) } } return config })signData在加密前先对原始明文做一次HMAC-SHA256,后端拿密文解密后再验签,能有效防止密文被篡改。这个“先签名再加密、先解密后验签”的顺序是经过反复推敲的,先说结论:先签名后加密是对的。如果先加密再签名,攻击者虽然无法解出明文,但可以把签名对象换成别人的密文,实现“换文攻击”。
另外,联调时前后端都要打开详细的日志。后端在拦截器里打日志时,只打印密文长度和签名结果,绝不能打印明文内容,否则日志泄露比接口泄露更严重。前端在开发环境下可以临时打印明文,但生产环境必须关掉console输出,我用webpack的terser-plugin配置把console.log全部移除。
4. 常见问题与排查技巧实录
4.1 问题排查速查表
我在改造过程中整理了一个速查表,遇到问题先查这块:
| 问题现象 | 可能原因 | 排查命令/手段 | 解决方案 |
|---|---|---|---|
| 前端报“解密结果为空” | 后端响应格式不是{payload:...}结构 | 浏览器Network面板查看原始响应 | 确认ResponseBodyAdvice是否生效,检查注解扫描路径 |
| 后端报“时间戳过期” | 前端与服务器时间不同步 | date命令对比两台机器时间 | 使用NTP时间同步,或放宽偏差阈值 |
| 解密后JSON是乱码 | SM4模式不一致,前端CBC后端ECB | 对比前后端mode参数 | 统一使用CBC模式,IV长度必须是16字节 |
| 响应内容被截断 | 密文太长,Nginx默认body大小限制 | 查看Nginx错误日志 | 在location里改client_max_body_size |
| 请求体为空 | GET请求走了加密逻辑但没加密 | 检查axios拦截器的method判断 | GET请求单独处理,不能走POST加密逻辑 |
解密后数据含有\ufffd | UTF-8编码转换错误 | 查看前后端字符编码设置 | 统一UTF-8,不要在中间环节手动转码 |
| 重复提交订单 | 时间戳在有效期内但请求被重放 | 给每个请求加唯一requestId | 服务端用Redis判断requestId是否已处理过 |
“解密后数据含有\ufffd”这个问题的根因我在日志里查了半小时:Cookie里带了一个非UTF-8的值,导致request.getReader()读取时把字符流搞乱了。后来我统一用request.getInputStream()读字节流,再手动按UTF-8转字符串,彻底解决了。
4.2 日志配置和敏感信息脱敏
这是很多人忽略的一环。接口加了加密以后,如果后端日志里直接打印decryptedData的内容,那加密就白做了。我做了三件事:
- 日志框架配置里,对包含“phone”、“idCard”、“password”关键字的字段自动打码,只保留前3位和后4位,中间用星号填充。
- 全局异常处理器里,对解密失败抛出的异常不打印密文原文,只打印密文长度和请求路径,避免日志被拖库后攻击者拿到密文做离线破解。
- 链路追踪ID(traceId)和密文长度、签名结果绑定打印,便于定位问题是哪个请求导致的,而不用依赖明文内容。
这样调日志就变成了“只记录元数据,不记录数据本身”。如果你的项目引入了“数据备份与恢复”策略,这里再提醒一句:备份文件和日志文件一样,如果包含明文敏感数据,备份文件本身也要加密存储。我在生产环境里见过备份SQL文件整整300GB明文放在对象存储上,权限配置不当的话相当于裸奔给全网看。
4.3 兼容性测试:老版本客户端适配
改造上线时最怕的是客户端没升级,新后端只认密文,老客户端还在发明文。我这个问题在App端尤其明显,因为App发版周期长,用户不更新的话接口直接挂。我的处理方式是:
- 后端拦截器做一个“双模兼容”:尝试按密文解析,如果解析出的JSON里没有
payload字段,则视为明文,直接放行。 - 给明文请求打一个
deprecated标记,在响应头里返回提示,引导前端引导用户升级。 - 设置一个过渡期,比如两周后强制切到密文模式,明文请求直接返回400,并在HTTP头里写明原因
UPGRADE_REQUIRED。
这个兼容模式上线时注意安全:明文请求的敏感数据仍然会被日志记录,所以过渡期内要对日志里的敏感字段加强脱敏,不能因为是兼容期就放松。“前端面试题2026”里经常考察的一个点——“如何优雅地做API版本兼容”,其实这里的双模兼容就是一个答案,只是很多人没机会在实际项目中实践。
4.4 性能实测:加解密损耗和创新点
加密必然带来性能损耗,但损耗在可接受范围内。我在生产环境做了压测,接口平均耗时变化如下:
| 指标 | 改造前 | 改造后 | 差异 |
|---|---|---|---|
| 登录接口平均响应时间 | 82ms | 96ms | +17% |
| 用户信息查询平均响应时间 | 64ms | 78ms | +21% |
| 订单列表(100条)平均响应时间 | 120ms | 145ms | +20% |
| CPU使用率(峰值压测) | 35% | 42% | +7个百分点 |
这个涨幅在可接受范围内,因为SM4在纯软件实现下接近1Gbps的吞吐量,短文本加密的耗时主要在序列化和I/O上,不在算法本身。如果使用硬件加速卡或者指令集优化,还能降一半损耗,但中小项目没必要上这个。
我优化性能的一个小技巧是“积累加密”:后端返回响应时,把多个小字段合并成一个JSON字符串再加密,而不是分成多个小密文。这样减少了加解密函数的调用次数,也减少了密文长度。前端的加密同理,不要对每一个表单字段单独调用encryptData,太浪费。
5. 实测体检与扩展思路
5.1 “加密体检”:你确定真的加密了吗?
做完改造,我最担心的是“自己以为加密了,实际没加密”。我写了一个体检脚本,模拟最真实的攻击路径来测试,如果你的项目也做了加密,建议按这个清单自查:
- 用浏览器开发者工具查看所有POST请求body,确认里面看不到任何明文键名(比如
password、phone等)。 - 点击浏览器历史记录,检查GET参数里有没有身份证号、手机号。
- 模拟中间人攻击:用fiddler或Charles抓包,看响应body是否全是密文代码,能不能在响应里直接搜索到明文关键词。
- 检查后端应用日志和Nginx日志,确认不打印明文请求参数。
- 手工篡改其中一个字节的密文,看后端能不能识别出校验失败,而不是解密出乱码后继续走业务流程。
- 测试重放攻击:抓一个包原样重放,看后端是否拒绝时间戳过期或requestId重复的请求。
这六项如果都能通过,才能说“钱后端加密传输数据”这件事真的落地了。别只看代码写了加密逻辑就觉得安全,安全是测出来的,不是写出来的。
5.2 后续扩展:双因子签名和国密整体改造
这套方案目前覆盖了数据加解密和完整性校验,但还有两个方向值得后续扩展。一个是“非对称密钥协商”:现在前后端共用同一个SM4密钥,一旦前端密钥泄露,整个系统就完了。下一步可以做“SM2 + SM4”混合方案:前端持有SM2公钥,后端持有私钥,每次会话前端生成临时的SM4密钥,用SM2公钥加密后传给后端,后端用SM2私钥解密拿到临时SM4密钥,之后的数据用这个临时密钥加密。这样的好处是即使前端密钥泄露,攻击者也只能解密出来一个会话,历史数据仍然安全。
另一个方向是把传输加密与数据库加密联动起来:前端加密的是传输过程中的数据,后端解密后如果直接写入数据库,那数据库文件泄露时还是明文。可以考虑敏感字段落库前再加密一次,查询时解密,这属于“数据库字段级加密”,和传输加密之间是两层独立防线。但要注意的是,字段加密会让索引、排序、模糊查询全部失效,需要设计旁路索引或使用支持密文检索的中间件,复杂度会上一个台阶。中小项目里,先把传输加密做好,数据库加密量力而行。
5.3 一点踩坑后的个人体会
这次改造给我的最大体会是:加密传输数据这件事,很多时候问题不在算法选型,而在密钥管理和团队习惯。算法选型我反复纠结过多次,但真正上线后被挑战的往往是“密钥存在哪里”“日志里有没有泄露”“旧客户端怎么兼容”。这些问题如果在设计阶段就想清楚,后面能少熬很多夜。
另外一个体会是,别把加密做成一刀切的全量加密。我一开始想的是所有接口全部加密,后来发现有些公开接口(比如商品列表、公告)加密纯粹是浪费CPU,还会让首页加载更慢。合理的做法是按照数据敏感程度分等级,等级高的接口加密,等级低的保持原样。这个“分级治理”的思路,在技术上值不了多少钱,但在工程治理上价值很高。
最后再分享一个小技巧:前后端联调时,把密钥和IV固定成一套开发环境的专用值,别拉到生产配置。开发环境用一套,测试环境用一套,生产环境用一套,三套独立。即使开发环境密钥被翻出来,也不影响生产。这个细节很多人会忽略,等出了问题才后悔当时嫌麻烦没分开。