找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1531

积分

0

好友

225

主题
发表于 11 小时前 | 查看: 2| 回复: 0

在构建单点登录系统时,OAuth 2.0的授权码模式因其较高的安全性而被广泛采用。许多开发者初识该流程时,可能将其简化为“获取code -> 换取token”等寥寥数步。然而,在实际的工程实现中,一个健壮的OAuth 2.0授权码流程远不止于此,涉及大量细节处理与安全考量,其复杂程度远超想象。

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并附上授权码,从而造成代码泄露。
这与常见的 网络安全 防护思想一致,即任何外部传入的参数都不可信。

解决方案

  1. 客户端发起授权请求时,生成一个高强度的随机字符串作为state参数,并将其与当前会话绑定(如存入Session)。
  2. 授权服务器在回调时,严格比对客户端传来的state参数与会话中存储的是否一致。
  3. 验证完毕后,立即清除会话中的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)扩展。这本质上是客户端与授权服务器之间的一次“挑战-应答”,即使授权码被截获,攻击者也无法换取令牌。

流程

  1. 客户端生成一个随机的code_verifier(高熵字符串)。
  2. 客户端对code_verifier进行SHA256哈希并Base64Url编码,得到code_challenge
  3. 授权请求中携带code_challenge
  4. 用授权码换取令牌时,必须携带原始的code_verifier
  5. 授权服务器验证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在有效期内仍然可用,存在安全风险。

正确的注销流程

  1. 客户端调用授权服务器的令牌撤销端点(/oauth/revoke),使当前access_token立即失效。
  2. 清除客户端本地所有会话信息。
  3. 可选:重定向至授权服务器的全局注销页,以清除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授权码流程核心步骤总结如下:

  1. 生成State:客户端生成随机state并绑定会话。
  2. 发起授权:携带statecode_challenge(若用PKCE)重定向至授权服务器。
  3. 验证State:在回调端点严格验证state参数,防御CSRF。
  4. 换取令牌:使用授权码和code_verifier(若用PKCE)向令牌端点请求令牌。
  5. 访问资源:使用access_token访问受保护的资源。
  6. 刷新令牌:在access_token过期前,使用refresh_token获取新的令牌对。
  7. 全局注销:注销时调用撤销接口使令牌失效,并清理本地和全局会话。

安全实施清单

  • [ ] 必须验证State参数,防止CSRF攻击。
  • [ ] 推荐使用PKCE,特别是公共客户端(如SPA、移动应用),以保护授权码。
  • [ ] 合理配置令牌有效期:refresh_token有效期应显著长于access_token
  • [ ] 注销时必须调用令牌撤销接口,确保令牌立即失效。
  • [ ] redirect_uri必须精确匹配且在服务端注册,避免开放重定向漏洞。
  • [ ] 令牌必须安全存储(如前端使用sessionStorage或由后端管理;移动端使用安全存储区)。
  • [ ] 实现完备的令牌刷新与失败处理机制(如刷新令牌过期时引导重新登录)。
  • [ ] 全程使用HTTPS加密通信。
  • [ ] 考虑设置Token黑名单,用于即时吊销已注销用户的令牌。
  • [ ] 监控异常登录行为(如IP、设备突变),增强风控。

遵循以上实践,方能构建一个既便捷又安全的OAuth 2.0单点登录系统,在用户体验与 网络安全 之间取得良好平衡。




上一篇:Oracle SQL注入进阶:时间盲注、DNS外带与Java提权技术实战解析
下一篇:Three.js实战:构建3D交互式机器学习数学公式可视化教学工具
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-24 19:00 , Processed in 0.190337 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表