Spring Security 6中使用PKCE实现身份验证

在本教程中,我们将讨论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
在上面的代码片段中,我们注册了一个客户端,client_id为public-client,client-authentication-methods为none。require -authorization-consent要求最终用户在成功认证后提供额外的同意才能访问个人资料和电子邮件范围。re ​​quire-proof-key配置可防止 PKCE 降级攻击。

启用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();
}
在上面的配置中,我们首先应用授权服​​务器的默认安全设置。然后,我们应用 OIDC、CORS 和 Oauth2 资源服务器的 Spring 安全默认设置。现在让我们定义另一个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实例,其中包含允许的来源、标头、方法和其他配置。请注意,在上面的配置中,我们使用 IP 地址 127.0.0.1 而不是localhost,因为后者是不允许的。最后,让我们定义一个UserDetailsS ​​ervice 实例来在授权服务器中定义用户。

@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. 公共客户端应用程序 现在让我们讨论一下公共客户端。为了演示目的,我们使用一个简单的 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;
授权配置了 Spring 授权服务器的地址,即http://127.0.0.1:9000。代码质询方法参数配置为 S256。这些配置用于准备 UserManager实例,稍后我们将使用它来调用授权服务器。此应用程序有两个端点 - “/” 用于访问应用程序的登录页面,以及处理来自授权服务器的回调请求的“回调”端点:

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
授权服务器将请求重定向到 Spring Security 登录页面:一旦我们提供登录凭据,授权就会请求同意附加的 Oauth 范围配置文件和电子邮件。这是由于授权服务器中的配置require-authorization-consent为 true。

2. 使用授权码兑换访问令牌

  • 如果我们完成登录,授权服务器将返回授权码。 随后,SPA 向授权服务器请求另一个 HTTP 以获取访问令牌。 SPA提供上一个请求中获得的授权码以及code_challenge以获取access_token
  • 对于上述请求,Spring 授权服务器使用访问令牌进行响应
  • 接下来,我们访问授权服务器中的userinfo端点以访问用户详细信息。 我们提供带有 Authorization HTTP 标头的access_token作为 Bearer 令牌来访问此端点。 此用户信息从userinfo详细信息中打印出来: