使用 Spring Authorization Server 和 PKCE 对 SPA 应用进行身份认证
1、简介
本文将带你了解如何在 OAuth 2.0 公开客户端(Public Client)中使用 Proof Key for Code Exchange (代码交换证明密钥,PKCE)。
2、背景
OAuth 2.0 公开客户端(如 SPA 单页应用,或使用授权码授权的移动应用)很容易受到授权码拦截攻击。如果客户端与服务器之间的通信是通过不安全的网络进行的,恶意攻击者就可能从授权端点截获授权代码。
如果攻击者可以访问授权码,就可以利用它获 Access Token。一旦攻击者拥有了 Access Token,就可以像合法应用用户一样访问受保护的应用资源,从而严重损害应用。例如,如果 Access Token 与金融应用相关联,攻击者就可能获取敏感的应用信息。
2.1、OAuth 授权码拦截攻击
来看看 Oauth 授权码拦截攻击是如何发生的:
上图展示了恶意攻击者如何滥用授权码获取 Access Token 的流程:
- 合法的 OAuth 应用使用其 Web 浏览器启动 OAuth 授权请求流程,并提供所有必要的详细信息。
- Web 浏览器向授权服务器发送请求。
- 授权服务器向 Web 浏览器返回授权码。
- 在此阶段,如果通信是通过不安全的通道进行的,恶意用户可能会获取授权码。
- 恶意用户使用授权码从授权服务器获取 Access Token。
- 由于授权许可有效,授权服务器会向恶意应用签发 Access Token。恶意应用可以滥用 Access Token,代表合法应用访问受保护的资源。
代码交换证明密钥(Proof Key for Code Exchange,PKCE)是 OAuth 框架的一个扩展,旨在减轻这种攻击。
3、PKCE 和 OAuth
PKCE(Proof Key for Code Exchange)扩展在 OAuth 授权码授权流程中包括以下额外步骤:
- 客户端应用在发送初始授权请求时,会同时发送两个附加参数:
code_challenge
和code_challenge_method
。 - 下一步,客户端在交换授权码以获取 Access Token 的同时,还会发送
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_challange
与 code_verifier
相同。S256
方法首先生成 code_verifier
的 SHA-256 哈希值,然后对哈希值进行 Base64 编码。
3.1、防止 OAuth 授权码拦截攻击
下图展示了 PKCE 扩展如何防止 Access Token 被盗:
- 合法的 OAuth 应用会使用其 Web 浏览器启动 OAuth 授权请求流程,并提供所有必要的详细信息以及
code_challenge
和code_challenge_method
参数。 - Web 浏览器向授权服务器发送请求,并为客户端应用存储
code_challenge
和code_challenge_method
。 - 授权服务器向 Web 浏览器返回授权码。
- 在此阶段,如果通信是通过不安全的通道进行的,恶意用户可能会获取授权码。
- 恶意用户试图交换授权码,从授权服务器获取 Access Token。然而,恶意用户并不知道需要与请求一起发送
code_verifier
。授权服务器拒绝了恶意应用的 Access Token 请求。 - 合法应用在提交授权许可的同时提供
code_verifier
,以获取 Access Token。授权服务器根据提供的code_verifier
和先前从授权码授予请求中存储的code_challenge_method
计算code_challenge
。它将计算出的code_challange
值与先前存储的code_challenge
相匹配。这些值总是匹配的,客户端将获得 Access Token。 - 客户端可使用该 Access Token 访问应用资源。
4、PKCE 和 Spring Security
从 6.3 版开始,Spring Security 支持 Servlet 和响应式 Web 应用的 PKCE。不过,由于并非所有身份提供商(Identity Provider)都支持 PKCE 扩展,因此默认情况下并未启用。当客户端运行在不受信任的环境(如原生应用或基于 Web 浏览器的应用)中,且 client_secret
为空或未提供,以及 client-authentication-method
被设置为 none
时,PKCE 会自动用于公开客户端。
4.1、Maven 配置
Spring 授权服务器支持 PKCE 扩展。因此,为 Spring Authorization Server 应用加入 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>
4.2、注册公开客户端
接下来,在 application.yml
文件中配置以下属性,注册一个公开的 SPA 应用客户端:
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
上述配置注册了一个客户端,client_id
为 public-client
,client-authentication-methods
为 none
。require-authorization-consent
要求最终用户在认证成功后提供访问配置(profile
)和电子邮件(email
)Scope 的额外许可。require-proof-key
配置可防止 PKCE 降级攻击。
启用 require-proof-key
配置后,授权服务器不会允许任何恶意尝试绕过没有 code_challenge
的 PKCE 流程。其余配置是授权服务器注册客户端的标准配置。
4.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();
}
在上述配置中,首先应用授权服务器的默认安全设置。然后,再为 OIDC、CORS 和 Oauth2 资源服务器应用 Spring Security 默认设置。
现在定义另一个 SecurityFilterChain
配置,它将应用于其他 HTTP 请求,如登录页面:
@Bean
@Order(2)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.anyRequest()
.authenticated())
.formLogin(Customizer.withDefaults());
return http.cors(Customizer.withDefaults())
.build();
}
在本例中,我们使用一个非常简单的 React 应用作为公开的客户端。该应用在 http://127.0.0.1:3000
上运行。授权服务器运行在不同的端口(9000
)上。由于这两个应用程序运行在不同的域名上,因此需要提供额外的 CORS 设置,以便授权服务器允许 React 应用访问它:
@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;
}
如上,定义了一个 CorsConfigurationSource
实例,其中包含允许的 origin、Header、方法和其他配置。注意,在上述配置中,我们使用的 IP 地址是 127.0.0.1
,而不是 localhost
,因为后者是不允许的。
最后,定义一个 UserDetailsService
实例,以便在授权服务器中定义用户。
@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);
}
有了上述配置,就可以使用用户名 john
和 password
作为口令,对授权服务器进行身份认证。
4.4、公开的客户端应用
现在来看看客户端。为便于演示,使用一个简单的 React 应用作为 SPA 应用。该应用使用 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;
authority
配置为 Spring 授权服务器的地址,即 http://127.0.0.1:9000
。code_challenge_method
参数配置为 S256
。这些配置用于准备 UserManager
实例,稍后将用它来调用授权服务器。此应用有两个端点 - “/” 端点用于访问应用的登录页面,“callback” 端点用于处理来自授权服务器的回调请求:
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;
5、测试
使用一个已启用 OIDC 客户端支持的 React 应用来测试流程。要安装所需的依赖项,需要在应用的根目录下运行 npm install
命令。然后,使用 npm start
命令启动应用。
5.1、访问授权码授权的应用
该客户端执行以下两项活动: 首先,访问 http://127.0.0.1:3000
上的主页会显示一个登录页面。这是 SPA 应用的登录页面。接下来,一旦开始登录,SPA 应用就会调用 Spring 授权服务器,并提供 code_challenge
和 code_challenge_method
参数:
向 http://127.0.0.1:9000
的 Spring Authorization Server 发出的请求如下:
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
授权服务器会将请求重定向到 Spring Security 登录页面:
一旦提供了登录凭证,授权请求将会征求对额外的 OAuth Scope profile
和 email
的同意。这是由于在授权服务器中将 require-authorization-consent
配置为 true 所导致的。
5.2、使用授权码获取 Access Token
登录完成后,授权服务器就会返回授权码。随后,SPA 向授权服务器发出另一个 HTTP 请求,以获取 Access Token。SPA 会提供在前一个请求中获得的授权码和 code_challenge
,以获取 access_token
:
对于上述请求,Spring Authorization Server 会响应 Access Token:
接下来,访问授权服务器中的 userinfo
端点以获取用户详细信息。
在 HTTP Authorization
请求头中将 access_token
作为 Bearer 令牌提供,以访问该端点。用户信息从 userinfo
详细信息中打印出来。
6、总结
本文介绍了如何在使用 Spring Authorization Server 的 SPA 应用中使用 OAuth 2.0 PKCE 扩展,首先介绍了公共客户端对 PKCE 的需求,然后介绍了 Spring Authorization Server 中使用 PKCE 流程的配置。最后,通过一个 React 应用演示了该流程。
Ref:https://www.baeldung.com/spring-authentication-single-page-application-pkce