使用 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 授权码拦截攻击是如何发生的:

Oauth 授权码授权流程

上图展示了恶意攻击者如何滥用授权码获取 Access Token 的流程:

  1. 合法的 OAuth 应用使用其 Web 浏览器启动 OAuth 授权请求流程,并提供所有必要的详细信息。
  2. Web 浏览器向授权服务器发送请求。
  3. 授权服务器向 Web 浏览器返回授权码。
  4. 在此阶段,如果通信是通过不安全的通道进行的,恶意用户可能会获取授权码。
  5. 恶意用户使用授权码从授权服务器获取 Access Token。
  6. 由于授权许可有效,授权服务器会向恶意应用签发 Access Token。恶意应用可以滥用 Access Token,代表合法应用访问受保护的资源。

代码交换证明密钥(Proof Key for Code Exchange,PKCE)是 OAuth 框架的一个扩展,旨在减轻这种攻击。

3、PKCE 和 OAuth

PKCE(Proof Key for Code Exchange)扩展在 OAuth 授权码授权流程中包括以下额外步骤:

  • 客户端应用在发送初始授权请求时,会同时发送两个附加参数:code_challengecode_challenge_method
  • 下一步,客户端在交换授权码以获取 Access Token 的同时,还会发送 code_verifier

首先,启用 PKCE 的客户端应用会选择一个动态创建的加密随机密钥,称为 code_verifier(代码校验码)。每个授权请求的 code_verifier 都是唯一的。根据 PKCE 规范code_verifier 值的长度必须在 43128 个八位字节之间。

此外,code_verifier 只能包含字母数字 ASCII 字符和少数允许使用的符号。其次,使用支持的 code_challenge_methodcode_verifier 转换为 code_challenge。目前,支持的转换方法有 plainS256plain 转换是一种无操作的转换,code_challangecode_verifier 相同。S256 方法首先生成 code_verifier 的 SHA-256 哈希值,然后对哈希值进行 Base64 编码。

3.1、防止 OAuth 授权码拦截攻击

下图展示了 PKCE 扩展如何防止 Access Token 被盗:

使用了 PKCE 扩展的 OAuth 授权码授权流程

  1. 合法的 OAuth 应用会使用其 Web 浏览器启动 OAuth 授权请求流程,并提供所有必要的详细信息以及 code_challengecode_challenge_method 参数。
  2. Web 浏览器向授权服务器发送请求,并为客户端应用存储 code_challengecode_challenge_method
  3. 授权服务器向 Web 浏览器返回授权码。
  4. 在此阶段,如果通信是通过不安全的通道进行的,恶意用户可能会获取授权码。
  5. 恶意用户试图交换授权码,从授权服务器获取 Access Token。然而,恶意用户并不知道需要与请求一起发送 code_verifier。授权服务器拒绝了恶意应用的 Access Token 请求。
  6. 合法应用在提交授权许可的同时提供 code_verifier,以获取 Access Token。授权服务器根据提供的 code_verifier 和先前从授权码授予请求中存储的 code_challenge_method 计算 code_challenge。它将计算出的 code_challange 值与先前存储的 code_challenge 相匹配。这些值总是匹配的,客户端将获得 Access Token。
  7. 客户端可使用该 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_idpublic-clientclient-authentication-methodsnonerequire-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();
}

在上述配置中,首先应用授权服务器的默认安全设置。然后,再为 OIDCCORS 和 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);
}

有了上述配置,就可以使用用户名 johnpassword 作为口令,对授权服务器进行身份认证。

4.4、公开的客户端应用

现在来看看客户端。为便于演示,使用一个简单的 React 应用作为 SPA 应用。该应用使用 oidc-client-ts 库提供客户端 OIDCOAuth2 支持。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:9000code_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_challengecode_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 登录页面:

Spring Security 登录页面

一旦提供了登录凭证,授权请求将会征求对额外的 OAuth Scope profileemail 的同意。这是由于在授权服务器中将 require-authorization-consent 配置为 true 所导致的。

OAuth Scope 授权请求

5.2、使用授权码获取 Access Token

登录完成后,授权服务器就会返回授权码。随后,SPA 向授权服务器发出另一个 HTTP 请求,以获取 Access Token。SPA 会提供在前一个请求中获得的授权码和 code_challenge,以获取 access_token

使用授权码获取 Access Token

对于上述请求,Spring Authorization Server 会响应 Access Token:

Spring Authorization Server 应 Access Token

接下来,访问授权服务器中的 userinfo 端点以获取用户详细信息。

在 HTTP Authorization 请求头中将 access_token 作为 Bearer 令牌提供,以访问该端点。用户信息从 userinfo 详细信息中打印出来。

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