若依前后端分离架构下基于MD5的签名验证实战

📅 发布时间:2026/7/13 4:14:51 👁️ 浏览次数:
若依前后端分离架构下基于MD5的签名验证实战
1. 为什么你的若依项目需要一个签名验证最近在做一个基于若依前后端分离框架的项目对接的是一个小程序端。项目上线前安全审计的同事提了个醒我们的API接口就这么“裸奔”着是不是有点危险虽然若依本身有登录认证和权限控制但那主要是防“君子”的。如果有人抓了包拿到接口地址和参数直接模拟请求怎么办或者请求被恶意篡改、重放攻击呢想想也是特别是对于涉及用户积分、订单这类敏感操作光靠一个登录态Token总觉得心里不踏实。这时候签名验证就该上场了。你可以把它理解成给每个从客户端比如小程序发出的请求都盖上一个“数字指纹”。这个指纹是根据请求的“身份信息”比如一个秘钥和“时间信息”比如时间戳通过特定的算法比如MD5计算出来的。后端收到请求后会用同样的规则再算一遍指纹如果两个指纹对不上或者指纹已经“过期”了那就直接拒绝请求。这样一来即使请求内容在传输过程中被截获攻击者也无法伪造出一个有效的签名因为他不知道我们用来生成指纹的“秘方”秘钥。为什么选择MD5呢我知道很多文章会说MD5有碰撞风险不适合密码存储。但对于API签名这种场景MD5有它的优势计算速度快、实现简单、结果固定长度。我们的核心目标不是不可逆的加密而是快速生成一个防篡改的校验码。在中小型项目尤其是内部系统或对性能要求较高的场景下MD5签名验证是一个在安全性和开发效率之间取得很好平衡的方案。当然如果你对安全要求极高可以考虑SHA-256等更复杂的算法但MD5作为入门和实战完全够用也更容易理解。在若依这个成熟的框架里加签名听起来好像要动大手术其实不然。若依前后端分离版已经为我们准备好了完美的“手术台”——请求拦截器。前端可以在发起请求的最后一刻统一给请求“盖章”后端则在处理请求的最开始统一“验章”。我们只需要在这两个关键位置插入少量代码就能为整个项目的API通信穿上一件轻便的“防弹衣”。接下来我就带你一步步实操从零开始在若依项目中实现这套MD5签名验证机制。2. 前端实战给每个请求盖上“指纹”前端是我们的“盖章员”负责在请求发出前按照既定规则生成签名并把它放到请求头里。我以最常用的uni-app配合uni-request或类似库为例Vue或React项目思路完全一致只是具体API稍有不同。2.1 环境准备与依赖安装首先我们需要一个生成MD5的工具。在uni-app项目中我推荐使用js-md5这个库它轻量且兼容性好。打开你的项目终端执行安装命令npm install js-md5 -D或者如果你用的是yarnyarn add js-md5 -D这里的-D表示作为开发依赖安装因为我们的代码构建后会包含它。接下来我们需要一个签名秘钥。这个秘钥是签名的核心必须前后端保持一致并且要妥善保管绝不能泄露到客户端代码之外虽然前端代码理论上可被查看但通过混淆等手段可以增加破解难度。一种常见的做法是把它放在一个统一的配置文件中。我在项目的main.js或一个专门的config.js里定义它// main.js 或 config.js export const signatureSecret your_secret_key_2024; // 替换成你自己复杂的字符串注意这个秘钥最好定期更换并且可以结合项目、环境开发、测试、生产使用不同的秘钥进一步提升安全性。2.2 改造请求拦截器假设你的项目已经有一个封装好的网络请求工具比如叫request.js或http.js。我们需要在这里面引入MD5和秘钥并修改请求拦截器。// utils/request.js import Md5 from js-md5; import { signatureSecret } from /main.js; // 根据你的实际路径引入 import store from /store; // 如果需要用到其他全局状态 // 假设你使用的是 uni-app 的 uni.request 封装或类似库 const http { // ... 你的原有配置比如 baseURL, timeout 等 }; // 请求拦截器 http.interceptors.request.use( (config) { // 1. 生成当前时间戳毫秒 const timeStamp new Date().getTime().toString(); // 2. 核心生成MD5签名 // 规则秘钥 时间戳然后进行MD5加密最后转为大写可选为了统一 const signString ${signatureSecret}${timeStamp}; const sign Md5(signString).toUpperCase(); // toUpperCase() 使签名统一为大写避免大小写不一致问题 // 3. 将签名和时间戳放入请求头 config.header { ...config.header, // 保留原有的header timeStamp: timeStamp, sign: sign, client-type: wxapp // 这是一个示例用于区分请求来源比如小程序、H5、管理后台 }; // 4. 可选对于GET请求你也可以将签名参数放入URL但放在Header更常见和安全 // 5. 确保data存在避免后续处理出错 config.data config.data || {}; console.log([请求签名] 时间戳: ${timeStamp}, 签名: ${sign}); // 调试用生产环境记得去掉 return config; }, (error) { // 请求配置出错的处理 return Promise.reject(error); } ); // 响应拦截器原有逻辑保持不变 http.interceptors.response.use( (response) { /* ... */ }, (error) { /* ... */ } ); export default http;这里有几个关键点和我踩过的坑时间戳同步前端生成的时间戳和后端验证时获取的系统时间戳必须基于同一时间源通常是UTC。new Date().getTime()在绝大多数现代浏览器和JS环境中是可靠的。签名规则一致前后端拼接字符串的规则必须一字不差。我这里用的是秘钥时间戳你也可以用时间戳|秘钥或者其他分隔符但前后端一定要对齐。Header命名timeStamp、sign这些字段名前后端也要一致。我见过有人前端用timestamp后端用timeStamp导致一直验签失败。字符大小写MD5生成的是32位十六进制字符串包含a-f字母。为了消除大小写可能带来的比对问题我习惯在生成后统一转成大写toUpperCase()后端比对时也把收到的签名转大写或者后端生成时也转大写。这样就避免了因大小写不一致导致的验证失败。2.3 处理特殊场景与调试在实际项目中你可能会遇到一些特殊情况文件上传请求Content-Type会是multipart/form-data我们的签名Header依然可以正常附加不影响文件体。登录接口登录接口往往在获取Token之前调用它本身也需要签名验证。所以我们的拦截器应该是全局的不排除任何接口登录接口的验证逻辑后端同样需要处理。调试技巧在开发阶段可以在浏览器或小程序开发者工具的Network面板中查看每个请求的Headers确认timeStamp和sign字段是否携带并且值是否符合预期。可以用在线的MD5计算工具手动按照你的规则计算一下看结果是否和前端生成的一致。前端部分完成后你的每一个从客户端发出的请求都自动带上了“时间戳”和“签名”这两个身份凭证。接下来就看后端如何“验明正身”了。3. 后端实战拦截并校验每一个“来客”后端是“验章员”。我们要在请求进入业务控制器Controller之前就完成签名验证。若依框架基于Spring Boot实现这个功能最优雅的方式就是使用HandlerInterceptor拦截器。若依本身已经有一个防止重复提交的拦截器RepeatSubmitInterceptor我们可以借鉴它的思路创建一个专门的签名验证拦截器。3.1 创建签名验证拦截器在若依框架中拦截器通常放在com.ruoyi.framework.interceptor包下。我们新建一个类SignAuthInterceptor。package com.ruoyi.framework.interceptor; import com.ruoyi.common.core.domain.AjaxResult; import com.ruoyi.common.utils.ServletUtils; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.security.MessageDigest; import java.util.Arrays; import java.util.List; /** * API签名验证拦截器 */ Component // 声明为Spring组件使其被管理 public class SignAuthInterceptor implements HandlerInterceptor { // 签名秘钥必须与前端的 signatureSecret 保持一致 // 强烈建议将此配置放到 application.yml 中通过 Value 注入这里为了演示写死 private final String secret your_secret_key_2024; // 允许的时间戳误差秒用于防止重放攻击 private final long timeTolerance 300; // 5分钟 // 可以配置不需要签名验证的路径比如某些健康检查接口、公开API private final ListString excludePaths Arrays.asList( /profile/upload, // 示例文件上传路径若依的静态资源映射 /druid/** // 示例Druid监控台路径 ); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String requestURI request.getRequestURI(); // 1. 检查请求路径是否在排除列表中 for (String excludePath : excludePaths) { // 这里可以使用AntPathMatcher进行更复杂的路径匹配简单起见用contains if (excludePath.contains(**)) { // 简单处理通配符实际可用Spring的AntPathMatcher if (requestURI.startsWith(excludePath.replace(/**, ))) { return true; // 放行 } } else if (requestURI.equals(excludePath) || requestURI.startsWith(excludePath /)) { return true; } } // 2. 从Header中获取签名和时间戳 String clientSign request.getHeader(sign); String timeStampStr request.getHeader(timeStamp); String clientType request.getHeader(client-type); // 获取客户端类型 // 3. 基础校验关键信息是否存在 if (clientSign null || clientSign.isEmpty() || timeStampStr null || timeStampStr.isEmpty()) { returnResponse(response, AjaxResult.error(签名验证失败缺少必要参数)); return false; } // 4. 校验客户端类型如果需要 // 这里假设我们只对来自小程序wxapp的请求进行严格的签名和时间戳校验 // 管理后台或其他内部服务可能采用其他认证方式 if (wxapp.equalsIgnoreCase(clientType)) { // 5. 验证时间戳是否在允许的误差范围内防重放 long clientTimeStamp; try { clientTimeStamp Long.parseLong(timeStampStr); } catch (NumberFormatException e) { returnResponse(response, AjaxResult.error(签名验证失败时间戳格式错误)); return false; } long serverTimeStamp System.currentTimeMillis(); long diffSeconds Math.abs((serverTimeStamp - clientTimeStamp) / 1000); if (diffSeconds timeTolerance) { // 请求时间戳与服务器时间相差太大可能是过期请求或重放攻击 returnResponse(response, AjaxResult.error(签名验证失败请求已过期)); return false; } // 6. 重新计算签名 String serverSign generateMD5Sign(secret, timeStampStr); // 7. 比对签名 // 注意前端签名转了大写这里我们也转大写比对或者前端不转这里也不转保持一致即可 if (!serverSign.equalsIgnoreCase(clientSign)) { returnResponse(response, AjaxResult.error(签名验证失败签名不匹配)); return false; } } else { // 对于非wxapp的客户端可以采取其他验证策略或者直接放行根据你的安全要求 // 例如管理后台可能使用更严格的Spring Security JWT // 这里为了演示我们简单记录日志或进行其他检查 } // 8. 所有验证通过放行请求到Controller return true; } /** * 生成MD5签名 * param secret 秘钥 * param timeStamp 时间戳字符串 * return 大写的MD5签名串 */ private String generateMD5Sign(String secret, String timeStamp) { try { String signString secret timeStamp; // 拼接规则必须与前段完全一致 MessageDigest md MessageDigest.getInstance(MD5); md.update(signString.getBytes(UTF-8)); // 指定字符集避免平台差异 byte[] digest md.digest(); // 将byte数组转换为16进制大写字符串 StringBuilder hexString new StringBuilder(); for (byte b : digest) { String hex Integer.toHexString(0xff b); if (hex.length() 1) { hexString.append(0); } hexString.append(hex); } return hexString.toString().toUpperCase(); // 返回大写形式 } catch (Exception e) { throw new RuntimeException(生成MD5签名时发生错误, e); } } /** * 返回错误信息给客户端 */ private void returnResponse(HttpServletResponse response, AjaxResult result) throws IOException { // 设置状态码为401未授权或403禁止根据场景选择 // response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString(result)); // 需要import com.alibaba.fastjson.JSON; } }3.2 配置拦截器生效创建好拦截器后我们需要把它注册到Spring MVC的拦截器链中。在若依框架中通常有一个Web配置类比如WebConfig或ResourcesConfig。我们找到它并添加我们的拦截器。package com.ruoyi.framework.config; import com.ruoyi.framework.interceptor.SignAuthInterceptor; 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 WebConfig implements WebMvcConfigurer { Autowired private SignAuthInterceptor signAuthInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { // 添加签名验证拦截器并配置拦截路径和排除路径 registry.addInterceptor(signAuthInterceptor) .addPathPatterns(/**) // 拦截所有请求 .excludePathPatterns( /login, // 登录接口如果需要特殊处理 /captchaImage, // 验证码接口 /webjars/**, // 静态资源 /swagger-resources/**, // Swagger /v2/api-docs, /swagger-ui.html ); // 注意这里配置的排除路径会覆盖拦截器内部的部分排除逻辑两者可以结合使用 } }配置的注意事项拦截顺序若依框架可能有多个拦截器如重复提交拦截器、权限拦截器。签名验证通常应该放在最前面因为它是最基础的通信安全校验甚至应该在登录验证之前。你可以通过order()方法设置顺序值越小优先级越高。排除路径一定要把那些不需要签名的接口排除掉比如静态资源、Swagger文档、登录接口如果登录接口本身也需要签名验证则不应排除。否则会导致这些请求也被拦截验证而失败。秘钥管理千万不要把秘钥硬编码在代码里像我上面示例中写死secret是不安全的。正确的做法是将其配置在application.yml或application-prod.yml中然后使用Value注解注入。这样便于不同环境使用不同秘钥也方便轮换。# application.yml ruoyi: security: sign: secret: your_dynamic_secret_key_${random.uuid} # 甚至可以结合随机值 tolerance: 300然后在拦截器中注入Value(${ruoyi.security.sign.secret}) private String secret; Value(${ruoyi.security.sign.tolerance}) private long timeTolerance;4. 进阶优化与常见问题排查基本的签名验证跑通后我们来看看如何让它更健壮、更安全以及如何快速定位问题。4.1 签名算法的增强单纯的秘钥时间戳的MD5已经能防御大部分简单的重放和篡改。但我们可以做得更好加入请求参数将关键的请求参数如body中的JSON字符串或query参数排序后的字符串也参与签名计算。这样即使签名和时间戳正确但参数被篡改签名也会对不上。不过要注意文件上传的multipart/form-data处理起来会麻烦一些。// 示例将排序后的请求参数Map转换为字符串参与签名 MapString, String sortedParams getSortedParams(request); String paramString buildQueryString(sortedParams); String signString secret timeStamp paramString;使用Nonce除了时间戳还可以增加一个随机数Nonce。服务器可以缓存一段时间内使用过的Nonce如果收到重复的Nonce则视为重放攻击。这比单纯的时间戳校验更严格。升级哈希算法如果安全等级要求高可以将MD5替换为SHA-256。只需修改MessageDigest.getInstance(MD5)为MessageDigest.getInstance(SHA-256)并注意输出长度的变化64位十六进制字符串。4.2 性能与缓存考虑每个请求都进行MD5计算对性能影响微乎其微。但如果你担心高并发下的性能可以考虑缓存签名结果对于短时间内相同的秘钥时间戳组合时间戳精度到秒其签名结果是固定的。可以将其缓存在内存如Caffeine中几秒钟避免重复计算。但要注意缓存大小和过期时间。异步日志验签失败时的日志记录尤其是IP、请求路径等信息可以异步处理避免阻塞主线程。4.3 联调与问题排查指南在开发联调阶段签名验证最容易出问题。这里给你一个排查清单现象前端请求后端始终返回“签名验证失败”。第一步核对秘钥和时间戳打开浏览器开发者工具F12的Network面板找到失败的请求。查看Request Headers确认sign和timeStamp字段是否存在且值正常。核对秘钥确保前端main.js或配置文件和后端application.yml或代码中的secret完全一致包括所有字符和空格。核对时间戳对比前端生成的时间戳和后端系统时间。如果服务器时间不同步会导致时间误差校验失败。可以临时将后端的timeTolerance调大比如3600秒来测试是否是时间问题。第二步手动验签从前端请求头中复制出timeStamp的值。在后端拦截器的preHandle方法开始处打上断点或者添加日志打印出接收到的clientSign、timeStampStr以及你用generateMD5Sign方法计算出来的serverSign。用一个在线的MD5计算工具搜索“MD5在线加密”按照你的规则secret timeStamp手动计算一次看结果是否与前端传来的sign一致注意大小写。常见坑点拼接字符串的格式前后端拼接时是否有多余空格secrettimestamp和secret timestamp结果是天壤之别。字符编码在生成MD5时getBytes()方法不指定字符集可能会因平台差异导致结果不同。最好统一指定为UTF-8。Header名称大小写HTTP Header名称不区分大小写但获取时最好保持一致。有些框架可能会自动规范化。第三步检查拦截器配置确认你的SignAuthInterceptor已经被Spring管理有Component注解。确认WebConfig配置类被加载并且addInterceptors方法被调用。检查拦截路径addPathPatterns和排除路径excludePathPatterns是否正确。有可能你的请求路径被意外排除了或者根本没被拦截器处理。第四步查看日志在后端拦截器的每个关键判断点如获取Header失败、时间戳超差、签名不匹配添加详细的日志输出使用Slf4j注解配合log.debug/info/warn。通过日志可以清晰地看到验证流程走到哪一步失败了。一个快速调试技巧在开发环境可以暂时在后端拦截器验签失败时不仅返回错误还把服务器计算签名的原始字符串(signString)和结果(serverSign)也返回给前端仅限调试方便对比。5. 在若依框架中的集成与扩展将签名验证无缝集成到若依框架中还能利用若依的一些特性让它更强大。5.1 与现有安全体系共存若依前后端分离版默认采用了Spring Security JWT的认证授权方案。我们的签名验证拦截器应该放在Security 过滤器链之前。因为签名验证是通信层面的安全而登录认证JWT是用户层面的安全。顺序应该是签名验证拦截器验证请求是否来自合法的客户端。JWT认证过滤器验证用户身份和权限。在我们的WebConfig中通常不需要特别调整顺序因为拦截器Interceptor的执行时机在Spring Security的过滤器Filter之后这里有个关键点需要澄清在Spring Boot中HandlerInterceptor的preHandle是在DispatcherServlet分发请求时执行的而Spring Security的过滤器链在DispatcherServlet之前。所以实际上过滤器(Filter)先于拦截器(Interceptor)执行。对于签名验证这种应在最早阶段进行的检查更标准的做法是将其实现为一个Filter并配置在Spring Security过滤器链的最前面。我们可以创建一个SignAuthFilterComponent public class SignAuthFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 这里放置和拦截器里类似的验签逻辑 if (!validateSign(request)) { // 验签失败直接返回不继续走过滤器链包括Security returnErrorResponse(response); return; } // 验签通过继续执行过滤器链下一步就是Spring Security filterChain.doFilter(request, response); } private boolean validateSign(HttpServletRequest request) { // ... 验签逻辑同上文拦截器 } }然后在Security配置类中通过HttpSecurity的addFilterBefore将其添加到链首Configuration EnableGlobalMethodSecurity(prePostEnabled true) public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private SignAuthFilter signAuthFilter; Override protected void configure(HttpSecurity http) throws Exception { http // 将签名过滤器放在最前面 .addFilterBefore(signAuthFilter, UsernamePasswordAuthenticationFilter.class) // ... 其他Security配置如JWT过滤器、登录配置、授权等 } }这样请求的处理流程就变成了SignAuthFilter-JwtAuthenticationTokenFilter(若依的JWT过滤器) - ... -DispatcherServlet-SignAuthInterceptor。我们可以在Filter做最严格的通信安全校验在Interceptor可以做更细粒度的、与MVC相关的校验。5.2 利用若依的注解进行灵活控制我们可能希望某些接口比如内部调用的、或特别公开的跳过签名验证。可以借鉴若依Anonymous注解的思路自定义一个SkipSignAuth注解。// 1. 定义注解 Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface SkipSignAuth { } // 2. 在拦截器或过滤器中判断 Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // ... 排除路径检查 ... if (handler instanceof HandlerMethod) { HandlerMethod handlerMethod (HandlerMethod) handler; // 检查方法上是否有跳过签名的注解 SkipSignAuth skipSignAuth handlerMethod.getMethodAnnotation(SkipSignAuth.class); if (skipSignAuth ! null) { return true; // 直接放行 } // 也可以检查类级别注解 handlerMethod.getBeanType().getAnnotation(SkipSignAuth.class) } // ... 后续验签逻辑 ... }然后在不需要签名的Controller方法上加上SkipSignAuth即可。5.3 监控与告警在拦截器或过滤器中我们可以方便地记录验签相关的日志用于监控和告警记录失败请求将验签失败的请求IP、路径、时间戳、客户端类型等信息记录到日志文件或发送到监控系统如ELK。统计失败率如果某个IP或客户端在短时间内大量验签失败很可能是恶意攻击可以触发告警。与若依操作日志结合若依有完善的操作日志记录功能Log注解。我们可以将验签失败也作为一种特殊的“操作失败”记录到数据库方便在管理后台查看。踩坑心得我记得第一次上线签名验证时因为服务器时区设置问题导致时间戳校验大面积失败。后来我们统一将服务器设置为UTC时间并在生成和校验时间戳时都明确使用UTC时间戳问题就解决了。另外对于文件上传接口一开始忘了在排除路径里加上/profile/upload的静态资源映射路径导致图片上传总是失败排查了半天才发现是签名拦截器把静态资源请求也给拦了。所以一定要仔细规划你的拦截和排除路径。这套基于MD5的签名验证机制在我们项目中稳定运行了一年多有效拦截了无数次恶意爬虫和重放攻击尝试。它就像给API接口加了一把轻巧但坚固的锁虽然不能防御所有高级攻击但对于提升项目整体安全性、增加攻击成本来说性价比极高。希望这份详细的实战指南能帮助你在若依项目中快速、稳妥地落地签名验证功能。