SpringBoot中使用OAuth2 认证与 JWT Token --- 概念、原理与实践
一、什么是认证与授权
1.1 基本概念
| 概念 | 英文 | 含义 | 类比 |
|---|---|---|---|
| 认证(Authentication) | AuthN | 你是谁?验证身份 | 刷工卡进公司大门 |
| 授权(Authorization) | AuthZ | 你能做什么?验证权限 | 进门后哪些楼层/房间你能进 |
1.2 传统认证方式的问题
传统 Session 方式:
用户登录 → 服务器创建 Session → 返回 SessionId(Cookie)
后续请求携带 Cookie → 服务器查 Session 确认身份
问题:
1. 微服务架构下,用户请求可能到不同服务器,Session 不共享
2. 需要 Session 共享方案(Redis Session),增加复杂度
3. 服务间调用也需要传递身份信息
Token 方式(OAuth2 + JWT):
用户登录 → 认证中心签发 Token → 返回给客户端
后续请求携带 Token → 各服务用公钥验证签名即可确认身份
优点:
1. 无状态:服务器不需要存储 Session
2. 自包含:Token 中携带用户信息,不需要查数据库
3. 跨服务:任何服务都能验证 Token
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、OAuth2 协议
2.1 是什么
OAuth2 是一个授权框架标准(RFC 6749),定义了客户端如何获取访问令牌(Access Token)的流程。
2.2 角色定义
| 角色 | 说明 |
|---|---|
| Resource Owner | 资源拥有者(用户) |
| Client | 客户端应用 |
| Authorization Server | 认证服务器 |
| Resource Server | 资源服务器 |
2.3 授权模式
| 模式 | 场景 |
|---|---|
| 密码模式(Password) | 用户名+密码直接换 Token |
| 授权码模式(Authorization Code) | 第三方应用授权(如微信登录) |
| 客户端模式(Client Credentials) | 服务间无用户参与的调用 |
| 刷新令牌模式(Refresh Token) | 用旧 Token 换新 Token |
2.4 密码模式完整流程
前端/客户端 认证服务器 资源服务器
│ │ │
│─── POST /oauth/token ────→│ │
│ grant_type=password │ │
│ username=test │ │
│ password=123456 │ │
│ Authorization: Basic │ │
│ (clientId:clientSecret) │ │
│ │ │
│ ├── 验证用户名密码 │
│ ├── 验证 clientId/Secret │
│ ├── 生成 JWT Token(RSA私钥签名)│
│ │ │
│←── 返回 Token ─────────────│ │
│ { │ │
│ "access_token":"eyJ..",│ │
│ "token_type":"bearer",│ │
│ "expires_in":53640 │ │
│ } │ │
│ │ │
│─── GET /api/xxx/xxx ─────────────────────────────────→│
│ Authorization: Bearer eyJ... │
│ │ │
│ │ ├── 用RSA公钥验证签名 │
│ │ ├── 检查Token是否过期 │
│ │ ├── 从Token中提取用户信息 │
│ │ └── 执行业务逻辑 │
│ │ │
│←── 返回业务数据 ────────────────────────────────────────│
三、JWT(JSON Web Token)
3.1 结构
JWT 由三部分组成,用 . 分隔:
eyJhbGciOiJSUzI1NiJ9.eyJtZW1iZXJfaWQiOjIxNTIyNyxxxxxxxx.签名部分
Header.Payload.Signature
Header(头部)
json
{
"alg": "RS256", // 签名算法:RSA + SHA-256
"typ": "JWT" // 令牌类型
}
→ Base64Url 编码 → eyJhbGciOiJSUzI1NiIsInxxxxx
Payload(载荷)
json
{
"user_name": "test",
"user_id": 1,
"member_id": 215227,
"parent_id": 215227,
"scope": ["all"],
"exp": 1758640298, // 过期时间(Unix时间戳)
"authorities": ["ROLE_USER"],
"jti": "53d5b75b-7a9c...", // Token唯一标识
"client_id": "demo_1"
}
→ Base64Url 编码 → eyJtZW1iZXJfaWQiOxxxx
Signature(签名)
签名 = RSA_SHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
privateKey // 认证中心的RSA私钥
)
→ Base64Url 编码 → dGhpcyBpcyBzaWduYXR1cmU...
3.2 为什么安全
篡改攻击:
攻击者修改 Payload 中的 member_id → 签名不匹配 → 验证失败
伪造攻击:
攻击者没有私钥 → 无法生成合法签名 → 验证失败
窃取攻击:
Token 被窃取 → 有时效性(exp),过期后失效
→ 使用 HTTPS 防止传输过程被窃取
3.3 RSA 非对称加密
认证中心(签发方):持有【私钥】
→ 用私钥签名生成 Token
各微服务(验证方):持有【公钥】
→ 用公钥验证签名真伪
→ 公钥可以公开分发,不影响安全性
→ 任何服务都能独立验证 Token,不需要调认证中心
四、本地获取 Token 的完整流程
4.1 请求格式
POST https://127.0.0.1/oauth/token
Headers:
Authorization: Basic ZGVtb19jbG91ZF9hcGlfZ2F0ZXdheToxMjM0NTY3OA==
↑ Base64(clientId:clientPassword)
↑ Base64("demo_cloud_api_gateway:12345678")
Content-Type: application/x-www-form-urlencoded
Body (form-urlencoded):
grant_type=password
username=test
password=123456
4.2 响应格式
json
{
"access_token": "eyJhbGciOiJSUzI1xxxxx.eyJtZW1iZXJfaWQiOjIxNTIyNy...",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJSUzI1NiIsIxxxxxx25hbWUi...",
"expires_in": 53640,
"scope": "all",
"member_id": 215227,
"parent_id": 215227,
"user_id": 1103671701,
"user_name_cn": "easyhandle_test",
"jti": "53d5b75b-7a9c-495c-83xxxx"
}
4.3 使用 Token 调业务接口
GET http://localhost:3012/api/page/xxx/xxx-xxx/get-order-id?...
Headers:
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
↑ 注意:Bearer 和 token 之间有一个空格
五、资源服务器验证流程
5.1 Spring Security OAuth2 验证链
HTTP 请求到达
│
├─→ OAuth2AuthenticationProcessingFilter
│ ├── 从 Header 提取 "Bearer xxx" → 获取 token 字符串
│ └── 调用 TokenStore 验证 token
│
├─→ JwtAccessTokenConverter(JWT 验证器)
│ ├── 用 RSA 公钥验证签名 → 签名合法?
│ ├── 检查 exp 字段 → Token 过期了?
│ ├── 检查 resource_ids → 当前服务有权接受此 Token?
│ └── 解析 Payload → 提取 user_id/member_id 等信息
│
├─→ SecurityContextHolder
│ └── 将认证信息放入当前线程的安全上下文
│
└─→ Controller 方法执行
└── 通过 JwtTokenUtil.getMemberId() 获取当前用户信息
5.2 配置文件中的公钥
yaml
# application.yml
security:
jwt:
signing-key: |
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkxxxxxx6fhMDz...
-----END PUBLIC KEY-----
resource-ids: test
六、通用示例代码
6.1 获取 Token(认证中心客户端)
java
/**
* OAuth2 Token 获取工具.
* 用于:服务间调用时获取系统级 Token.
*/
@Component
public class OAuth2TokenClient {
@Value("${auth.url}")
private String authUrl;
@Value("${auth.client-id}")
private String clientId;
@Value("${auth.client-password}")
private String clientPassword;
@Value("${auth.username}")
private String username;
@Value("${auth.password}")
private String password;
private final RestTemplate restTemplate = new RestTemplate();
/**
* 使用密码模式获取 Access Token.
*/
public String getAccessToken() {
// 1. 构建 Basic Auth Header(clientId:clientPassword 的 Base64)
String credentials = clientId + ":" + clientPassword;
String basicAuth = "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
// 2. 构建请求头
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", basicAuth);
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
// 3. 构建请求体(form-urlencoded)
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "password");
body.add("username", username);
body.add("password", password);
// 4. 发送请求
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(
authUrl + "/oauth/token", request, Map.class);
// 5. 提取 access_token
Map<String, Object> responseBody = response.getBody();
return (String) responseBody.get("access_token");
}
}
6.2 资源服务器配置
java
/**
* JWT Token 存储器.
* 用公钥验证签名,解析 Payload 中的用户信息.
*/
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(jwtAccessTokenConverter());
}
/**
* JWT 转换器:配置 RSA 公钥.
* 认证中心用私钥签名,资源服务器用公钥验证.
*/
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setVerifierKey(publicKey); // 设置 RSA 公钥
return converter;
}
}
6.3 从 Token 中获取当前用户信息
java
/**
* JWT Token 用户信息工具类.
* 从 Spring Security 上下文中提取 Token 中携带的用户信息.
*/
public class JwtTokenUtil {
/**
* 获取当前登录用户的会员ID.
* 从 JWT Payload 中的 member_id 字段获取.
*/
public static Long getMemberId() {
Map<String, Object> additionalInfo = getAdditionalInfo();
Object memberId = additionalInfo.get("member_id");
return memberId != null ? Long.valueOf(memberId.toString()) : null;
}
/**
* 获取当前登录用户ID.
*/
public static Long getUserId() {
Map<String, Object> additionalInfo = getAdditionalInfo();
Object userId = additionalInfo.get("user_id");
return userId != null ? Long.valueOf(userId.toString()) : null;
}
/**
* 获取当前登录用户名.
*/
public static String getUserName() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null) {
return authentication.getName();
}
return null;
}
/**
* 从 Security 上下文获取 Token 中的附加信息(additional_info).
*/
private static Map<String, Object> getAdditionalInfo() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
return Collections.emptyMap();
}
Object details = authentication.getDetails();
if (details instanceof OAuth2AuthenticationDetails) {
OAuth2AuthenticationDetails oauthDetails = (OAuth2AuthenticationDetails) details;
Object decoded = oauthDetails.getDecodedDetails();
if (decoded instanceof Map) {
return (Map<String, Object>) decoded;
}
}
return Collections.emptyMap();
}
}
6.4 使用示例
java
@RestController
public class DeliveryController {
/**
* 查询当前用户的发货单列表.
* Token 中携带了 memberId,无需前端传入.
*/
@GetMapping("/api/page/xxx/delivery/list")
public Result<List<DeliveryDto>> listDelivery() {
// 从 Token 中获取当前用户的 memberId
Long memberId = JwtTokenUtil.getMemberId();
if (memberId == null) {
throw new UnauthorizedException("未登录");
}
// 用 memberId 查询该用户的发货单
List<DeliveryDto> list = deliveryService.listByMemberId(memberId.intValue());
return Result.success(list);
}
}
6.5 Token 刷新机制
java
/**
* Token 刷新示例.
* access_token 有效期短(14小时),过期后用 refresh_token 换新的.
*/
@Component
public class TokenRefreshService {
@Value("${auth.url}")
private String authUrl;
@Value("${auth.client-id}")
private String clientId;
@Value("${auth.client-password}")
private String clientPassword;
/**
* 使用 refresh_token 获取新的 access_token.
* 无需用户重新输入密码.
*/
public String refreshToken(String refreshToken) {
String credentials = clientId + ":" + clientPassword;
String basicAuth = "Basic " + Base64.getEncoder()
.encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", basicAuth);
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "refresh_token"); // 刷新模式
body.add("refresh_token", refreshToken); // 用旧的 refresh_token
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Map> response = restTemplate.postForEntity(
authUrl + "/oauth/token", request, Map.class);
return (String) response.getBody().get("access_token");
}
}
6.6 服务间调用携带 Token
java
/**
* 微服务间 Feign 调用时自动携带 Token.
*/
@Component
public class FeignTokenInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
// 从当前请求的 Security 上下文中获取 Token
ServletRequestAttributes attributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
String token = request.getHeader("Authorization");
if (token != null && !token.isEmpty()) {
// 将当前请求的 Token 透传给下游服务
template.header("Authorization", token);
}
}
}
}
// Feign 客户端定义
@FeignClient(value = "xxx-service-order",
fallbackFactory = OrderFeignFallbackFactory.class)
public interface OrderFeign {
@PostMapping("/api/inner/xxx/management/get-order-status-info")
RestControllerResult<List<OrderInfoDto>> getOrderStatusInfo(
@RequestBody List<GetOrderStatusInfoParamsDto> params);
// Feign 调用时自动携带 Authorization Header(由 FeignTokenInterceptor 注入)
}
七、Token 生命周期
┌────────────────────────────────────────────────────────────────┐
│ Token 生命周期 │
├────────────────────────────────────────────────────────────────┤
│ │
│ 用户登录 → 认证中心签发 Token(有效期14小时) │
│ │ │
│ ▼ │
│ access_token 有效期间: │
│ - 前端每次请求携带 Token │
│ - 各服务用公钥验证签名 │
│ - 从 Payload 获取用户信息 │
│ │ │
│ ▼ │
│ access_token 即将过期(前端检测 expires_in): │
│ - 用 refresh_token 换新的 access_token │
│ - 无需用户重新登录 │
│ │ │
│ ▼ │
│ refresh_token 也过期: │
│ - 用户需要重新登录 │
│ - 前端跳转登录页 │
│ │
└────────────────────────────────────────────────────────────────┘
八、安全要点
| 要点 | 说明 |
|---|---|
| HTTPS 必须 | Token 在网络传输中必须加密,防止被截获 |
| Token 不存 localStorage | 容易被 XSS 攻击窃取,优先用 httpOnly Cookie |
| 短有效期 | access_token 有效期不宜过长(本项目14小时) |
| 签名验证 | 每次请求都验签名,防止 Token 被篡改 |
| 最小权限 | Token 中的 scope/authorities 限制可访问的资源 |
| 私钥保护 | RSA 私钥只在认证中心,不分发给任何其他服务 |
| resource_ids | 每个服务只接受自己 resourceId 的 Token |
九、关键设计总结
| 设计要点 | 实现方式 | 收益 |
|---|---|---|
| 无状态认证 | JWT 自包含用户信息 | 服务器不存 Session,水平扩展无压力 |
| 非对称加密 | RSA 私钥签名 + 公钥验签 | 私钥不出认证中心,安全性高 |
| Token 透传 | Feign Interceptor 自动携带 | 服务间调用无需手动传 Token |
| 用户信息提取 | SecurityContextHolder + @Version 解析 | 业务代码一行获取 memberId |
| 配置化公钥 | application.yml 配置 | 不同环境使用不同密钥对 |
| 刷新机制 | refresh_token 换新 token | 用户无感续期,体验好 |
| 权限控制 | security.matchers 配置 | 声明式定义哪些接口需要/不需要认证 |