REST API 认证:JWT 与 OAuth2
本教程共 48 篇 · 第 34 篇 · 更新于 2026-08-13 · 约 5 分钟阅读
本节目标:理解无状态认证的原理,会用 Spring Security 签发和校验 JWT,认识 OAuth2 客户端、资源服务器和授权服务器三个角色。
为什么 REST API 需要无状态认证
上一节的表单登录依赖两样东西:服务端 Session 和浏览器 Cookie。
前后端分离后,这套组合开始别扭:
- 手机 App 没有 Cookie 机制
- 多实例部署时,Session 要额外共享存储
- 跨域请求带 Cookie 有诸多限制
无状态认证换个思路:客户端每次请求自己带凭证,服务端验完即走,不保存会话。
JWT 就是最流行的凭证格式。你认识它之后,会发现整个认证链路都围绕「签发、传递、验证」展开。
JWT 长什么样
JWT(JSON Web Token)由三部分组成,用点分隔:
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ6aGFuZ3NhbiJ9.8m5T...签名
- Header:算法和类型,Base64URL 编码
- Payload:claims,用户信息、过期时间
- Signature:用私钥对前两段签名,防篡改
服务端验签通过就信任内容。所以 JWT 天生无状态,适合 REST API。
WarningJWT 内容是 Base64 编码,不是加密。任何拿到 token 的人都能解码看 Payload。别把密码、手机号等敏感信息放进去。
密码存储:BCrypt
认证绕不开密码存储。明文存密码是事故,加盐哈希是底线。
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
createDelegatingPasswordEncoder() 创建委派编码器,存储格式带算法前缀:
{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
校验时它根据前缀自动选算法。将来换算法,老密码不用重存。这也是 Spring Boot 4.x 里的默认 PasswordEncoder。
BCrypt 的特点:自带盐、可调强度($2a$10$ 里的 10 是迭代成本)。同样的密码每次加密结果都不同,彩虹表直接失效。
自己签发 JWT:JwtEncoder 与 JwtDecoder
Spring Security 官方用 Nimbus JOSE 实现 JWT,随资源服务器 starter 一起引入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
签发方配置:生成 RSA 密钥对,提供 JwtEncoder / JwtDecoder Bean。
package com.example.demo.config;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.interfaces.RSAPublicKey;
import java.util.UUID;
@Configuration
public class JwtConfig {
@Bean
public KeyPair keyPair() throws Exception {
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
return generator.generateKeyPair();
}
@Bean
public RSAKey rsaKey(KeyPair keyPair) {
return new RSAKey.Builder((RSAPublicKey) keyPair.getPublic())
.privateKey(keyPair.getPrivate())
.keyID(UUID.randomUUID().toString())
.build();
}
@Bean
public JWKSource<SecurityContext> jwkSource(RSAKey rsaKey) {
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, context) -> jwkSelector.select(jwkSet);
}
@Bean
public JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
return new NimbusJwtEncoder(jwkSource);
}
@Bean
public JwtDecoder jwtDecoder(RSAKey rsaKey) throws Exception {
return NimbusJwtDecoder.withPublicKey(rsaKey.toRSAPublicKey()).build();
}
}
写一个登录接口,认证通过后签发 token:
package com.example.demo.web;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
@RestController
public class AuthController {
private final JwtEncoder jwtEncoder;
public AuthController(JwtEncoder jwtEncoder) {
this.jwtEncoder = jwtEncoder;
}
@PostMapping("/authenticate")
public JwtResponse authenticate(Authentication authentication) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("self")
.issuedAt(now)
.expiresAt(now.plusSeconds(1800))
.subject(authentication.getName())
.claim("scope", "message:read") // 业务 scope,openid 属 OIDC 概念
.build();
String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return new JwtResponse(token);
}
record JwtResponse(String token) {
}
}
/authenticate 由 Spring Security 先做 HTTP Basic 认证,Authentication 参数直接拿到当前用户。
跑起来验证:
# 登录拿 token
curl -u admin:admin123 -X POST http://localhost:8080/authenticate
# 带 token 访问受保护接口
curl -H "Authorization: Bearer <上一步返回的token>" http://localhost:8080/api/orders
Tip用 jjwt 库也可以,坐标
io.jsonwebtoken:jjwt-api、jjwt-impl、jjwt-jackson。官方路线推荐 spring-security-oauth2-jose,和 Spring Security 集成最顺。
资源服务器校验 JWT
校验方不持有私钥,只验签。配置 jwk-set-uri 或 issuer-uri,指向签发方的公钥地址:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: "http://localhost:8080"
安全配置开启 JWT 资源服务器模式:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/authenticate").authenticated()
.anyRequest().authenticated()
)
.httpBasic(withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
这段配置要和上面的 AuthController 放在同一个应用里组合使用:/authenticate 走 HTTP Basic 认证换 token,其余接口用 Bearer token 访问。
SessionCreationPolicy.STATELESS 表示不创建 Session。客户端请求时带:
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...
Note生产环境一般由独立的授权服务器签发 token,资源服务器通过
jwk-set-uri(JWK Set 地址)拉公钥验证,双方不共享密钥。
还可以用spring.security.oauth2.resourceserver.jwt.audiences限定 JWT 的 aud 声明,防止 token 被别的服务复用。
OAuth2 客户端登录
第三方登录(微信、GitHub、Google)走 OAuth2 授权码流程。先认清 OAuth2 里的角色:
- 资源所有者:用户本人
- 客户端:你的应用
- 授权服务器:第三方账号体系
- 资源服务器:提供用户信息的接口
流程一句话:用户去授权服务器确认「允许这个应用登录」,授权服务器发授权码,应用拿授权码换 token,再用 token 取用户信息。
Spring Boot 对常见提供商有内置默认值:
spring:
security:
oauth2:
client:
registration:
github:
client-id: "你的 client-id"
client-secret: "你的 client-secret"
配置类加 oauth2Login:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2Login(withDefaults());
return http.build();
}
访问应用任意页面,自动跳转 GitHub 授权页。授权后回调 /login/oauth2/code/github 完成登录。
自定义提供商时,用 spring.security.oauth2.client.provider.<id>.issuer-uri 指向 OIDC 发现地址,授权、token、JWKS 端点自动拉取。
OAuth2 与 JWT 的关系
两者经常一起出现,但它们是两层东西:
- OAuth2 是授权框架,定义「怎么授权、怎么换 token」
- JWT 是 token 的一种格式,OAuth2 的 token 可以是 JWT,也可以是不透明字符串
类比:OAuth2 是银行流程,JWT 是其中一种钞票样式。流程定了,钞票可以换版。
选型时记住:JWT 自包含、可离线校验,适合资源服务器分散的场景;不透明 token 需要授权服务器在线校验(introspection),撤销容易、泄露面小。
Spring Security 两种都支持:JWT 用 jwk-set-uri / issuer-uri,不透明 token 用 spring.security.oauth2.resourceserver.opaquetoken.introspection-uri 配内省地址。
授权服务器
需要自己搭授权服务器时,用 Spring Authorization Server 项目:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
</dependency>
注册客户端:
spring:
security:
oauth2:
authorizationserver:
client:
my-client:
registration:
client-id: "abcd"
client-secret: "{noop}secret1"
authorization-grant-types:
- "authorization_code"
- "refresh_token"
redirect-uris:
- "http://127.0.0.1:8080/login/oauth2/code/my-client"
scopes:
- "openid"
- "profile"
require-authorization-consent: true
client-secret 的写法要能被当前 PasswordEncoder 匹配,所以演示里用 {noop},生产要换成加密值。
token 默认有过期时间:授权码 5 分钟、访问令牌 5 分钟、刷新令牌 60 分钟,都可以调。访问令牌过期后用刷新令牌换新的,用户不用重新登录。
spring:
security:
oauth2:
authorizationserver:
client:
my-client:
token:
authorization-code-time-to-live: 5m
access-token-time-to-live: 5m
refresh-token-time-to-live: 60m
Note版本对照:1.x 时代的
@EnableAuthorizationServer、@EnableResourceServer(tutorialspoint 老教程常用)已随旧 OAuth2 模块移除。
现在分别由 Spring Authorization Server 和资源服务器自动配置取代,授权码流程、token 签发全部内置。
初学者常栽的坑
- JWT 里塞敏感信息:Payload 可解码,只放用户 ID 和角色
- 秘钥写死在代码里:用环境变量或配置中心,定期轮换
- token 过期时间设一天:泄露窗口太大,短过期加刷新令牌是常规做法
- 资源服务器配了
jwk-set-uri又配issuer-uri:二选一即可,同时配可能互相干扰 - 忘记
STATELESS:JWT 场景还开 Session,白占内存 - 混淆 OAuth2 和 JWT:授权框架和凭证格式是两层,别混着用
本节小结
- 无状态认证不存 Session,JWT 是主流凭证格式
- 密码用
{bcrypt}前缀的委派编码器存储 - 签发用
JwtEncoder,校验用资源服务器加jwk-set-uri/issuer-uri - 第三方登录用 OAuth2 Client,
oauth2Login一行开启 - 自建授权服务器用
spring-boot-starter-oauth2-authorization-server