在构建单点登录系统时,OAuth 2.0的授权码模式因其较高的安全性而被广泛采用。许多开发者初识该流程时,可能将其简化为“获取code -> 换取token”等寥寥数步。然而,在实际的工程实现中,一个健壮的OAuth 2.0授权码流程远不止于此,涉及大量细节处理与安全考量,其复杂程度远超想象。
OAuth 2.0授权码流程:基础认知与完整实践
一个简易的认知模型通常包括四步:获取授权码、用授权码换取访问令牌、使用令牌访问受保护资源、以及刷新令牌。
然而,一个生产级别的完整流程需要考虑数十个关键步骤与安全环节,其核心交互流程如下图所示:

代码实现:基于Spring Security OAuth2的完整配置
下面以经典的 Java Spring 框架为例,展示如何构建一个兼顾功能与安全的OAuth 2.0授权服务器、资源服务器和客户端。
1. 授权服务器配置
授权服务器负责颁发授权码和令牌,是SSO系统的核心。
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private DataSource dataSource;
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private UserDetailsService userDetailsService;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
// 使用数据库存储客户端信息,便于管理
clients.jdbc(dataSource)
.withClient("system-a")
.secret(passwordEncoder().encode("system-a-secret"))
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("read", "write")
.redirectUris("http://system-a.com/login/oauth2/code/sso")
.autoApprove(true) // 自动授权,简化演示流程
.accessTokenValiditySeconds(3600) // access_token 1小时过期
.refreshTokenValiditySeconds(86400 * 7) // refresh_token 7天过期
.and()
.withClient("system-b")
.secret(passwordEncoder().encode("system-b-secret"))
.authorizedGrantTypes("authorization_code", "refresh_token")
.scopes("read", "write")
.redirectUris("http://system-b.com/login/oauth2/code/sso")
.autoApprove(true)
.accessTokenValiditySeconds(3600)
.refreshTokenValiditySeconds(86400 * 7);
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
endpoints
.tokenStore(tokenStore())
.accessTokenConverter(jwtAccessTokenConverter())
.authenticationManager(authenticationManager)
.userDetailsService(userDetailsService)
.authorizationCodeServices(authorizationCodeServices())
.pathMapping("/oauth/confirm_access", "/oauth/authorize")
.addInterceptor(new SsoInterceptor()); // 关键:添加state参数验证拦截器
}
@Bean
public TokenStore tokenStore() {
// 使用JWT存储token,实现无状态令牌
return new JwtTokenStore(jwtAccessTokenConverter());
}
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("sso-secret-key-at-least-256-bits-long-for-hs256");
return converter;
}
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
// 使用数据库存储授权码,防止code被重复使用
return new JdbcAuthorizationCodeServices(dataSource);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
// 自定义拦截器:用于生成和验证state参数
@Component
public class SsoInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String state = request.getParameter("state");
// 如果是授权请求,且未携带state,则生成并重定向
if (request.getRequestURI().equals("/oauth/authorize") && state == null) {
state = generateRandomState();
request.getSession().setAttribute("oauth_state", state);
String redirectUri = request.getParameter("redirect_uri");
redirectUri += (redirectUri.contains("?") ? "&" : "?") + "state=" + state;
response.sendRedirect(redirectUri);
return false;
}
// 如果是回调请求,验证state是否匹配
if (request.getRequestURI().equals("/login/oauth2/code/sso") && state != null) {
String sessionState = (String) request.getSession().getAttribute("oauth_state");
if (!state.equals(sessionState)) {
throw new InvalidRequestException("state参数不匹配,可能受到CSRF攻击");
}
// state验证通过,立即移除Session中的值
request.getSession().removeAttribute("oauth_state");
}
return true;
}
private String generateRandomState() {
return Base64.getUrlEncoder().encodeToString(
UUID.randomUUID().toString().getBytes()
);
}
}
2. 资源服务器配置
资源服务器负责验证令牌并保护API资源。
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources
.tokenServices(tokenServices())
.resourceId("resource");
}
@Bean
public ResourceServerTokenServices tokenServices() {
RemoteTokenServices services = new RemoteTokenServices();
services.setCheckTokenEndpointUrl("http://sso.com/oauth/check_token");
services.setClientId("resource-server");
services.setClientSecret("resource-secret");
return services;
}
@Override
public void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated();
}
}
3. 客户端配置(以system-a为例)
客户端应用引导用户完成OAuth登录,并管理本地会话。
@Configuration
@EnableWebSecurity
public class OAuth2ClientConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/login**", "/oauth2/**").permitAll()
.anyRequest().authenticated()
.and()
.oauth2Login()
.loginPage("/login")
.authorizationEndpoint()
.authorizationRequestResolver(customAuthorizationRequestResolver()) // 自定义state生成逻辑
.and()
.successHandler(successHandler())
.failureHandler(failureHandler())
.and()
.logout()
.logoutSuccessHandler(logoutSuccessHandler())
.and()
.csrf().disable(); // 注意:生产环境应妥善配置CSRF
}
@Bean
public OAuth2AuthorizationRequestResolver customAuthorizationRequestResolver() {
return new CustomAuthorizationRequestResolver(
clientRegistrationRepository(),
"/oauth2/authorization"
);
}
@Bean
public ClientRegistrationRepository clientRegistrationRepository() {
return new InMemoryClientRegistrationRepository(
ClientRegistration.withRegistrationId("sso")
.clientId("system-a")
.clientSecret("system-a-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://system-a.com/login/oauth2/code/sso")
.scope("read", "write")
.authorizationUri("http://sso.com/oauth/authorize")
.tokenUri("http://sso.com/oauth/token")
.userInfoUri("http://sso.com/oauth/userinfo")
.jwkSetUri("http://sso.com/oauth/token_keys")
.userNameAttributeName("sub")
.clientName("SSO")
.build()
);
}
@Bean
public AuthenticationSuccessHandler successHandler() {
return (request, response, authentication) -> {
// 登录成功,创建本地Session
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
String userId = oauthToken.getPrincipal().getAttribute("sub");
String username = oauthToken.getPrincipal().getAttribute("preferred_username");
request.getSession().setAttribute("user_id", userId);
request.getSession().setAttribute("username", username);
response.sendRedirect("/home");
};
}
@Bean
public AuthenticationFailureHandler failureHandler() {
return (request, response, exception) -> {
log.error("SSO登录失败", exception);
response.sendRedirect("/login?error=" + exception.getMessage());
};
}
@Bean
public LogoutSuccessHandler logoutSuccessHandler() {
return (request, response, authentication) -> {
// 1. 调用授权服务器的revoke接口使令牌失效
String accessToken = (String) request.getSession().getAttribute("access_token");
if (accessToken != null) {
revokeToken(accessToken);
}
// 2. 清除本地Session
request.getSession().invalidate();
// 3. 重定向到SSO全局注销页
response.sendRedirect("http://sso.com/logout");
};
}
private void revokeToken(String token) {
RestTemplate restTemplate = new RestTemplate();
HttpHeaders headers = new HttpHeaders();
headers.setBasicAuth("system-a", "system-a-secret");
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("token", token);
HttpEntity<MultiValueMap<String, String>> entity = new HttpEntity<>(params, headers);
try {
restTemplate.postForObject("http://sso.com/oauth/revoke", entity, String.class);
} catch (Exception e) {
log.error("撤销token失败", e);
}
}
}
// 自定义AuthorizationRequestResolver:负责生成state并存储
public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
private final OAuth2AuthorizationRequestResolver defaultResolver;
public CustomAuthorizationRequestResolver(ClientRegistrationRepository repo, String authorizationUri) {
this.defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(repo, authorizationUri);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request);
return customizeAuthorizationRequest(authorizationRequest, request);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
OAuth2AuthorizationRequest authorizationRequest = defaultResolver.resolve(request, clientRegistrationId);
return customizeAuthorizationRequest(authorizationRequest, request);
}
private OAuth2AuthorizationRequest customizeAuthorizationRequest(
OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request) {
if (authorizationRequest == null) {
return null;
}
// 生成随机state
String state = generateState();
// 将state存入Session,用于后续验证
request.getSession().setAttribute("oauth_state", state);
// 返回自定义的AuthorizationRequest
return OAuth2AuthorizationRequest.from(authorizationRequest)
.state(state)
.build();
}
private String generateState() {
return Base64.getUrlEncoder().encodeToString(
UUID.randomUUID().toString().getBytes()
);
}
}
核心踩坑点与解决方案详解
在实际集成OAuth 2.0授权码流程时,以下几个坑点尤为常见。
坑1:忽略State参数验证,导致CSRF攻击
风险:攻击者可以伪造一个授权链接(如 http://sso.com/oauth/authorize?client_id=system-a&redirect_uri=http://evil.com)。如果用户已在授权服务器登录且未验证state,授权服务器会直接重定向至攻击者的evil.com并附上授权码,从而造成代码泄露。
这与常见的 网络安全 防护思想一致,即任何外部传入的参数都不可信。
解决方案:
- 客户端发起授权请求时,生成一个高强度的随机字符串作为
state参数,并将其与当前会话绑定(如存入Session)。
- 授权服务器在回调时,严格比对客户端传来的
state参数与会话中存储的是否一致。
- 验证完毕后,立即清除会话中的
state值,防止重复使用。
核心代码逻辑:
// 生成并存储state
String state = UUID.randomUUID().toString();
request.getSession().setAttribute("oauth_state", state);
// 回调时验证state
String callbackState = request.getParameter("state");
String sessionState = (String) request.getSession().getAttribute("oauth_state");
if (!callbackState.equals(sessionState)) {
throw new SecurityException("CSRF攻击 detected");
}
// 验证通过,立即移除
request.getSession().removeAttribute("oauth_state");
坑2:授权码可能被拦截,未使用PKCE增强保护
风险:授权码通过浏览器的URL参数传递,存在被网络窃听或浏览器历史记录泄露的风险。
解决方案:采用PKCE(Proof Key for Code Exchange,RFC 7636)扩展。这本质上是客户端与授权服务器之间的一次“挑战-应答”,即使授权码被截获,攻击者也无法换取令牌。
流程:
- 客户端生成一个随机的
code_verifier(高熵字符串)。
- 客户端对
code_verifier进行SHA256哈希并Base64Url编码,得到code_challenge。
- 授权请求中携带
code_challenge。
- 用授权码换取令牌时,必须携带原始的
code_verifier。
- 授权服务器验证
code_verifier产生的挑战值是否与最初收到的code_challenge匹配。
代码示例:
// 生成PKCE参数
String codeVerifier = generateRandomString(128);
String codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(
MessageDigest.getInstance("SHA-256").digest(codeVerifier.getBytes()));
// 存入Session,换取token时使用
request.getSession().setAttribute("pkce_code_verifier", codeVerifier);
// 构建授权请求URL
String authorizationUrl = "http://sso.com/oauth/authorize" +
"?client_id=system-a" +
"&redirect_uri=http://system-a.com/callback" +
"&response_type=code" +
"&state=" + state +
"&code_challenge=" + codeChallenge +
"&code_challenge_method=S256";
// 回调后,用code和codeVerifier换取token
MultiValueMap<String, String> tokenParams = new LinkedMultiValueMap<>();
tokenParams.add("grant_type", "authorization_code");
tokenParams.add("code", code);
tokenParams.add("redirect_uri", redirectUri);
tokenParams.add("code_verifier", codeVerifier); // 关键:必须携带code_verifier
坑3:Refresh Token过期时间配置不当
现象:将refresh_token的有效期设置得过短(如1小时),导致用户需要频繁重新登录,体验糟糕。
正确配置:access_token设置较短有效期(如1小时),refresh_token设置较长有效期(如7天或30天),通过定期刷新access_token来维持会话。
.accessTokenValiditySeconds(3600) // access_token 1小时
.refreshTokenValiditySeconds(86400 * 7) // refresh_token 7天
自动刷新逻辑示例:
@Scheduled(fixedDelay = 300000) // 每5分钟检查一次
public void refreshAccessToken() {
// 当token即将过期时(例如剩余10分钟),使用refresh_token换取新的
if (tokenWillExpireIn(10 * 60)) {
String refreshToken = getRefreshToken();
OAuth2AccessToken newToken = restTemplate.postForObject(
"http://sso.com/oauth/token",
new HttpEntity<>(Map.of(
"grant_type", "refresh_token",
"refresh_token", refreshToken,
"client_id", "system-a",
"client_secret", "system-a-secret"
)),
OAuth2AccessToken.class
);
storeAccessToken(newToken); // 安全地存储新token
}
}
坑4:注销流程不完整,未调用令牌撤销接口
现象:用户注销时仅清除了客户端本地Session,但原有的access_token在有效期内仍然可用,存在安全风险。
正确的注销流程:
- 客户端调用授权服务器的令牌撤销端点(
/oauth/revoke),使当前access_token立即失效。
- 清除客户端本地所有会话信息。
- 可选:重定向至授权服务器的全局注销页,以清除SSO会话。
@GetMapping("/logout")
public String logout(HttpServletRequest request) {
// 1. 从安全上下文中获取access_token
String accessToken = (String) request.getSession().getAttribute("access_token");
// 2. 调用授权服务器的revoke接口
if (accessToken != null) {
restTemplate.postForObject(
"http://sso.com/oauth/revoke",
new HttpEntity<>(Map.of("token", accessToken), createAuthHeader()),
String.class
);
}
// 3. 清除本地Session
request.getSession().invalidate();
// 4. 重定向到SSO全局注销页
return "redirect:http://sso.com/logout";
}
坑5:Redirect URI不匹配导致授权失败
现象:在移动端或特殊场景下授权失败,提示“redirect_uri不匹配”。
原因:授权服务器配置的redirect_uri是精确匹配的。移动端应用可能使用自定义Scheme(如myapp://callback)或Universal Links,如果未在客户端注册信息中预先配置,则会导致匹配失败。
解决方案:
在授权服务器注册客户端时,配置所有可能的回调地址。
// 客户端配置示例,支持Web和移动端
.withClient("mobile-app")
.secret(passwordEncoder().encode("mobile-secret"))
.redirectUris(
"http://mobile.com/callback", // Web回调
"myapp://callback", // Android自定义Scheme
"com.mobile.app://callback" // iOS Universal Links
)
总结与最佳实践
一个安全的OAuth 2.0授权码流程核心步骤总结如下:
- 生成State:客户端生成随机
state并绑定会话。
- 发起授权:携带
state、code_challenge(若用PKCE)重定向至授权服务器。
- 验证State:在回调端点严格验证
state参数,防御CSRF。
- 换取令牌:使用授权码和
code_verifier(若用PKCE)向令牌端点请求令牌。
- 访问资源:使用
access_token访问受保护的资源。
- 刷新令牌:在
access_token过期前,使用refresh_token获取新的令牌对。
- 全局注销:注销时调用撤销接口使令牌失效,并清理本地和全局会话。
安全实施清单:
- [ ] 必须验证State参数,防止CSRF攻击。
- [ ] 推荐使用PKCE,特别是公共客户端(如SPA、移动应用),以保护授权码。
- [ ] 合理配置令牌有效期:
refresh_token有效期应显著长于access_token。
- [ ] 注销时必须调用令牌撤销接口,确保令牌立即失效。
- [ ]
redirect_uri必须精确匹配且在服务端注册,避免开放重定向漏洞。
- [ ] 令牌必须安全存储(如前端使用
sessionStorage或由后端管理;移动端使用安全存储区)。
- [ ] 实现完备的令牌刷新与失败处理机制(如刷新令牌过期时引导重新登录)。
- [ ] 全程使用HTTPS加密通信。
- [ ] 考虑设置Token黑名单,用于即时吊销已注销用户的令牌。
- [ ] 监控异常登录行为(如IP、设备突变),增强风控。
遵循以上实践,方能构建一个既便捷又安全的OAuth 2.0单点登录系统,在用户体验与 网络安全 之间取得良好平衡。