跳到主要内容

oauth-oidc

OAuth 2.0 通过引入授权层,将客户端应用的角色与资源所有者的角色分离,解决了这些问题。第三方应用不再需要用户的密码,而是使用临时的访问令牌来访问资源。

核心角色定义

OAuth 协议定义了四种核心角色:

资源所有者 (Resource Owner):通常为终端用户,拥有受保护的数据。

资源服务器 (Resource Server):托管受保护资源的服务器,负责验证令牌并响应资源请求。

客户端 (Client):代表资源所有者发起资源请求的应用程序。

授权服务器 (Authorization Server):负责验证身份并在获得授权后向客户端颁发令牌。

协议流程

OAuth 2.0 的抽象授权流程如下:

+--------+                               +---------------+
| |--(A)- Authorization Request ->| Resource |
| | | Owner |
| |<-(B)-- Authorization Grant ---| |
| | +---------------+
| |
| | +---------------+
| |--(C)-- Authorization Grant -->| Authorization |
| Client | | Server |
| |<-(D)----- Access Token -------| |
| | +---------------+
| |
| | +---------------+
| |--(E)----- Access Token ------>| Resource |
| | | Server |
| |<-(F)--- Protected Resource ---| |
+--------+ +---------------+

流程说明:

  1. (A) 客户端向资源所有者请求授权,授权请求可以直接发给资源所有者,或者通过授权服务器间接转发
  2. (B) 资源所有者同意授权,返回授权许可(Authorization Grant)
  3. (C) 客户端使用授权许可向授权服务器请求访问令牌
  4. (D) 授权服务器验证客户端身份和授权许可,颁发访问令牌
  5. (E) 客户端使用访问令牌向资源服务器请求受保护资源
  6. (F) 资源服务器验证访问令牌,返回受保护资源

授权许可类型

OAuth 2.0 定义了四种授权许可类型,适用于不同的应用场景:

1. 授权码模式(Authorization Code Grant)

这是最常用、最安全的授权模式,适用于有后端的 Web 应用。

适用场景

  • 传统的服务器端 Web 应用
  • 需要长期访问令牌的应用
  • 对安全性要求高的应用

流程详解

+----------+
| Resource |
| Owner |
| |
+----------+
^
|
(B)
+----|-----+ Client Identifier +---------------+
| -+----(A)-- & Redirection URI ---->| |
| User- | | Authorization |
| Agent -+----(B)-- User authenticates --->| Server |
| | | |
| -+----(C)-- Authorization Code ---| |
+-|----|---+ +---------------+
| | ^ v
(A) (C) | |
| | | |
^ v | |
+---------+ | |
| |>---(D)-- Authorization Code ---------' |
| Client | & Redirection URI |
| | |
| |<---(E)----- Access Token -------------------'
+---------+ (w/ Optional Refresh Token)

步骤说明

  1. (A) 客户端将用户代理(浏览器)重定向到授权服务器的授权端点,携带客户端标识和重定向 URI
  2. (B) 授权服务器验证资源所有者身份(通常是登录页面),资源所有者同意授权
  3. (C) 授权服务器将用户代理重定向回客户端,携带授权码
  4. (D) 客户端使用授权码向授权服务器的令牌端点请求访问令牌
  5. (E) 授权服务器验证授权码和客户端身份,返回访问令牌

代码示例(Java Spring Security OAuth2 Client)

// application.yml 配置
spring:
security:
oauth2:
client:
registration:
google:
client-id: your-client-id
client-secret: your-client-secret
scope:
- openid
- profile
- email
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
google:
authorization-uri: https://accounts.google.com/o/oauth2/v2/auth
token-uri: https://www.googleapis.com/oauth2/v4/token
user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo
// Security 配置
@Configuration
@EnableWebSecurity
public class SecurityConfig {

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
);
return http.build();
}
}

手动实现授权码流程(Java)

@Service
public class OAuth2Service {

private final String clientId = "your-client-id";
private final String clientSecret = "your-client-secret";
private final String redirectUri = "http://localhost:8080/callback";
private final String authorizeUrl = "https://oauth.example.com/authorize";
private final String tokenUrl = "https://oauth.example.com/token";

/**
* 第一步:构建授权请求URL,重定向用户到授权服务器
*/
public String buildAuthorizationUrl(String state) {
return UriComponentsBuilder.fromHttpUrl(authorizeUrl)
.queryParam("response_type", "code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("scope", "read:user email")
.queryParam("state", state)
.build()
.toUriString();
}

/**
* 第二步:使用授权码交换访问令牌
*/
public TokenResponse exchangeCodeForToken(String code) {
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(clientId, clientSecret);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("code", code);
params.add("redirect_uri", redirectUri);

HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);

ResponseEntity<TokenResponse> response = restTemplate.postForEntity(
tokenUrl, request, TokenResponse.class);

return response.getBody();
}
}

