
现在微服务一拆,API 满天飞,安全漏洞一抓一大把。以前搞个简单的 Token 认证、加个 HTTPS 就觉得万事大吉了,但在现在的"零信任"大环境下,光靠单一认证根本扛不住生产环境的折腾。越权访问、数据泄露、接口被重放,随便中一个都够喝一壶的。
今天不扯虚的,直接聊聊在 Spring Boot 体系下,怎么把 OAuth 2.1、mTLS 和 API 签名这套组合拳落地,搞一套真正能用的企业级 API 安全方案。
1. OAuth 2.1 与 Spring Authorization Server 落地
先说 OAuth 2.1。它其实不是个新协议,而是 2.0 的"打补丁"版本。核心改动就几个:强制要求 PKCE,干掉了不安全的隐式模式(Implicit)和密码模式(Password),并且强制 Refresh Token 必须轮换。
在 Spring 生态里,官方现在主推 Spring Authorization Server (SAS),它原生就支持 2.1 规范。
注册客户端是第一步,这里有个细节,生产环境千万别用 {noop} 这种明文密码编码器,代码里我写出来只是为了演示跑通:
java
@Bean
public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate) {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("mobile-app-client")
.clientSecret("{bcrypt}$2a$10$...") // 生产环境必须上加密,比如 bcrypt
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.redirectUri("https://myapp.com/callback")
.scope(OidcScopes.OPENID)
.scope("api:read")
// OAuth 2.1 强制要求:客户端必须支持 PKCE
.clientSettings(ClientSettings.builder()
.requireProofKey(true)
.requireAuthorizationConsent(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofMinutes(15))
.refreshTokenTimeToLive(Duration.ofDays(7))
.reuseRefreshTokens(false) // 2.1 强制:禁用 Refresh Token 重用,用完即焚
.build())
.build();
return new JdbcRegisteredClientRepository(jdbcTemplate);
}
2. PKCE 到底怎么防拦截?
PKCE(Proof Key for Code Exchange)是 2.1 的核心,主要防授权码拦截。原理不复杂:客户端先搞个高熵的 code_verifier,算个 SHA-256 的哈希作为 code_challenge 发给服务端。拿授权码换 Token 的时候,再把原始的 code_verifier 带上。服务端一算,对得上才给 Token。
这里用 Java 演示下底层逻辑。不过说句实话,前端或者移动端开发,千万别自己手写这套逻辑,容易在随机数生成或者 Base64 编码上出安全漏洞,直接用现成的 OIDC SDK 就行。
java
// 1. 生成高熵的 code_verifier (43-128 字符,必须用安全随机数)
String codeVerifier = generateSecureRandomString(64);
// 2. 算 SHA-256,然后 Base64 URL 无填充编码
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
// 3. 请求授权时带上 challenge
// GET /oauth2/authorize?...&code_challenge=xxx&code_challenge_method=S256
3. 微服务间的"套娃"认证:Token Exchange
服务 A 调服务 B,如果是系统级调用,用 Client Credentials 模式就行。但如果是"服务 A 代表当前用户去调服务 B"呢?这时候就得透传用户身份了。
SAS 支持 Token Exchange (RFC 8693)。服务 A 拿着用户的 Access Token,去授权服务器换个新的 Token。新 Token 里会带上原始用户的信息(通常放在 JWT 的 act 或 sub 字段里)。
java
// 服务 A 发起 Token Exchange
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange");
params.add("subject_token", userAccessToken);
params.add("subject_token_type", "urn:ietf:params:oauth:token-type:access_token");
params.add("requested_token_type", "urn:ietf:params:oauth:token-type:access_token");
params.add("audience", "service-b");
这么做的好处是,服务 B 校验的是服务 A 签发的合法 Token,而不是直接暴露用户的原始 Token,安全性高了一个档次。
4. mTLS:传输层的"硬核"认证
OAuth 搞定应用层,mTLS(双向 TLS)搞定传输层。在零信任网络里,mTLS 能确保只有拿着合法证书的客户端才能建立 TCP 连接,中间人攻击直接歇菜。
Spring Boot 里开启 mTLS 很简单,application.yml 里配一下:
yaml
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: changeit
key-store-type: PKCS12
trust-store: classpath:truststore.p12
trust-store-password: changeit
client-auth: need # 注意这里是 need,强制要求客户端提供证书
关于证书热加载的血泪教训 :
很多文章会教你写个 WebServerFactoryCustomizer 去监听文件变化,动态 reload TrustStore。听我一句劝,在 Java 应用层搞证书热加载纯属给自己找不痛快 ,Tomcat 的 SSL 上下文刷新有很多玄学坑。
生产环境老老实实把 mTLS 卸载到 Nginx、Envoy 这种 Sidecar 代理上。Java 应用只负责处理业务,别去碰底层证书文件。
5. API 签名:防篡改与防重放的底线
对于对外开放的 API,光有 Token 不够。黑客抓个包,把你合法的请求原封不动重发一万次,你的系统可能就挂了。API 签名是防篡改和防重放的底线。
设计签名有几个原则:
- 内部微服务用 HMAC-SHA256(对称,快);外部 API 用 RSA-SHA256(非对称,私钥签公钥验)。
- 必须带
Timestamp(时间戳)和Nonce(随机数)。时间戳防过期,Nonce 防重放。
下面是个拦截器的骨架。这里有个巨坑 提醒一下:如果是 POST 请求,你要对 Body 签名,在拦截器里读了 InputStream,Controller 里就拿不到 Body 了。必须用 ContentCachingRequestWrapper 包装一下请求。
java
@Component
public class ApiSignatureInterceptor implements HandlerInterceptor {
@Autowired
private NonceCacheService nonceCacheService;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String appId = request.getHeader("X-App-Id");
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
// 1. 校验时间戳,误差超过 5 分钟直接拒
long reqTime = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - reqTime) > 5 * 60 * 1000) {
throw new SecurityException("Request expired");
}
// 2. 校验 Nonce,Redis 里 setIfAbsent,10分钟过期
if (!nonceCacheService.setIfAbsent(appId + ":" + nonce, "1", 10, TimeUnit.MINUTES)) {
throw new SecurityException("Duplicate request");
}
// 3. 构建待签名字符串(注意:参数必须按字典序排序,POST 请求要包含 Body)
String stringToSign = buildStringToSign(request, appId, timestamp, nonce);
// 4. 验签
String publicKey = getAppPublicKey(appId);
if (!RsaUtils.verify(stringToSign, signature, publicKey)) {
throw new SecurityException("Invalid signature");
}
return true;
}
}
6. JWT 裸奔与 JWE 加密
JWT 默认只是 Base64 编码,根本不是加密!任何人拿到 Token,去 base64decode 一下,里面的用户邮箱、角色甚至手机号看得一清二楚。
如果 Payload 里有敏感信息,必须上 JWE (JSON Web Encryption)。SAS 支持配置 JWE,用 RSA 加密 AES 密钥,再用 AES-GCM 加密 Payload。
不过要注意,JWE 会消耗额外的 CPU 性能。内部高并发接口尽量别用 JWE,把敏感信息放 Redis 里用 opaque token(不透明令牌)代替;只有对外接口或者必须带敏感信息的场景才上 JWE。
另外,OAuth 2.1 强制 Refresh Token 轮换。每次换 Access Token 必须给个新的 Refresh Token,旧的作废。同时,一定要实现 /oauth2/revoke 撤销接口,用户改密码或者手机丢了,得能把 Token 拉黑。
7. 权限控制:别把 Scope 和 Authority 搞混了
粗粒度的 admin/user 早就够用了。在 Spring Security 里,要分清 Scope 和 Authority。
- Scope :客户端级别的,决定这个 App 能调哪些接口(比如
api:read)。 - Authority :用户级别的,决定这个人在 App 里能干啥(比如
ROLE_MANAGER)。
这里有个新手必踩的坑:在 Spring Security 里,OAuth2 的 Scope 转换成 Authority 时,默认会加上 SCOPE_ 前缀。
java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
// 注意这里的 SCOPE_api:read,千万别写成 api:read,否则永远校验不通过
@PreAuthorize("hasAuthority('SCOPE_api:read') and " +
"hasAuthority('ROLE_SALES') and " +
"@orderPermissionEvaluator.canRead(authentication, #orderId)")
@GetMapping("/{orderId}")
public Order getOrder(@PathVariable String orderId) {
return orderService.findById(orderId);
}
}
结合自定义的 SpEL 表达式(@orderPermissionEvaluator),就能做到数据行级别的权限控制,彻底解决越权问题。
8. 安全审计:别光写数据库
安全不仅是防,还得能查。Spring Security 提供了事件发布机制,我们可以监听各种安全事件。
java
@Component
public class SecurityAuditListener {
@Autowired
private AuditLogService auditLogService;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
// 记录登录成功
auditLogService.logAsync("LOGIN_SUCCESS", event.getAuthentication().getName());
}
@EventListener
public void onFailure(AuthorizationFailureEvent event) {
// 记录越权或认证失败
auditLogService.logAsync("ACCESS_DENIED", event.getAuthentication().getName());
}
}
实战建议:审计日志千万别直接同步写数据库,并发一高数据库就挂了。搞个异步线程池,或者直接把日志扔进 Kafka,后面接 ELK 或者专门的审计系统去分析。基于这些日志,搞点异地登录告警、高频失败锁定,能挡住大部分低级扫描。
9. 网关统一鉴权:别让下游服务背锅
微服务架构下,如果让每个下游服务都去解析和校验 JWT,纯属浪费资源。最佳实践是在 Spring Cloud Gateway 层把活干了。
java
@Component
public class AuthGlobalFilter implements GlobalFilter, Ordered {
@Autowired
private ReactiveJwtDecoder jwtDecoder;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = extractToken(exchange.getRequest());
if (token == null) {
return onError(exchange, HttpStatus.UNAUTHORIZED);
}
return jwtDecoder.decode(token)
.flatMap(jwt -> {
// 校验通过,把用户信息塞进 Header 透传给下游
ServerHttpRequest request = exchange.getRequest().mutate()
.header("X-User-Id", jwt.getClaimAsString("sub"))
.header("X-User-Roles", String.join(",", jwt.getClaimAsStringList("roles")))
.build();
return chain.filter(exchange.mutate().request(request).build());
})
.onErrorResume(e -> onError(exchange, HttpStatus.UNAUTHORIZED));
}
@Override
public int getOrder() {
return -100;
}
}
关键提醒 :网关把用户信息放进 Header 透传后,下游微服务必须配置网络策略 ,只允许网关的 IP 访问。否则黑客直接绕过网关调下游服务,自己伪造个 X-User-Id 的 Header,你的权限控制就形同虚设了。
10. 对照 OWASP API Top 10 查漏补缺
最后,咱们这套方案得能扛住 OWASP API Security Top 10 的检验。简单对一下核心项:
- BOLA (越权访问) :这是 API 安全排名第一的漏洞。咱们在第 7 节搞的
@PreAuthorize结合数据归属校验(orderPermissionEvaluator)就是干这个的,确保用户只能操作自己 ID 的数据。 - 认证失效:OAuth 2.1 强制 PKCE 防了授权码拦截;mTLS 防了传输层窃听;JWE 防了 Token 泄露敏感信息。
- 对象属性级越权 (Mass Assignment) :在 Controller 层严格使用 DTO,配合 Jackson 的
@JsonView或者@JsonIgnore,前端传什么字段就接收什么字段,别直接把请求体反序列化到 Entity 上。 - 安全配置错误:这个靠管理。CI/CD 流水线里加上 Trivy 扫镜像,K8s 配置用 Checkov 扫,确保 mTLS 证书快过期时能提前告警,别把默认密码留在配置文件里。
搞安全是个无底洞,别想着弄个方案就能一劳永逸。业务跑起来,天天被扫、天天修漏洞才是常态。把上面这些基础打牢,别留那种一眼就能被工具扫出来的低级漏洞,遇到懂行的黑客也能多撑几天,这就已经跑赢大部分团队了。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
