跳到主要内容

OAuth 2.0 与 OpenID Connect

OAuth 2.0 和 OpenID Connect 是现代 Web 应用中实现第三方登录和授权的核心协议。OAuth 2.0 专注于授权,而 OpenID Connect 在 OAuth 2.0 之上添加了身份认证层,两者共同构成了现代身份管理的基础。

为什么需要 OAuth 2.0?

在传统的应用授权模式中,用户需要将自己的用户名和密码提供给第三方应用,让第三方应用代表自己访问资源。这种方式存在严重的安全隐患:

  1. 密码泄露风险 - 第三方应用需要存储用户密码
  2. 权限过度授予 - 第三方应用获得用户的全部权限
  3. 无法撤销权限 - 用户无法单独撤销某个应用的访问权限,只能修改密码
  4. 密码修改影响 - 用户修改密码后,所有第三方应用都无法正常工作

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

OAuth 2.0 核心概念

角色定义

OAuth 2.0 定义了四种核心角色:

资源所有者(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 参数
if (!state.equals(session.getAttribute("oauth2_state"))) {
throw new SecurityException("Invalid state parameter");
}


// ❌ 错误 2: 允许任意的重定向 URI
params.add("redirect_uri", userInput); // 危险!

// ✅ 正确: 只允许预注册的 URI
if (!allowedRedirectUris.contains(redirectUri)) {
throw new SecurityException("Invalid redirect URI");
}


// ❌ 错误 3: 在客户端暴露 client_secret
// 浏览器或移动应用中硬编码 secret

// ✅ 正确: 公共客户端使用 PKCE
// 机密客户端在服务器端存储 secret


// ❌ 错误 4: 不使用 HTTPS
// 令牌在传输过程中可能被截获

// ✅ 正确: 所有通信必须使用 HTTPS


// ❌ 错误 5: 长期存储 Access Token
// 增加令牌泄露的风险

// ✅ 正确: 使用 Refresh Token 机制
// Access Token 短期有效(1小时)
// Refresh Token 长期有效(7天)

实现 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 2.0 和 OpenID Connect 是现代身份认证和授权的基础:

  1. OAuth 2.0 核心

    • 授权码模式是最安全、最常用的模式
    • 公共客户端必须使用 PKCE
    • 所有通信必须使用 HTTPS
  2. OpenID Connect

    • 在 OAuth 2.0 之上添加身份认证层
    • 通过 ID Token 传递用户信息
    • 标准化了用户信息的获取方式
  3. 安全要点

    • 验证 state 参数防止 CSRF
    • 严格验证重定向 URI
    • 安全存储 client_secret
    • 实施适当的令牌生命周期
  4. 最佳实践

    • 优先使用授权码模式 + PKCE
    • 使用短期 Access Token + 长期 Refresh Token
    • 实施最小权限原则(Scope)
    • 记录和监控授权事件