前后端API签名验证实战:HMAC-SHA256在若依与uni-app中的防篡改实现
1. 项目概述:为什么签名验证是前后端分离的“守门员”
最近在重构一个基于若依框架的移动端项目,前端用的是 uni-app,后端自然是 Spring Boot。项目上线前做安全审计,接口裸奔的问题被拎了出来。所谓的“裸奔”,就是 API 没有任何防篡改、防重放的机制,只要拿到 URL,谁都能调。这显然不行。于是,给所有关键接口加上签名验证就成了必须完成的任务。
签名验证,听起来高大上,其实核心逻辑就一句话:确保请求来自合法的客户端,且传输的数据在途中没有被篡改。它就像你家小区的门禁,访客(请求)需要出示一个由系统(客户端)生成、且物业(服务端)能验证的“通行证”(签名)。这个通行证里包含了访客身份(App标识)、来访时间(时间戳)、以及要访问的具体门牌号(请求参数)等信息,一旦任何一项被涂改,门禁就会报警(验证失败)。
在若依这种成熟的后端框架上做,很多人会直接想到用 Spring 拦截器(Interceptor)来统一处理。前端 uni-app 那边,则需要封装一个通用的请求方法,在每次发起网络请求前,自动计算并带上签名。整个流程看似清晰,但实操起来,从参数排序的坑,到时间戳同步的雷,再到签名算法一致性的魔鬼细节,每一步都可能让你调试到怀疑人生。这篇文章,我就结合这次实战,把从 uni-app 封装到 Spring 拦截器实现的完整流程,以及最关键的三个“坑”给你拆解明白。
2. 核心思路与方案选型:为什么选“签名”而非“Token”?
在动手之前,我们先要厘清一个基本问题:已经有 JWT Token 做认证了,为什么还要签名验证?它们俩分工不同。Token(如 JWT)解决的是“你是谁”(认证与授权)的问题,它标识了用户的身份和权限。而签名验证解决的是“你的请求是否可信且完整”(防篡改与防重放)的问题。一个已登录的用户,其 Token 可能被恶意截获,攻击者依然可以用这个 Token 伪造或重复发送请求。签名验证正是为了堵上这个漏洞。
2.1 签名验证的核心要素
一个健壮的签名验证方案,通常包含以下几个要素:
- App ID 与 App Secret:这是客户端和服务端的共享密钥。App ID 公开,用于标识客户端身份;App Secret 绝对保密,存储在服务端和客户端安全的位置(如客户端代码混淆、服务端配置中心),是生成和验证签名的密钥。
- 时间戳(Timestamp):用于防止重放攻击。请求必须在一定时间窗口内(如5分钟)才被接受,过期的请求直接拒绝。
- 随机数(Nonce):一个一次性使用的随机字符串,同样用于防重放。服务端需要缓存一段时间内(如时间窗口)使用过的 Nonce,重复的则拒绝。
- 签名算法:将请求的所有关键要素(如 App ID、时间戳、随机数、请求参数等)按照既定规则拼接成一个字符串,然后用 App Secret 通过某种哈希算法(如 HMAC-SHA256)计算得出签名(Signature)。
2.2 方案选型:我们为什么这么设计
市面上有很多签名方案,比如简单的 MD5(参数+密钥),或者更复杂的 RSA 非对称加密。我们选择了HMAC-SHA256作为签名算法,理由如下:
- 性能与安全性平衡:HMAC(密钥散列消息认证码)是专门为消息认证设计的结构,能有效防止长度扩展攻击,比简单的
MD5(参数+密钥)更安全。SHA-256 哈希算法目前仍是安全的标配。相比 RSA,HMAC 的计算速度更快,更适合 API 高频调用的场景。 - 对称加密,简单高效:只需要一个共享的 App Secret,无需管理复杂的公私钥对,部署和验证逻辑相对简单。
- 若依生态兼容:若依框架本身没有提供现成的签名验证拦截器,但其基于 Spring Security 的认证体系和清晰的拦截器机制,让我们可以无缝集成自定义的签名验证逻辑,不会与原有的 Token 认证流冲突。
我们的流程设计如下:
- uni-app 客户端:在发起请求前,收集 App ID、时间戳、随机数、请求参数(GET的 Query 或 POST 的 Body),按规则排序拼接,使用 App Secret 通过 HMAC-SHA256 生成签名。将 App ID、时间戳、随机数和签名放入 HTTP 请求头(Header)中。
- Spring Boot 服务端:编写一个拦截器,在请求进入 Controller 之前,从 Header 中取出上述信息。然后根据 App ID 查找到对应的 App Secret,用同样的规则拼接字符串并计算签名。对比客户端传来的签名和服务端计算的签名是否一致,同时校验时间戳和随机数的有效性。
这个流程的成败,关键在于前后端对“规则”的绝对一致。而这个“一致”,恰恰是坑最多的地方。
3. 避坑实战一:uni-app 请求封装与签名生成
前端是签名生成的起点,这里的不严谨会导致服务端永远验证失败。我们基于 uni-app 的uni.request进行封装。
3.1 基础请求封装
首先,创建一个request.js模块,目的是统一处理请求基础配置、签名添加和响应拦截。
// utils/request.js import crypto from ‘crypto-js‘; // 引入加密库,需通过npm安装 crypto-js const APP_ID = ‘your_app_id_here‘; // 从项目配置或构建环境变量中读取,切勿硬编码 const APP_SECRET = ‘your_app_secret_here‘; // 同上,至关重要! const API_BASE_URL = ‘https://your-api-domain.com‘; const TIMESTAMP_EXPIRY = 5 * 60 * 1000; // 签名有效期5分钟 // 生成指定长度的随机字符串作为 Nonce function generateNonce(length = 16) { const chars = ‘ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678‘; let nonce = ‘‘; for (let i = 0; i < length; i++) { nonce += chars.charAt(Math.floor(Math.random() * chars.length)); } return nonce; } // **核心函数:生成签名** function generateSignature(params, timestamp, nonce) { // 坑1:参数排序必须规范 // 将请求参数(对象格式)转换为按key字母顺序排序的数组 const sortedParams = Object.keys(params) .sort() .map(key => `${key}=${params[key]}`) .join(‘&‘); // 构建待签名字符串,格式必须与服务端严格一致 // 常见格式:appId + timestamp + nonce + sortedParams const stringToSign = `appId=${APP_ID}×tamp=${timestamp}&nonce=${nonce}&${sortedParams}`; // 使用 HMAC-SHA256 计算签名,并以 HEX 格式输出 const signature = crypto.HmacSHA256(stringToSign, APP_SECRET).toString(crypto.enc.Hex); return signature; } export const http = { request(config) { const { url, method = ‘GET‘, data = {}, header = {} } = config; const timestamp = Date.now(); // 当前时间戳 const nonce = generateNonce(); // 区分 GET 和 POST 的参数处理 let requestParams = {}; if (method.toUpperCase() === ‘GET‘) { // GET 请求,参数在 URL 的 query 中,我们需要将其对象化用于签名 // 注意:uni.request 的 GET 请求参数放在 data 字段,最终会拼接到 URL requestParams = data; } else { // POST/PUT/DELETE 请求,参数在请求体 requestParams = data; } // 生成签名 const signature = generateSignature(requestParams, timestamp, nonce); // 设置签名相关的请求头 const signedHeaders = { ‘X-App-Id‘: APP_ID, ‘X-Timestamp‘: timestamp.toString(), ‘X-Nonce‘: nonce, ‘X-Signature‘: signature, ‘Content-Type‘: ‘application/json‘, ...header, }; return new Promise((resolve, reject) => { uni.request({ url: API_BASE_URL + url, method, data: method.toUpperCase() === ‘GET‘ ? requestParams : data, // GET 参数放 data,POST 参数放 data header: signedHeaders, success: (res) => { // 这里可以添加统一的响应处理,如 token 过期跳登录等 if (res.statusCode === 200) { resolve(res.data); } else { reject(res); } }, fail: (err) => { reject(err); }, }); }); }, // 可以继续封装 get, post 等方法方便调用 get(url, data = {}) { return this.request({ url, method: ‘GET‘, data }); }, post(url, data = {}) { return this.request({ url, method: ‘POST‘, data }); }, };3.2 第一个坑:参数排序与空值处理
这是签名失败的最高发原因。generateSignature函数中,sortedParams的生成是关键。规则是:将所有待签名的参数(键值对)按照参数名的 ASCII 码从小到大排序(字典序),然后使用 URL 键值对的格式(即 key1=value1&key2=value2…)拼接成字符串。
注意事项:
- 排序规则必须一致:前端按
Object.keys(params).sort()排序,后端也必须用同样的排序逻辑(如 Java 的TreeMap或对keySet()进行Collections.sort())。 - 空参数如何处理?这是最容易出歧义的地方。我们的约定是:值为
null或undefined的参数不参与签名。在上面的代码中,params[key]如果是空值,拼接后会是key=或key=null,这会导致前后端不一致。更安全的做法是在排序前过滤掉空值。const sortedParams = Object.keys(params) .filter(key => params[key] != null) // 过滤 null 和 undefined .sort() .map(key => `${key}=${params[key]}`) .join(‘&‘); - 嵌套对象与数组:对于复杂的 JSON 参数,需要定义统一的序列化规则。通常建议前端在传参前,将嵌套对象 JSON.stringify 后作为一个字符串值传递,或者将其扁平化为带路径的键。例如
{user: {name: ‘a‘}}可以转换为user.name=a。前后端必须约定死规则,否则签名必然对不上。
4. 避坑实战二:Spring Boot 拦截器实现与验证逻辑
服务端是验证的守门员,逻辑必须严谨且高效。我们在若依框架中新增一个拦截器。
4.1 创建签名验证拦截器
首先,创建一个SignatureInterceptor类实现HandlerInterceptor接口。
package com.ruoyi.framework.interceptor; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.sign.SignatureUtils; import com.ruoyi.framework.manager.AsyncManager; import com.ruoyi.framework.manager.factory.AsyncFactory; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.util.*; @Component @Slf4j public class SignatureInterceptor implements HandlerInterceptor { @Value("${signature.appSecretMap}") // 从配置读取 AppId-Secret 映射,格式:appId1:secret1,appId2:secret2 private String appSecretConfig; private static final String APP_ID_HEADER = "X-App-Id"; private static final String TIMESTAMP_HEADER = "X-Timestamp"; private static final String NONCE_HEADER = "X-Nonce"; private static final String SIGNATURE_HEADER = "X-Signature"; private static final long TIMESTAMP_EXPIRY = 5 * 60 * 1000L; // 5分钟有效期 // 缓存已使用的 Nonce,防止重放。生产环境应使用 Redis 并设置过期时间。 private final Set<String> usedNonceCache = Collections.synchronizedSet(new HashSet<>()); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 获取签名头信息 String appId = request.getHeader(APP_ID_HEADER); String timestampStr = request.getHeader(TIMESTAMP_HEADER); String nonce = request.getHeader(NONCE_HEADER); String clientSignature = request.getHeader(SIGNATURE_HEADER); // 2. 基础校验:头信息是否存在 if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(timestampStr) || StringUtils.isEmpty(nonce) || StringUtils.isEmpty(clientSignature)) { log.warn("签名验证失败:缺少必要的签名头信息。AppId:{}, Timestamp:{}, Nonce:{}, Signature:{}", appId, timestampStr, nonce, clientSignature); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("{\"code\": 400, \"msg\": \"Missing signature headers.\"}"); return false; } // 3. 校验时间戳 long timestamp; try { timestamp = Long.parseLong(timestampStr); } catch (NumberFormatException e) { log.warn("签名验证失败:时间戳格式错误。Timestamp:{}", timestampStr); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write("{\"code\": 400, \"msg\": \"Invalid timestamp format.\"}"); return false; } long currentTime = System.currentTimeMillis(); if (Math.abs(currentTime - timestamp) > TIMESTAMP_EXPIRY) { log.warn("签名验证失败:请求已过期。Timestamp:{}, CurrentTime:{}", timestamp, currentTime); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\": 401, \"msg\": \"Request expired.\"}"); return false; } // 4. 校验 Nonce 是否重复(防重放) if (usedNonceCache.contains(nonce)) { log.warn("签名验证失败:Nonce 重复使用。Nonce:{}", nonce); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\": 401, \"msg\": \"Nonce reused.\"}"); return false; } // 临时加入缓存,后续验证通过后再决定是否永久记录(或放入短期缓存) usedNonceCache.add(nonce); // 生产环境建议:将 nonce 和 timestamp 作为键存入 Redis,设置过期时间为 TIMESTAMP_EXPIRY // 5. 根据 AppId 获取对应的 AppSecret String appSecret = getAppSecretById(appId); if (StringUtils.isEmpty(appSecret)) { log.warn("签名验证失败:无效的 AppId。AppId:{}", appId); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\": 401, \"msg\": \"Invalid AppId.\"}"); usedNonceCache.remove(nonce); // 移除临时加入的 nonce return false; } // 6. 获取所有请求参数,并构建待签名字符串 Map<String, String> allParams = getAllRequestParams(request); // 坑2:服务端构建签名字符串的逻辑必须与前端完全一致 String serverSignature = SignatureUtils.generateSignature(allParams, appId, timestamp, nonce, appSecret); // 7. 比较签名 if (!serverSignature.equalsIgnoreCase(clientSignature)) { log.warn("签名验证失败:签名不匹配。ClientSig:{}, ServerSig:{}, Params:{}", clientSignature, serverSignature, allParams); // 记录可疑请求到日志或异步队列,用于安全审计 AsyncManager.me().execute(AsyncFactory.recordSignFailLog(request, appId, clientSignature, serverSignature)); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().write("{\"code\": 401, \"msg\": \"Invalid signature.\"}"); usedNonceCache.remove(nonce); // 验证失败,移除 nonce return false; } // 8. 签名验证通过 log.debug("签名验证通过。AppId:{}, Path:{}", appId, request.getRequestURI()); // 可以将 AppId 等信息存入请求属性,供后续业务使用 request.setAttribute("APP_ID", appId); return true; } /** * 从配置中获取 AppSecret */ private String getAppSecretById(String appId) { // 简单演示,实际应从数据库或配置中心安全获取 // 格式:appId1:secret1,appId2:secret2 if (StringUtils.isNotEmpty(appSecretConfig)) { String[] pairs = appSecretConfig.split(","); for (String pair : pairs) { String[] keyValue = pair.split(":"); if (keyValue.length == 2 && keyValue[0].trim().equals(appId)) { return keyValue[1].trim(); } } } return null; } /** * 获取所有请求参数(GET的Query和POST的Form/Body) * 注意:对于 application/json 的 POST 请求,需要从 body 中读取 */ private Map<String, String> getAllRequestParams(HttpServletRequest request) { Map<String, String> params = new HashMap<>(); // 1. 获取 URL 查询参数 Enumeration<String> paramNames = request.getParameterNames(); while (paramNames.hasMoreElements()) { String paramName = paramNames.nextElement(); // 过滤掉签名相关的参数,这些参数在 Header 里,不应参与 body/query 的签名计算 if (!isSignatureHeader(paramName)) { params.put(paramName, request.getParameter(paramName)); } } // 2. 处理 POST JSON 请求体 (需要额外处理,见下文注意事项) // 这里是一个简化示例。完整处理需要读取 request.getInputStream() 并解析 JSON。 // 建议使用 @RequestBody 注解的 Controller 方法,在拦截器之后获取解析好的对象。 // 更常见的做法是:约定签名参数只来自 URL Query 和 Form Data,JSON Body 内容作为一个整体参与签名(如将整个JSON字符串作为一个参数值)。 // 本例中,我们假设主要参数来自 Query 或 Form。 return params; } private boolean isSignatureHeader(String paramName) { return paramName.startsWith("X-"); } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 请求完成后,可以清理资源,例如将已验证成功的 nonce 正式持久化 // 或者对于验证失败的 nonce,从临时缓存中移除(已在失败分支处理) } }4.2 第二个坑:请求体(Body)参数的读取与签名
这是拦截器实现中最棘手的问题。HttpServletRequest的getParameter()方法只能获取到 URL 查询字符串和application/x-www-form-urlencoded格式的 POST 参数。对于application/json格式的请求体,参数是放在输入流(InputStream)里的。
问题:拦截器一旦读取了request.getInputStream(),后续的 Controller 方法(使用@RequestBody)就无法再读取到数据了,因为流只能读一次。
解决方案(常见实践):
- 使用 ContentCachingRequestWrapper:Spring 提供了这个包装类,可以缓存请求体数据,允许多次读取。你可以在一个过滤器(Filter)中提前包装请求,然后在拦截器中读取缓存的 body。
- 调整签名策略:约定签名参数仅来自URL 查询参数(Query String)和HTTP 头(Header),而 JSON 请求体不直接参与签名。但为了防篡改,可以将整个 JSON 请求体的字符串的 MD5 或 SHA-256 哈希值作为一个特殊的参数(比如叫
bodyHash)放到请求头或查询参数中,参与签名计算。这样既能验证 Body 的完整性,又避免了读取流的麻烦。 - 在 Controller 层做二次验证:拦截器只验证 Header 和 Query 中的签名基础信息。在 Controller 方法收到
@RequestBody对象后,再根据业务需要,计算其哈希值与请求头中传来的bodyHash比对。这种方式将部分验证逻辑后置。
在我们的工具类SignatureUtils中,需要实现与前端完全一致的签名生成逻辑:
// com.ruoyi.common.utils.sign.SignatureUtils package com.ruoyi.common.utils.sign; import org.apache.commons.codec.digest.HmacUtils; import java.util.Map; import java.util.TreeMap; public class SignatureUtils { /** * 生成服务端签名 * @param params 请求参数Map (必须过滤掉签名头本身的参数) * @param appId * @param timestamp * @param nonce * @param appSecret * @return */ public static String generateSignature(Map<String, String> params, String appId, long timestamp, String nonce, String appSecret) { // 1. 使用 TreeMap 对参数名进行自动排序(字典序) Map<String, String> sortedParams = new TreeMap<>(params); // 2. 构建键值对字符串 StringBuilder paramString = new StringBuilder(); for (Map.Entry<String, String> entry : sortedParams.entrySet()) { String value = entry.getValue(); // 坑3:空值处理必须与前端约定一致!这里我们约定空字符串也参与签名(value为"") if (value == null) { value = ""; // 或选择跳过此参数,必须与前端一致 } if (paramString.length() > 0) { paramString.append("&"); } paramString.append(entry.getKey()).append("=").append(value); } // 3. 构建最终的待签名字符串,格式必须与前端完全一致 // 例如:appId=xxx×tamp=xxx&nonce=xxx&key1=value1&key2=value2 String stringToSign = String.format("appId=%s×tamp=%s&nonce=%s&%s", appId, timestamp, nonce, paramString.toString()); // 4. 使用 Apache Commons Codec 的 HmacUtils 计算 HMAC-SHA256 // 注意:前端 crypto-js 输出的 Hex 是小写,这里也保持小写 String signature = HmacUtils.hmacSha256Hex(appSecret, stringToSign); return signature; } }4.3 注册拦截器
最后,将拦截器注册到 Spring MVC 的拦截器链中。在若依框架中,通常有WebMvcConfig或类似的配置类。
package com.ruoyi.framework.config; import com.ruoyi.framework.interceptor.SignatureInterceptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class SignatureConfig implements WebMvcConfigurer { @Autowired private SignatureInterceptor signatureInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { // 添加签名验证拦截器,并配置拦截路径和排除路径 registry.addInterceptor(signatureInterceptor) .addPathPatterns("/api/**") // 拦截所有 /api 开头的接口 .excludePathPatterns("/api/auth/login", "/api/captchaImage") // 排除登录、验证码等无需签名的接口 .order(1); // 设置拦截器顺序,通常在认证拦截器之前 } }5. 避坑实战三:联调与生产环境下的魔鬼细节
当代码写完,本地测试通过后,真正的挑战才刚刚开始。
5.1 第三个坑:时间戳同步与容错
前端使用Date.now()生成时间戳,后端用System.currentTimeMillis()比较。这两者都是基于客户端和服务器各自的系统时间。
问题:如果用户手机时间不准,或者服务器之间存在微小的时间差,即使请求是即时的,也可能因为超出时间窗口而被拒绝。
解决方案:
- 放宽时间窗口:将
TIMESTAMP_EXPIRY适当调大,比如从 5 分钟调到 10 分钟。但这会略微降低防重放攻击的安全性。 - 引入时间漂移容错:在验证时,不仅检查
|serverTime - clientTime| < expiry,还可以额外允许一个小的正向漂移(比如允许客户端时间比服务器快 2 分钟)。因为网络延迟通常只会导致客户端时间戳“更旧”。 - 使用 NTP 同步:确保服务器时间与标准时间同步。对于客户端,可以在 App 启动时向服务器发起一个简单的“时间同步”请求,获取服务器时间,并计算本地与服务器的时间差,在生成签名时进行修正。但这增加了复杂度。
- 实践建议:对于内部或用户可控的 App,优先保证服务器时间准确,并将时间窗口设置为一个合理的值(如 5-10 分钟)。在拦截器的错误响应中,给出明确的提示,如 “签名过期”,方便客户端排查是否是时间问题。
5.2 Nonce 存储与分布式环境
我们示例中用了内存Set来缓存已使用的 Nonce,这在单机部署时勉强可用,但在生产环境,尤其是分布式、多副本部署时,根本行不通。一个 Nonce 可能在副本 A 验证通过,但在副本 B 的缓存中不存在,导致重放请求在 B 上被放过。
解决方案:使用 Redis。将 Nonce 作为 Key,其值为时间戳(或简单的 1),并设置过期时间(TIMESTAMP_EXPIRY)。验证时,使用 Redis 的SET key value NX EX seconds命令。这个命令是原子性的:只有 Key 不存在时才会设置成功并返回 OK,同时设置过期时间。如果返回null,说明 Nonce 已存在(重复),验证失败。
// 在拦截器中替换内存 Set 操作 String redisKey = "sign:nonce:" + nonce; // 使用 RedisTemplate Boolean isSet = redisTemplate.opsForValue().setIfAbsent(redisKey, String.valueOf(timestamp), TIMESTAMP_EXPIRY, TimeUnit.MILLISECONDS); if (Boolean.FALSE.equals(isSet)) { // Nonce 已存在,重放攻击 log.warn("签名验证失败:Nonce 重复使用(Redis)。Nonce:{}", nonce); // ... 返回错误 }5.3 签名失败日志与监控
签名验证失败是重要的安全事件。不能仅仅返回一个 401 就了事。需要详细记录日志,包括:AppId、请求 IP、URL、客户端签名、服务端计算出的签名、所有参数、时间戳等。这些日志有助于:
- 排查问题:当客户端报告签名失败时,可以通过日志快速定位是参数问题、时间问题还是密钥问题。
- 安全审计:分析大量的签名失败请求,可能发现爬虫或攻击行为。 我们的拦截器示例中已经通过
AsyncManager异步记录了失败日志,这是一个好习惯,避免同步写日志影响接口响应速度。
6. 完整流程回顾与核心检查清单
让我们把整个流程串起来,并给出一个部署上线的检查清单。
完整流程:
- 客户端(uni-app)发起请求:
- 获取当前时间戳(
timestamp)、生成随机数(nonce)。 - 收集本次请求的参数(
params)。 - 将
appId、timestamp、nonce、params按规则排序、拼接成字符串(stringToSign)。 - 使用
appSecret对stringToSign进行HMAC-SHA256计算,得到签名(signature)。 - 将
appId、timestamp、nonce、signature放入 HTTP 请求头(Header)。 - 发送请求。
- 获取当前时间戳(
- 服务端(Spring Boot 拦截器)接收并验证:
- 从 Header 中取出
appId、timestamp、nonce、signature。 - 基础校验:检查是否存在、格式是否正确。
- 时间校验:计算与服务器时间的差值,是否在允许窗口内(如5分钟)。
- Nonce 校验:检查 Redis 中该
nonce是否已存在(防重放)。 - 密钥获取:根据
appId从数据库或配置中心获取对应的appSecret。 - 参数收集:从
HttpServletRequest中获取请求参数(需妥善处理 JSON Body)。 - 签名计算:使用与客户端完全相同的规则(排序、拼接、空值处理)构建
stringToSign,并用appSecret计算服务端签名。 - 签名比对:比较客户端传来的签名与服务端计算的签名是否一致(忽略大小写)。
- 验证结果:一致则放行,并将
appId等信息存入请求属性;不一致则返回 401 错误,并记录安全日志。
- 从 Header 中取出
上线前核心检查清单:
| 检查项 | 客户端 (uni-app) | 服务端 (Spring Boot) | 一致性确认 |
|---|---|---|---|
| AppSecret 存储 | 是否已混淆/加密?是否可通过反编译轻易获取? | 是否存储在安全的配置中心(如 Apollo, Nacos)或环境变量中? | - |
| 参数排序规则 | 是否按 ASCII 码升序排序? | 是否同样使用 TreeMap 或排序后拼接? | ✅ 必须完全一致 |
| 空值/Null处理 | 遇到null或undefined值是跳过还是转为空字符串? | 遇到null值是跳过还是转为空字符串? | ✅ 必须完全一致 |
| 布尔值/数字 | true/false,数字1传参时是什么格式?字符串还是原生类型? | 接收参数时是String还是Boolean/Integer?转换后参与签名的字符串是什么? | ✅ 必须完全一致 |
| JSON Body 处理 | 如何参与签名?是整个 JSON 字符串做哈希,还是解析后平铺? | 拦截器如何读取 Body?是否与前端策略匹配? | ✅ 必须完全一致 |
| 待签名字符串格式 | appId=xxx×tamp=xxx&nonce=xxx&k1=v1&k2=v2 | 是否严格按照相同顺序和连接符拼接? | ✅ 必须完全一致 |
| 签名算法 | HMAC-SHA256,输出 Hex 小写 | HmacUtils.hmacSha256Hex,输出 Hex 小写 | ✅ 必须完全一致 |
| 时间窗口 | 生成签名后尽快发送请求 | 校验时间戳,窗口大小(如5分钟) | 窗口值需一致 |
| Nonce 长度与字符集 | 生成16位随机字符串,字符集是什么? | 校验长度,字符集是否允许? | 规则需一致 |
| 错误提示 | 收到 401 后,是否能根据响应信息区分是过期、Nonce重复还是签名无效? | 拦截器返回的错误信息是否明确(生产环境可模糊)? | 便于联调 |
最后的实操心得:
- 联调阶段,务必在服务端将前后端生成的
stringToSign和signature都打印到日志里。这是排查不一致问题的终极武器。 - 考虑降级方案。对于某些特殊情况(如 H5 页面临时调用),是否可以提供一个开关或白名单路径,暂时绕过签名验证?这需要在安全性和灵活性间权衡。
- 密钥管理是生命线。AppSecret 泄露意味着整套签名机制形同虚设。一定要定期更换密钥,并建立完善的密钥分发和吊销机制。
- 签名验证是安全加固的一环,不是银弹。它需要与 HTTPS、请求限流、人机验证等其他安全措施配合使用,才能构建相对稳固的 API 防护体系。
整个流程实施下来,虽然步骤繁琐,但一旦跑通,对于 API 安全性的提升是立竿见影的。它能有效抵御绝大多数抓包重放、参数篡改等初级攻击,为你的若依应用加上一道坚实的护栏。
