😱 前言:HTTPS 就够了吗?
很多同学觉得:“我上了 HTTPS,数据就是加密传输的,很安全。”
错!HTTPS 只能防止数据在传输链路上不被窃听,但它防不住:
- 中间人攻击(MITM):如果客户端被植入根证书,HTTPS 一样被抓包。
- 重放攻击:黑客截获你的请求,虽然解不开,但他可以把这个请求重复发送 100 次(比如转账接口)。
- 参数篡改:黑客修改了转账金额,而服务端只认 Token,不校验金额是否被改过。
要达到金融级安全,我们需要“三道防线”。
🏗️ 一、 架构设计:三道防线
我们将安全逻辑封装在一个全局 Filter 中,请求到达 Controller 之前必须通关。
安全防御流程图 (Mermaid):
🔐 二、 第一道防线:OAuth2 + JWT (身份认证)
这是最基础的“门票”。我们使用 Spring Security OAuth2 Resource Server 来解析 JWT。
依赖引入:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-oauth2-resource-server</artifactId></dependency>配置 SecurityFilterChain:
@Configuration@EnableWebSecuritypublicclassSecurityConfig{@BeanpublicSecurityFilterChainfilterChain(HttpSecurityhttp)throwsException{http.csrf(csrf->csrf.disable()).sessionManagement(session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).authorizeHttpRequests(auth->auth.requestMatchers("/api/public/**").permitAll().anyRequest().authenticated())// 启用 JWT 解析.oauth2ResourceServer(oauth2->oauth2.jwt(Customizer.withDefaults()));returnhttp.build();}}这步之后,接口有了基本的“登录”功能,但还不够。
✍️ 三、 第二道防线:参数签名 (防篡改)
这是金融级安全的核心。
原理:客户端把所有参数按 ASCII 码排序,拼接成字符串,加上一个双方约定的SecretKey,进行 MD5 或 SHA256 运算,生成sign。
服务端收到请求后,用同样的逻辑算一遍。如果算出来的sign和传过来的不一样,说明参数被篡改了。
签名工具类 (SignatureUtils):
importorg.apache.commons.codec.digest.DigestUtils;importjava.util.TreeMap;importjava.util.Map;publicclassSignatureUtils{// 双方约定的盐值,绝对不能暴露privatestaticfinalStringSALT="Abc@123456_Financial_Secret";/** * 生成签名 * @param params 所有的业务参数 + timestamp + nonce */publicstaticStringgenerateSign(Map<String,String>params){// 1. 使用 TreeMap 进行 ASCII 排序TreeMap<String,String>sorted=newTreeMap<>(params);StringBuildersb=newStringBuilder();for(Map.Entry<String,String>entry:sorted.entrySet()){// 排除 sign 本身和空值if(!"sign".equals(entry.getKey())&&entry.getValue()!=null){sb.append(entry.getKey()).append("=").append(entry.getValue()).append("&");}}// 2. 拼接盐值sb.append("key=").append(SALT);// 3. SHA256 摘要returnDigestUtils.sha256Hex(sb.toString()).toUpperCase();}}⏱️ 四、 第三道防线:Timestamp + Nonce (防重放)
即使黑客拿到了合法的sign,他如果把这串请求拦截下来,隔了 10 分钟再发一遍怎么办?
我们需要“时效性”和“唯一性”校验。
- Timestamp:请求带上时间戳。服务端判断:
当前时间 - 请求时间 > 60秒,直接拒绝。 - Nonce:随机流水号。服务端将处理过的 Nonce 存入 Redis(有效期 60 秒)。如果发现 Redis 里已经有这个 Nonce,说明是重复请求,拒绝。
🛡️ 五、 终极合体:自定义安全过滤器
我们将上述逻辑封装在一个 Filter 中,放在 Spring Security 过滤器链的上游。
@ComponentpublicclassApiSecurityFilterextendsOncePerRequestFilter{@AutowiredprivateStringRedisTemplateredisTemplate;@OverrideprotectedvoiddoFilterInternal(HttpServletRequestrequest,HttpServletResponseresponse,FilterChainfilterChain)throwsServletException,IOException{// 1. 获取 Header 中的安全参数Stringsign=request.getHeader("X-Sign");Stringtimestamp=request.getHeader("X-Timestamp");Stringnonce=request.getHeader("X-Nonce");if(!StringUtils.hasText(sign)||!StringUtils.hasText(timestamp)){response.sendError(HttpServletResponse.SC_FORBIDDEN,"缺少安全参数");return;}// 2. 防重放 - 时间校验 (限制 60 秒内有效)longreqTime=Long.parseLong(timestamp);if(System.currentTimeMillis()-reqTime>60000){response.sendError(HttpServletResponse.SC_FORBIDDEN,"请求已过期");return;}// 3. 防重放 - Nonce 唯一性校验 (Redis)StringnonceKey="nonce:"+nonce;BooleanisAbsent=redisTemplate.opsForValue().setIfAbsent(nonceKey,"1",60,TimeUnit.SECONDS);if(Boolean.FALSE.equals(isAbsent)){response.sendError(HttpServletResponse.SC_FORBIDDEN,"重复的请求");return;}// 4. 防篡改 - 验签// 这里需要包装 HttpServletRequest 以便多次读取 Body (针对 POST/JSON)// 假设我们要对 URL 参数 + Header 参数进行签名校验Map<String,String>params=newHashMap<>();request.getParameterMap().forEach((k,v)->params.put(k,v[0]));params.put("timestamp",timestamp);params.put("nonce",nonce);StringcalculatedSign=SignatureUtils.generateSign(params);if(!calculatedSign.equals(sign)){response.sendError(HttpServletResponse.SC_UNAUTHORIZED,"签名验证失败");return;}// 5. 放行给下游 (Spring Security)filterChain.doFilter(request,response);}}注意:对于 POST JSON 请求,你需要实现HttpServletRequestWrapper来缓存 Body 流,否则 Filter 读完 Body 后,Controller 就读不到了。
🎯 总结
通过这一套组合拳,我们实现了:
- JWT:解决了“你是谁”的问题。
- 签名:解决了“数据被改”的问题。
- 时间戳+Nonce:解决了“请求被偷”的问题。
这基本达到了支付宝、微信支付等开放平台的安全标准。虽然增加了一些开发复杂度,但在资金安全面前,这一切都是值得的。
Next Step:
思考一下,如果你的密钥(SecretKey)泄露了怎么办?
进阶方案是引入RSA 非对称加密:客户端用私钥签名,服务端用公钥验签。这样即使服务端代码泄露,黑客也无法伪造客户端请求!