附录B: OAuth 2.0 / OIDC 快速参考
2025/9/7大约 9 分钟
OAuth 2.0和OpenID Connect(OIDC)是现代身份认证和授权的核心协议。本快速参考指南将帮助开发者快速理解和使用这些协议的关键概念、流程和最佳实践。
引言
OAuth 2.0是一个授权框架,允许第三方应用在用户授权的情况下访问用户资源,而无需获取用户的凭证。OpenID Connect(OIDC)则是在OAuth 2.0基础上构建的身份认证层,提供了用户身份认证功能。
OAuth 2.0核心概念
角色定义
- Resource Owner(资源所有者):能够授予对受保护资源访问权限的实体,通常是用户
- Client(客户端):代表资源所有者请求访问受保护资源的应用
- Authorization Server(授权服务器):在成功验证资源所有者并获得授权后,向客户端颁发访问令牌的服务器
- Resource Server(资源服务器):托管受保护资源的服务器,能够接收和响应使用访问令牌的请求
授权类型
OAuth 2.0定义了四种主要的授权类型:
1. Authorization Code(授权码模式)
适用于有后端的Web应用,是最安全的授权类型。
# 授权请求
GET /authorize?
response_type=code&
client_id=s6BhdRkqt3&
redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb&
scope=read&
state=xyz HTTP/1.1
Host: server.example.com
# 授权响应
HTTP/1.1 302 Found
Location: https://client.example.org/cb?
code=SplxlOBeZQQYbYS6WxSbIA&
state=xyz
# 令牌请求
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=SplxlOBeZQQYbYS6WxSbIA&
redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
# 令牌响应
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"Bearer",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"scope":"read"
}
2. Implicit(简化模式)
适用于纯前端应用(SPA),令牌直接返回给客户端。
# 授权请求
GET /authorize?
response_type=token&
client_id=s6BhdRkqt3&
redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb&
scope=read&
state=xyz HTTP/1.1
Host: server.example.com
# 授权响应(片段标识符中包含令牌)
HTTP/1.1 302 Found
Location: https://client.example.org/cb#
access_token=2YotnFZFEjr1zCsicMWpAA&
token_type=Bearer&
expires_in=3600&
scope=read&
state=xyz
3. Resource Owner Password Credentials(密码模式)
适用于高度信任的应用,直接使用用户名和密码获取令牌。
# 令牌请求
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=password&
username=johndoe&
password=A3ddj3w
# 令牌响应
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"Bearer",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"scope":"read"
}
4. Client Credentials(客户端凭证模式)
适用于客户端以自己的名义访问受保护资源。
# 令牌请求
POST /token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
# 令牌响应
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"Bearer",
"expires_in":3600
}
OpenID Connect核心概念
OIDC扩展OAuth 2.0
OIDC在OAuth 2.0的基础上增加了身份认证功能,主要扩展包括:
- ID Token:JWT格式的身份令牌,包含用户身份信息
- UserInfo Endpoint:获取用户详细信息的端点
- 标准化的Scope和Claims:定义了标准的用户信息字段
ID Token结构
{
"iss": "https://server.example.com",
"sub": "24400320",
"aud": "s6BhdRkqt3",
"nonce": "n-0S6_WzA2Mj",
"exp": 1311281970,
"iat": 1311280970,
"auth_time": 1311280969,
"acr": "urn:mace:incommon:iap:silver"
}
标准Claims
标准Claims:
必需Claims:
- iss: 签发者标识符
- sub: 主体标识符
- aud: 受众标识符
- exp: 过期时间
- iat: 签发时间
可选Claims:
- auth_time: 认证时间
- nonce: 防重放攻击值
- acr: 认证上下文类引用
- amr: 认证方法引用
- azp: 授权方标识符
用户信息Claims:
- name: 姓名
- given_name: 名字
- family_name: 姓氏
- middle_name: 中间名
- nickname: 昵称
- preferred_username: 首选用户名
- profile: 个人资料页面URL
- picture: 头像URL
- website: 网站URL
- email: 电子邮件地址
- email_verified: 电子邮件验证状态
- gender: 性别
- birthdate: 生日
- zoneinfo: 时区
- locale: 区域设置
- phone_number: 电话号码
- phone_number_verified: 电话号码验证状态
- address: 地址信息
- updated_at: 最后更新时间
安全最佳实践
令牌安全
public class TokenSecurityBestPractices {
// 1. 安全存储访问令牌
public class SecureTokenStorage {
private final KeyStore keyStore;
public void storeAccessToken(String token, long expirationTime) {
// 加密存储令牌
String encryptedToken = encryptToken(token);
keyStore.store("access_token", encryptedToken, expirationTime);
}
public String getAccessToken() {
String encryptedToken = keyStore.retrieve("access_token");
if (encryptedToken != null) {
return decryptToken(encryptedToken);
}
return null;
}
}
// 2. 令牌刷新机制
public class TokenRefreshManager {
private final OAuth2Client client;
private String accessToken;
private String refreshToken;
public String getValidAccessToken() {
if (isTokenExpired(accessToken)) {
refreshAccessToken();
}
return accessToken;
}
private void refreshAccessToken() {
try {
TokenResponse response = client.refreshToken(refreshToken);
this.accessToken = response.getAccessToken();
this.refreshToken = response.getRefreshToken();
} catch (Exception e) {
// 处理刷新失败,可能需要重新认证
handleRefreshFailure(e);
}
}
}
// 3. 令牌撤销
public class TokenRevocation {
public void revokeToken(String token, String tokenType) {
client.revokeToken(token, tokenType);
// 清除本地存储
clearLocalTokenStorage();
}
}
}
防范常见攻击
class OAuth2SecurityMeasures:
def __init__(self):
self.state_store = {}
def generate_state_parameter(self):
"""生成state参数防止CSRF攻击"""
import uuid
state = str(uuid.uuid4())
# 存储state值用于后续验证
self.state_store[state] = {
'created_at': time.time(),
'used': False
}
return state
def validate_state_parameter(self, state):
"""验证state参数"""
if state not in self.state_store:
raise SecurityException("Invalid state parameter")
state_info = self.state_store[state]
if state_info['used']:
raise SecurityException("State parameter already used")
# 检查是否过期(5分钟有效期)
if time.time() - state_info['created_at'] > 300:
raise SecurityException("State parameter expired")
# 标记为已使用
state_info['used'] = True
return True
def validate_redirect_uri(self, client_id, redirect_uri):
"""验证重定向URI"""
# 从配置中获取客户端注册的重定向URI
registered_uris = self.get_registered_redirect_uris(client_id)
# 精确匹配或前缀匹配
for registered_uri in registered_uris:
if redirect_uri == registered_uri:
return True
# 支持路径前缀匹配
if redirect_uri.startswith(registered_uri):
return True
return False
def validate_pkce_challenge(self, code_verifier, code_challenge, method):
"""验证PKCE挑战"""
import hashlib
import base64
if method == "S256":
# SHA-256哈希后Base64URL编码
challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode().rstrip('=')
elif method == "plain":
challenge = code_verifier
else:
raise ValueError("Unsupported PKCE method")
return challenge == code_challenge
实施示例
前端实现(JavaScript)
class OAuth2Client {
constructor(config) {
this.config = {
clientId: config.clientId,
redirectUri: config.redirectUri,
authorizationEndpoint: config.authorizationEndpoint,
tokenEndpoint: config.tokenEndpoint,
scopes: config.scopes || ['openid', 'profile', 'email']
};
this.stateManager = new StateManager();
}
// 发起授权请求
async authorize() {
// 生成state参数
const state = this.stateManager.generateState();
// 生成PKCE code verifier和challenge
const { codeVerifier, codeChallenge } = await this.generatePKCE();
// 构建授权URL
const authUrl = new URL(this.config.authorizationEndpoint);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', this.config.clientId);
authUrl.searchParams.append('redirect_uri', this.config.redirectUri);
authUrl.searchParams.append('scope', this.config.scopes.join(' '));
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', 'S256');
// 存储code verifier用于后续令牌交换
sessionStorage.setItem('code_verifier', codeVerifier);
// 重定向到授权服务器
window.location.href = authUrl.toString();
}
// 处理授权回调
async handleCallback() {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
// 检查错误
if (error) {
throw new Error(`Authorization error: ${error}`);
}
// 验证state参数
if (!this.stateManager.validateState(state)) {
throw new Error('Invalid state parameter');
}
// 交换授权码获取令牌
return await this.exchangeCodeForToken(code);
}
// 交换授权码获取令牌
async exchangeCodeForToken(code) {
const codeVerifier = sessionStorage.getItem('code_verifier');
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
redirect_uri: this.config.redirectUri,
client_id: this.config.clientId,
code_verifier: codeVerifier
})
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}
const tokenResponse = await response.json();
// 存储令牌
this.storeTokens(tokenResponse);
return tokenResponse;
}
// 生成PKCE参数
async generatePKCE() {
// 生成随机code verifier
const array = new Uint8Array(32);
window.crypto.getRandomValues(array);
const codeVerifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
// 生成code challenge
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hash = await window.crypto.subtle.digest('SHA-256', data);
const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
return { codeVerifier, codeChallenge };
}
// 存储令牌
storeTokens(tokens) {
// 安全存储访问令牌
localStorage.setItem('access_token', tokens.access_token);
// 存储刷新令牌(如果存在)
if (tokens.refresh_token) {
localStorage.setItem('refresh_token', tokens.refresh_token);
}
// 存储过期时间
if (tokens.expires_in) {
const expiresAt = Date.now() + (tokens.expires_in * 1000);
localStorage.setItem('expires_at', expiresAt.toString());
}
}
// 获取有效的访问令牌
async getValidAccessToken() {
const accessToken = localStorage.getItem('access_token');
const expiresAt = localStorage.getItem('expires_at');
// 检查令牌是否过期
if (!accessToken || !expiresAt || Date.now() >= parseInt(expiresAt)) {
// 尝试刷新令牌
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
try {
const newTokens = await this.refreshToken(refreshToken);
return newTokens.access_token;
} catch (error) {
// 刷新失败,需要重新认证
this.clearTokens();
return null;
}
}
return null;
}
return accessToken;
}
// 刷新令牌
async refreshToken(refreshToken) {
const response = await fetch(this.config.tokenEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.config.clientId
})
});
if (!response.ok) {
throw new Error(`Token refresh failed: ${response.status}`);
}
const tokenResponse = await response.json();
// 更新存储的令牌
this.storeTokens(tokenResponse);
return tokenResponse;
}
// 清除令牌
clearTokens() {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('expires_at');
sessionStorage.removeItem('code_verifier');
}
}
// State管理器
class StateManager {
generateState() {
const state = Math.random().toString(36).substring(2, 15) +
Math.random().toString(36).substring(2, 15);
sessionStorage.setItem('oauth_state', state);
return state;
}
validateState(state) {
const storedState = sessionStorage.getItem('oauth_state');
sessionStorage.removeItem('oauth_state');
return state && storedState && state === storedState;
}
}
后端实现(Java Spring Security)
@Configuration
@EnableWebSecurity
public class OAuth2SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
.failureUrl("/login?error=true")
)
.oauth2Client(withDefaults())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(withDefaults())
);
return http.build();
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
// 配置OAuth2客户端注册
ClientRegistration clientRegistration = ClientRegistrations
.fromOidcIssuerLocation("https://accounts.google.com")
.registrationId("google")
.clientId("your-client-id")
.clientSecret("your-client-secret")
.scope("openid", "profile", "email")
.build();
return new InMemoryClientRegistrationRepository(clientRegistration);
}
@Bean
public OAuth2AuthorizedClientService authorizedClientService(
ClientRegistrationRepository clientRegistrationRepository) {
return new InMemoryOAuth2AuthorizedClientService(clientRegistrationRepository);
}
@Bean
public OAuth2AuthorizedClientRepository authorizedClientRepository(
OAuth2AuthorizedClientService authorizedClientService) {
return new AuthenticatedPrincipalOAuth2AuthorizedClientRepository(authorizedClientService);
}
}
@RestController
public class UserController {
@GetMapping("/user/info")
public Map<String, Object> getUserInfo(
@RegisteredOAuth2AuthorizedClient OAuth2AuthorizedClient authorizedClient) {
// 获取访问令牌
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
// 使用令牌调用用户信息端点
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken.getTokenValue());
HttpEntity<?> entity = new HttpEntity<>(headers);
ResponseEntity<Map> response = restTemplate.exchange(
"https://openidconnect.googleapis.com/v1/userinfo",
HttpMethod.GET,
entity,
Map.class
);
return response.getBody();
}
}
常见问题与解决方案
1. 令牌过期处理
// 自动刷新令牌的拦截器
class TokenRefreshInterceptor {
async intercept(config) {
// 检查令牌是否即将过期
const accessToken = localStorage.getItem('access_token');
const expiresAt = localStorage.getItem('expires_at');
if (accessToken && expiresAt) {
const now = Date.now();
const expirationTime = parseInt(expiresAt);
// 如果令牌将在5分钟内过期,尝试刷新
if (expirationTime - now < 5 * 60 * 1000) {
try {
const refreshToken = localStorage.getItem('refresh_token');
if (refreshToken) {
const newTokens = await this.refreshToken(refreshToken);
config.headers.Authorization = `Bearer ${newTokens.access_token}`;
}
} catch (error) {
// 刷新失败,清除令牌并重定向到登录页
this.clearTokens();
window.location.href = '/login';
}
} else {
config.headers.Authorization = `Bearer ${accessToken}`;
}
}
return config;
}
}
2. 单点登出实现
@RestController
public class LogoutController {
@PostMapping("/logout")
public ResponseEntity<?> logout(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
// 清除本地会话
new SecurityContextLogoutHandler().logout(request, response, authentication);
// 如果使用了OAuth2登录,重定向到提供商的登出端点
OAuth2AuthenticationToken oauth2Token = (OAuth2AuthenticationToken) authentication;
if (oauth2Token != null) {
String issuerUri = getIssuerUri(oauth2Token);
String logoutUrl = issuerUri + "/v2/logout?" +
"client_id=" + getClientId() +
"&returnTo=" + getReturnToUrl();
return ResponseEntity.status(HttpStatus.FOUND)
.location(URI.create(logoutUrl))
.build();
}
return ResponseEntity.ok().build();
}
}
总结
OAuth 2.0和OIDC是现代应用开发中不可或缺的身份认证和授权协议。通过理解其核心概念、授权流程和安全最佳实践,开发者可以构建更加安全、可靠的应用系统。在实施过程中,务必注意令牌安全、防范常见攻击,并根据具体场景选择合适的授权类型。
本快速参考指南涵盖了这些协议的主要内容,但在实际应用中还需要根据具体需求和安全要求进行深入研究和定制实现。