How-to: Authenticate using a Single Page Application with PKCE

本指南展示了如何配置Spring Authorization Server,以通过证明密钥代码交换(PKCE)支持单页应用程序(SPA)。本指南的目的是演示如何支持公有客户端并要求PKCE进行客户端认证。

This guide shows how to configure Spring Authorization Server to support a Single Page Application (SPA) with Proof Key for Code Exchange (PKCE). The purpose of this guide is to demonstrate how to support a public client and require PKCE for client authentication.

Spring Authorization Server 不会为公用客户端颁发刷新令牌。我们建议将后端用作前端 (BFF) 模式,作为公开公用客户端的替代方案。请参阅 gh-297以了解更多信息。

Spring Authorization Server will not issue refresh tokens for a public client. We recommend the backend for frontend (BFF) pattern as an alternative to exposing a public client. See gh-297 for more information.

Enable CORS

SPA 由静态资源组成,可以通过多种方式部署。它可以与后端分开部署,例如与 CDN 或单独的 Web 服务器一起部署,或者也可以使用 Spring Boot 与后端一起部署。

A SPA consists of static resources that can be deployed in a variety of ways. It can be deployed separately from the backend such as with a CDN or separate web server, or it can be deployed along side the backend using Spring Boot.

当 SPA 托管在不同的域下时,可以使用跨源资源共享 (CORS) 来让应用程序与后端通信。

When a SPA is hosted under a different domain, Cross Origin Resource Sharing (CORS) can be used to allow the application to communicate with the backend.

例如,如果您在端口 4200 上本地运行 Angular 开发服务器,可以定义一个 CorsConfigurationSource @Bean 并使用 cors() DSL 配置 Spring Security 以允许预检请求,如下面的示例所示:

For example, if you have an Angular dev server running locally on port 4200, you can define a CorsConfigurationSource @Bean and configure Spring Security to allow pre-flight requests using the cors() DSL as in the following example:

Enable CORS
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	@Bean
	@Order(1)
	public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
			throws Exception {
		OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
		http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
			.oidc(Customizer.withDefaults());	// Enable OpenID Connect 1.0
		http
			// Redirect to the login page when not authenticated from the
			// authorization endpoint
			.exceptionHandling((exceptions) -> exceptions
				.defaultAuthenticationEntryPointFor(
					new LoginUrlAuthenticationEntryPoint("/login"),
					new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
				)
			)
			// Accept access tokens for User Info and/or Client Registration
			.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));

		return http.cors(Customizer.withDefaults()).build();
	}

	@Bean
	@Order(2)
	public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
			throws Exception {
		http
			.authorizeHttpRequests((authorize) -> authorize
				.anyRequest().authenticated()
			)
			// Form login handles the redirect to the login page from the
			// authorization server filter chain
			.formLogin(Customizer.withDefaults());

		return http.cors(Customizer.withDefaults()).build();
	}

	@Bean
	public CorsConfigurationSource corsConfigurationSource() {
		UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
		CorsConfiguration config = new CorsConfiguration();
		config.addAllowedHeader("*");
		config.addAllowedMethod("*");
		config.addAllowedOrigin("http://127.0.0.1:4200");
		config.setAllowCredentials(true);
		source.registerCorsConfiguration("/**", config);
		return source;
	}

}

单击以上代码示例中“展开折叠文本”图标以显示完整示例。

Click on the "Expand folded text" icon in the code sample above to display the full example.

Configure a Public Client

SPA 无法安全地存储凭证,因此必须将其视为 public client。应要求公有客户端使用 Proof Key for Code Exchange (PKCE)。

A SPA cannot securely store credentials and therefore must be treated as a public client. Public clients should be required to use Proof Key for Code Exchange (PKCE).

继续 earlier 示例,您可以按照以下示例配置 Spring 授权服务器以使用客户端身份验证方法 none 支持公有客户端并要求 PKCE:

Continuing the earlier example, you can configure Spring Authorization Server to support a public client using the Client Authentication Method none and require PKCE as in the following example:

  • Yaml

  • Java

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:4200"
              scopes:
                - "openid"
                - "profile"
            require-authorization-consent: true
            require-proof-key: true
import java.util.UUID;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;

@Configuration
public class ClientConfig {

	// tag::client[]
	@Bean
	public RegisteredClientRepository registeredClientRepository() {
		RegisteredClient publicClient = RegisteredClient.withId(UUID.randomUUID().toString())
			.clientId("public-client")
			.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri("http://127.0.0.1:4200")
			.scope(OidcScopes.OPENID)
			.scope(OidcScopes.PROFILE)
			.clientSettings(ClientSettings.builder()
				.requireAuthorizationConsent(true)
				.requireProofKey(true)
				.build()
			)
			.build();

		return new InMemoryRegisteredClientRepository(publicClient);
	}
	// end::client[]

}

`requireProofKey`设置非常重要,可以防止 PKCE Downgrade Attack

The requireProofKey setting is important to prevent the PKCE Downgrade Attack.

Authenticate with the Client

服务器配置为支持公用客户端后,一个常见问题是:我如何验证客户端并获取访问令牌? 简单的答案是:与处理任何其他客户端时相同的方法。

Once the server is configured to support a public client, a common question is: How do I authenticate the client and get an access token? The short answer is: The same way you would with any other client.

SPA 是一个基于浏览器的应用程序,因此使用与任何其他客户端相同的基于重定向的流程。此问题通常与一种期望相关,即可以通过 REST API 执行身份验证,而 OAuth2 中不存在这种情况。

A SPA is a browser-based application and therefore uses the same redirection-based flow as any other client. This question is usually related to an expectation that authentication can be performed via a REST API, which is not the case with OAuth2.

更详细的答案需要了解 OAuth2 和 OpenID Connect 中涉及的流程,在本例中为授权代码流程。授权代码流程的步骤如下:

A more detailed answer requires an understanding of the flow(s) involved in OAuth2 and OpenID Connect, in this case the Authorization Code flow. The steps of the Authorization Code flow are as follows:

  1. The client initiates an OAuth2 request via a redirect to the Authorization Endpoint. For a public client, this step includes generating the code_verifier and calculating the code_challenge, which is then sent as a query parameter.

  2. If the user is not authenticated, the authorization server will redirect to the login page. After authentication, the user is redirected back to the Authorization Endpoint again.

  3. If the user has not consented to the requested scope(s) and consent is required, the consent page is displayed.

  4. Once the user has consented, the authorization server generates an authorization_code and redirects back to the client via the redirect_uri.

  5. The client obtains the authorization_code via a query parameter and performs a request to the Token Endpoint. For a public client, this step includes sending the code_verifier parameter instead of credentials for authentication.

正如您所看到的,此流程相当复杂,本概述只是浅尝辄止。

As you can see, the flow is fairly involved and this overview only scratches the surface.

建议您使用由单页应用程序框架支持的健壮客户端库来处理授权码流程。

It is recommended that you use a robust client-side library supported by your single-page app framework to handle the Authorization Code flow.