@Data
public class TokenResponse {
private String access_token;
private String token_type;
private Long expires_in;
private String refresh_token;
private String scope;
}

2. 简化模式(Implicit Grant)

警告:该模式已被标记为废弃,不推荐使用。请使用授权码模式配合 PKCE。

简化模式直接在浏览器中返回访问令牌,没有授权码交换步骤。由于令牌暴露在 URL 中,存在安全风险。

3. 密码凭证模式(Resource Owner Password Credentials Grant)

用户直接向客户端提供用户名和密码,客户端使用这些凭据向授权服务器请求令牌。

适用场景

  • 受信任的应用(如第一方移动应用)
  • 遗留系统的迁移
  • 不推荐用于新应用

代码示例

public TokenResponse passwordGrant(String username, String password) {
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(clientId, clientSecret);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "password");
params.add("username", username);
params.add("password", password);
params.add("scope", "read write");

HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);

return restTemplate.postForEntity(
tokenUrl, request, TokenResponse.class).getBody();
}

4. 客户端凭证模式(Client Credentials Grant)

客户端使用自己的凭据(而非用户的凭据)向授权服务器请求令牌。适用于服务器之间的通信。

适用场景

  • 后台服务之间的 API 调用
  • 数据同步任务
  • 不需要用户参与的自动化流程
public TokenResponse clientCredentialsGrant() {
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.setBasicAuth(clientId, clientSecret);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "client_credentials");
params.add("scope", "api:read api:write");

HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);

return restTemplate.postForEntity(
tokenUrl, request, TokenResponse.class).getBody();
}

PKCE 扩展

PKCE(Proof Key for Code Exchange)是授权码模式的扩展,用于防止授权码拦截攻击。对于公共客户端(如移动应用、单页应用)是必需的。

PKCE 流程

+--------+                               +---------------+
| |--(A)- Authorization Request --| Resource |
| | + code_challenge | Owner |
| | +---------------+
| |<-(B)----- Authorization Grant ----------------|
| | + code |
| Client | +---------------+
| |--(C)----- Access Token Request -------------->| Authorization |
| | + code_verifier | Server |
| |<-(D)----- Access Token -----------------------| |
+--------+ +---------------+

实现示例

@Service
public class PkceOAuth2Service {

/**
* 生成 PKCE 参数
*/
public PkceParams generatePkceParams() {
// 1. 生成 code_verifier(43-128 个字符的随机字符串)
SecureRandom random = new SecureRandom();
byte[] codeVerifierBytes = new byte[32];
random.nextBytes(codeVerifierBytes);
String codeVerifier = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(codeVerifierBytes);

// 2. 计算 code_challenge(SHA-256 哈希后 Base64URL 编码)
String codeChallenge;
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(codeVerifier.getBytes());
codeChallenge = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}

return new PkceParams(codeVerifier, codeChallenge, "S256");
}

/**
* 构建授权 URL(包含 code_challenge)
*/
public String buildAuthorizationUrl(PkceParams pkceParams, String state) {
return UriComponentsBuilder.fromHttpUrl(authorizeUrl)
.queryParam("response_type", "code")
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri)
.queryParam("scope", "openid profile email")
.queryParam("state", state)
.queryParam("code_challenge", pkceParams.getCodeChallenge())
.queryParam("code_challenge_method", pkceParams.getCodeChallengeMethod())
.build()
.toUriString();
}

/**
* 交换授权码(包含 code_verifier)
*/
public TokenResponse exchangeCodeForToken(String code, String codeVerifier) {
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);

MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("grant_type", "authorization_code");
params.add("code", code);
params.add("redirect_uri", redirectUri);
params.add("client_id", clientId);
params.add("code_verifier", codeVerifier);

HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);

return restTemplate.postForEntity(
tokenUrl, request, TokenResponse.class).getBody();
}
}

@Data
@AllArgsConstructor
public class PkceParams {
private String codeVerifier;
private String codeChallenge;
private String codeChallengeMethod;
}

OpenID Connect

OpenID Connect(OIDC)是基于 OAuth 2.0 协议的身份认证层。它在 OAuth 2.0 的基础上添加了身份验证功能,允许客户端验证用户身份并获取用户基本信息。

核心概念

ID Token

OIDC 引入了 ID Token,这是一个包含用户身份信息的 JWT。与 Access Token 不同,ID Token 专门用于身份验证。

{
"iss": "https://accounts.google.com",
"sub": "1234567890",
"aud": "your-client-id",
"exp": 1234567890,
"iat": 1234567800,
"name": "John Doe",
"email": "[email protected]",
"picture": "https://example.com/photo.jpg"
}

标准声明(Standard Claims)

声明说明
sub用户的唯一标识符
name用户全名
given_name
family_name
email电子邮件地址
email_verified邮箱是否已验证
picture头像 URL
updated_at信息最后更新时间

OIDC 流程

/**
* OpenID Connect 认证流程
*/
@Service
public class OidcService {

private final String issuer = "https://accounts.google.com";
private final String jwksUri = "https://www.googleapis.com/oauth2/v3/certs";

/**
* 验证 ID Token
*/
public DecodedJWT verifyIdToken(String idToken) {
// 1. 解析 JWT
DecodedJWT jwt = JWT.decode(idToken);

// 2. 验证发行者
if (!issuer.equals(jwt.getIssuer())) {
throw new JwtVerificationException("Invalid issuer");
}

// 3. 验证受众
if (!clientId.equals(jwt.getAudience().get(0))) {
throw new JwtVerificationException("Invalid audience");
}

// 4. 验证过期时间
if (jwt.getExpiresAt().before(new Date())) {
throw new JwtVerificationException("Token expired");
}

// 5. 验证签名
JwkProvider provider = new UrlJwkProvider(new URL(jwksUri));
Jwk jwk = provider.get(jwt.getKeyId());

Algorithm algorithm = Algorithm.RSA256(
(RSAPublicKey) jwk.getPublicKey(),
null
);

JWTVerifier verifier = JWT.require(algorithm)
.withIssuer(issuer)
.withAudience(clientId)
.build();

return verifier.verify(idToken);
}

/**
* 获取用户信息
*/
public UserInfo getUserInfo(String accessToken) {
RestTemplate restTemplate = new RestTemplate();

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

HttpEntity<Void> request = new HttpEntity<>(headers);

ResponseEntity<UserInfo> response = restTemplate.exchange(
userInfoEndpoint,
HttpMethod.GET,
request,
UserInfo.class
);

return response.getBody();
}
}

@Data
public class UserInfo {
private String sub;
private String name;
private String givenName;
private String familyName;
private String picture;
private String email;
private Boolean emailVerified;
}

OAuth 2.0 安全最佳实践

安全清单

/**
* OAuth 2.0 安全检查
*/
@Component
public class OAuth2SecurityChecker {

/**
* 验证 state 参数
* 防止 CSRF 攻击
*/
public boolean validateState(String state, HttpSession session) {
String storedState = (String) session.getAttribute("oauth2_state");
return state != null && state.equals(storedState);
}

/**
* 验证重定向 URI
* 防止授权码重定向攻击
*/
public boolean validateRedirectUri(String redirectUri) {
// 只允许预注册的重定向 URI
List<String> allowedUris = Arrays.asList(
"http://localhost:8080/callback",
"https://myapp.com/callback"
);
return allowedUris.contains(redirectUri);
}

/**
* 生成安全的 state 参数
*/
public String generateState() {
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[16];
random.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}

常见安全错误

// 错误 1: 忽略 state 参数验证,导致系统易受 CSRF 登录攻击。
// 正确: 强制验证 state 参数的一致性。

// 错误 2: 允许通配符或任意重定向 URI,可能导致授权码被重定向至非法域名。
// 正确: 严格匹配预先注册的重定向地址。

// 错误 3: 在公共客户端(如 SPA 或移动端)暴露 client_secret。
// 正确: 公共客户端应使用 PKCE 机制,不依赖 secret 验证。

// 错误 4: 未使用 HTTPS 传输,令牌存在被嗅探截获的风险。
// 正确: 强制所有协议通信走加密通道。

// 错误 5: 设置过长的令牌有效期。
// 正确: 采用短期访问令牌与 Refresh Token 刷新的机制。

实现 OAuth 2.0 授权服务器

/**
* 简化的 OAuth 2.0 授权服务器实现
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig {

@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient client = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("my-client")
.clientSecret(passwordEncoder().encode("my-secret"))
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://localhost:8080/callback")
.scope("read")
.scope("write")
.scope("openid")
.scope("profile")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenTimeToLive(Duration.ofHours(1))
.refreshTokenTimeToLive(Duration.ofDays(7))
.build())
.build();

return new InMemoryRegisteredClientRepository(client);
}

@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return context -> {
if (context.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
context.getClaims().claim("custom_claim", "custom_value");
}
};
}
}

小结

OAuth 与 OIDC 是实现安全身份体系的基础:

  1. 授权码模式配合 PKCE 是现代应用的首选安全方案。
  2. OIDC 提供了标准化的 ID Token,简化了跨系统的用户身份同步。
  3. 安全实施的关键在于重定向校验、state 验证、密钥保护以及合理的令牌生命周期管理。