在本教程中,我们将讨论OAuth 2.0公共客户端的代码交换证明密钥 (PKCE) 的使用。
背景 OAuth 2.0 公共客户端(例如单页应用程序 (SPA) 或使用授权码授予的移动应用程序)容易受到授权码拦截攻击。如果客户端-服务器通信发生在不安全的网络上,恶意攻击者可能会从授权端点拦截授权码。
如果攻击者可以访问授权代码,则可以使用它来获取访问令牌。一旦攻击者拥有访问令牌,它就可以像合法应用程序用户一样访问受保护的应用程序资源,从而严重危害应用程序。例如,如果访问令牌与金融应用程序相关联,攻击者可能会访问敏感的应用程序信息。
OAuth 代码拦截攻击 在本节中,让我们讨论一下 Oauth 授权代码拦截攻击是如何发生的:
恶意攻击者如何滥用授权授予代码来获取访问令牌的流程:
- 合法的 OAuth 应用程序使用其 Web 浏览器启动 OAuth 授权请求流程,并提供所有必需的详细信息
- Web 浏览器向授权服务器发送请求
- 授权服务器将授权码返回给网页浏览器
- 在此阶段,如果通信通过不安全的渠道进行,恶意用户可能会访问授权码
- 恶意用户交换授权码授权以从授权服务器获取访问令牌
- 由于授权许可有效,授权服务器向恶意应用程序发出访问令牌。恶意应用程序可以滥用访问令牌,以合法应用程序的名义访问受保护的资源
- 代码交换的证明密钥是 OAuth 框架的扩展,旨在减轻这种攻击。
使用 OAuth 的 PKCE PKCE 扩展包括 OAuth 授权码授予流程的以下附加步骤:
- 客户端应用程序在初始授权请求中发送两个附加参数code_challenge和code_challenge_method
- 客户端在下一步交换授权码以获取访问令牌时,还会发送code_verifier
- 首先,支持 PKCE 的客户端应用程序会选择一个动态创建的加密随机密钥,称为code_verifier。此code_verifier对于每个授权请求都是唯一的。根据PKCE 规范, code_verifier值的长度必须介于 43 到 128 个八位字节之间。
此外,code_verifier只能包含字母数字ASCII 字符和一些允许的符号。其次,使用支持的code_challenge_method将code_verifier转换为code_challenge。目前,支持的转换方法是plain和S256。plain是一种无操作转换,使code_challenge值与code_verifier保持一致。S256 方法首先生成 code_verifier 的 SHA-256 哈希,然后对哈希值执行 Base64 编码。
防止OAuth代码拦截攻击 下面演示了 PKCE 扩展如何防止访问令牌被盗:
- 合法的 OAuth 应用程序使用其 Web 浏览器启动 OAuth 授权请求流程,提供所有必需的详细信息以及code_challenge和code_challenge_method参数。
- Web 浏览器将请求发送到授权服务器,并为客户端应用程序存储code_challenge和code_challenge_method
- 授权服务器将授权码返回给网页浏览器
- 在此阶段,如果通信通过不安全的渠道进行,恶意用户可能会访问授权码
- 恶意用户尝试交换授权代码授权,以从授权服务器获取访问令牌。但是,恶意用户不知道需要随请求一起发送的code_verifier 。授权服务器拒绝向恶意应用程序发送访问令牌请求
- 合法应用程序提供code_verifier和授权许可以获得访问令牌。授权服务器根据提供的code_verifier和授权代码授予请求中先前存储的code_challenge_method计算code_challenge 。它将计算出的code_challenge与先前存储的code_challenge进行匹配。这些值始终匹配,并且客户端会获得访问令牌
- 客户端可以使用此访问令牌访问应用程序资源
使用 Spring Security 的 PKCE 从 6.3 版开始,Spring Security 支持 servlet 和响应式 Web 应用程序的 PKCE。但是,默认情况下不启用它,因为并非所有身份提供者都支持 PKCE 扩展。当客户端在不受信任的环境(例如本机应用程序或基于 Web 浏览器的应用程序)中运行,并且client_secret为空或未提供,并且客户端身份验证方法设置为none 时,会自动为公共客户端使用 PKCE 。
1. Maven 配置 Spring 授权服务器支持 PKCE 扩展。因此,在 Spring 授权服务器应用程序中包含 PKCE 支持的简单方法是包含spring-boot-starter-oauth2-authorization-server依赖项:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> <version>3.3.0</version> </dependency> |
2. 注册公共客户端 接下来,让我们通过在application.yml文件中配置以下属性来注册一个公共的单页应用程序客户端:
spring: security: oauth2: authorizationserver: client: public-client: registration: client-id: "public-client" client-authentication-methods: - "none" authorization-grant-types: - "authorization_code" redirect-uris: - "http://127.0.0.1:3000/callback" scopes: - "openid" - "profile" - "email" require-authorization-consent: true require-proof-key: true |
启用require-proof-key配置后,授权服务器将不允许任何恶意尝试绕过没有 code_challenge 的 PKCE 流程。其余配置是向授权服务器注册客户端的标准配置。
3. Spring Security 配置 接下来,让我们为授权服务器定义SecurityFileChain配置:
@Bean @Order(1) SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http); http.getConfigurer(OAuth2AuthorizationServerConfigurer.class) .oidc(Customizer.withDefaults()); http.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML))) .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults())); return http.cors(Customizer.withDefaults()) .build(); } |
@Bean @Order(2) SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests((authorize) -> authorize.anyRequest() .authenticated()) .formLogin(Customizer.withDefaults()); return http.cors(Customizer.withDefaults()) .build(); } |
@Bean CorsConfigurationSource corsConfigurationSource() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); config.addAllowedHeader("*"); config.addAllowedMethod("*"); config.addAllowedOrigin("http://127.0.0.1:3000"); config.setAllowCredentials(true); source.registerCorsConfiguration("/**", config); return source; } |
@Bean UserDetailsService userDetailsService() { PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder(); UserDetails userDetails = User.builder() .username("john") .password("password") .passwordEncoder(passwordEncoder::encode) .roles("USER") .build(); return new InMemoryUserDetailsManager(userDetails); } |
4. 公共客户端应用程序 现在让我们讨论一下公共客户端。为了演示目的,我们使用一个简单的 React 应用程序作为单页应用程序。此应用程序使用oidc-client-ts库来提供客户端 OIDC 和 OAuth2 支持。SPA 应用程序配置了以下配置:
const pkceAuthConfig = { authority: 'http://127.0.0.1:9000/', client_id: 'public-client', redirect_uri: 'http://127.0.0.1:3000/callback', response_type: 'code', scope: 'openid profile email', post_logout_redirect_uri: 'http://127.0.0.1:3000/', userinfo_endpoint: 'http://127.0.0.1:9000/userinfo', response_mode: 'query', code_challenge_method: 'S256', }; export default pkceAuthConfig; |
import React, { useState, useEffect } from 'react'; import { BrowserRouter, Routes, Route } from 'react-router-dom'; import Login from './components/LoginHandler'; import CallbackHandler from './components/CallbackHandler'; import pkceAuthConfig from './pkceAuthConfig'; import { UserManager, WebStorageStateStore } from 'oidc-client-ts'; function App() { const [authenticated, setAuthenticated] = useState(null); const [userInfo, setUserInfo] = useState(null); const userManager = new UserManager({ userStore: new WebStorageStateStore({ store: window.localStorage }), ...pkceAuthConfig, }); function doAuthorize() { userManager.signinRedirect({state: '6c2a55953db34a86b876e9e40ac2a202',}); } useEffect(() => { userManager.getUser().then((user) => { if (user) { setAuthenticated(true); } else { setAuthenticated(false); } }); }, [userManager]); return ( <BrowserRouter> <Routes> <Route path="/" element={<Login authentication={authenticated} handleLoginRequest={doAuthorize}/>}/> <Route path="/callback" element={<CallbackHandler authenticated={authenticated} setAuth={setAuthenticated} userManager={userManager} userInfo={userInfo} setUserInfo={setUserInfo}/>}/> </Routes> </BrowserRouter> ); } export default App; |
测试 我们将使用启用了 OIDC 客户端支持的 React 应用程序来测试流程。要安装所需的依赖项,我们需要从应用程序的根目录运行npm install命令。然后,我们将使用npm start命令启动该应用程序。
1. 访问授权码授予申请 此客户端应用程序执行以下两个活动:首先,访问http://127.0.0.1:3000上的主页会呈现登录页面。这是我们的 SPA 应用程序的登录页面:接下来,一旦我们继续登录,SPA 应用程序就会使用 code_challenge和code_challenge_method调用Spring 授权服务器:我们可以注意到对http://127.0.0.1:9000上的 Spring 授权服务器发出的请求具有以下参数:
http://127.0.0.1:9000/oauth2/authorize? client_id=public-client& redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback& response_type=code& scope=openid+profile+email& state=301b4ce8bdaf439990efd840bce1449b& code_challenge=kjOAp0NLycB6pMChdB7nbL0oGG0IQ4664OwQYUegzF0& code_challenge_method=S256& response_mode=query |
2. 使用授权码兑换访问令牌
- 如果我们完成登录,授权服务器将返回授权码。 随后,SPA 向授权服务器请求另一个 HTTP 以获取访问令牌。 SPA提供上一个请求中获得的授权码以及code_challenge以获取access_token
- 对于上述请求,Spring 授权服务器使用访问令牌进行响应
- 接下来,我们访问授权服务器中的userinfo端点以访问用户详细信息。 我们提供带有 Authorization HTTP 标头的access_token作为 Bearer 令牌来访问此端点。 此用户信息从userinfo详细信息中打印出来: