11.5 将 Spring Security 与 SPA 集成
微服务架构和其他分布式系统的 Web 前端部分通常使用 Angular、React 或 Vue 等框架构建为一个或多个单页应用程序。分析 SPA 的创建方式不在本书的范围内,但了解支持此类前端客户端需要进行哪些更改非常重要。
到目前为止,您已经通过终端窗口与组成 Polar Bookshop 系统的服务进行了交互。在本节中,我们将添加一个 Angular 应用程序作为系统的前端。它将由 NGINX 容器提供服务,并可通过 Edge Service 提供的网关访问。支持 SPA 需要在 Spring Security 中进行一些额外的配置,以解决跨源请求共享(CORS)和跨站请求伪造(CSRF)等问题。本节将介绍如何操作。
11.5.1 运行 Angular 应用程序
Polar Bookshop 系统将有一个 Angular 应用程序作为前端。由于本书不涵盖前端技术和模式,我已经准备了一个。我们只需要决定如何将其包含在 Polar Bookshop 系统中。
一个选项是让 Edge Service 提供 SPA 静态资源。提供前端的 Spring Boot 应用程序通常将源代码托管在 src/main/resources 中。当使用 Thymeleaf 等模板引擎时,这是一种方便的策略,但对于 Angular 等 SPA,我更喜欢将代码保存在单独的模块中。SPA 有自己的开发、构建和发布工具,因此拥有一个专用文件夹更清晰且更易于维护。然后您可以配置 Spring Boot 在构建时处理 SPA 的静态资源,并将它们包含在最终版本中。
另一个选项是让专用服务负责提供 Angular 静态资源。这是我们将用于 Polar Bookshop 的策略。我已经将 Angular 应用程序打包在 NGINX 容器中。NGINX(https://nginx.org)提供 HTTP 服务器功能,它非常方便于提供 HTML、CSS 和 JavaScript 文件等静态资源,这些文件组成了 Angular 应用程序。
让我们继续在 Docker 中运行 Polar Bookshop 前端(polar-ui)。首先,转到您的 polar-deployment 仓库,打开您的 Docker Compose 文件(docker/docker-compose.yml)。然后添加配置以运行 polar-ui 并通过端口 9004 暴露它。
清单 11.12 将 Angular 应用程序作为容器运行
version: "3.8"
services:
...
polar-ui:
image: "ghcr.io/polarbookshop/polar-ui:v1"
# 我构建的用于打包 Angular 应用程序的容器镜像
container_name: "polar-ui"
ports:
- 9004:9004
# NGINX 将在端口 9004 上提供 SPA 服务
environment:
- PORT=9004
# 配置 NGINX 服务器端口
与 Polar Bookshop 系统中的其他应用程序一样,我们不希望 Angular 应用程序可以直接从外部访问。相反,我们希望通过 Edge Service 提供的网关使其可访问。我们可以通过为 Spring Cloud Gateway 添加一个新路由来做到这一点,该路由将任何对静态资源的请求转发到 Polar UI 应用程序。
转到您的 Edge Service 项目(edge-service),打开 application.yml 文件,并按如下所示配置新路由。
清单 11.13 为 SPA 静态资源配置新的网关路由
spring:
gateway:
routes:
- id: spa-route
# 路由 ID
uri: ${SPA_URL:http://localhost:9004}
# URI 值来自环境变量,否则使用指定的默认值
predicates:
- Path=/,/*.css,/*.js,/favicon.ico
# 谓词是匹配根端点和 SPA 静态资源的路径列表
Polar UI 应用程序的 URI 是使用环境变量(SPA_URL)的值计算的。如果未定义,则使用第一个冒号(:)符号后写的默认值。
注意 将 Edge Service 作为容器运行时,记得配置
SPA_URL环境变量。在 Docker 上,您可以使用容器名称和端口作为值,结果为http://polar-ui:9004。
让我们测试一下。首先,与 Redis 和 Keycloak 一起运行 Polar UI 容器。打开终端窗口,导航到保存 Docker Compose 文件的文件夹(polar-deployment/docker/docker-compose.yml),然后运行以下命令:
$ docker-compose up -d polar-ui polar-redis polar-keycloak
然后再次构建 Edge Service 项目并运行应用程序(./gradlew bootRun)。最后,打开一个隐身浏览器窗口并导航到 http://localhost:9000。
Spring Security 配置为保护所有端点和资源,因此您将自动被重定向到 Keycloak 登录页面。以 Isabelle 或 Bjorn 身份进行身份验证后,您将被重定向回提供 Angular 前端的 Edge Service 根端点。
目前,您能做的不多。当 Spring Security 收到未经身份验证的请求时会触发身份验证流程,但如果它是 AJAX 请求,则由于 CORS 问题将无法工作。此外,由于 Spring Security 启用了 CSRF 保护,POST 请求(包括注销操作)将失败。在以下部分中,我将向您展示如何更新 Spring Security 配置以克服这些问题。
在继续之前,使用 Ctrl-C 停止应用程序(但保持容器运行——您将需要它们)。
11.5.2 控制身份验证流程
在上一节中,您尝试访问 Edge Service 主页并体验了被自动重定向到 Keycloak 以提供用户名和密码。当前端由服务器渲染的页面组成时(例如使用 Thymeleaf 时),该行为可以正常工作,并且很方便,因为它不需要任何额外的配置。如果您尚未通过身份验证,或者您的会话已过期,Spring Security 将自动触发身份验证流程并将您的浏览器重定向到 Keycloak。
对于单页应用程序,工作方式略有不同。当浏览器通过标准 HTTP GET 请求访问根端点时,后端会返回 Angular 应用程序。在第一步之后,SPA 通过 AJAX 请求与后端交互。当 SPA 向受保护的端点发送未经身份验证的 AJAX 请求时,您不希望 Spring Security 以 HTTP 302 响应进行回复,重定向到 Keycloak。相反,您希望它返回带有错误状态(如 HTTP 401 Unauthorized)的响应。
不与 SPA 使用重定向的主要原因是您会遇到跨源请求共享(CORS)问题。考虑这样的场景:SPA 从 https://client.polarbookshop.com 提供服务,并通过 AJAX 向 https://server.polarbookshop.com 的后端发出 HTTP 调用。通信被阻止,因为两个 URL 没有相同的源(相同的协议、域和端口)。这是所有 Web 浏览器强制执行的标准同源策略。
CORS 是一种允许服务器接受来自基于浏览器的客户端(如 SPA)的 AJAX HTTP 调用的机制,即使两者具有不同的源。在 Polar Bookshop 中,我们通过 Edge Service 中实现的网关提供 Angular 前端(相同源)。因此,这两个组件之间没有任何 CORS 问题。但是,假设 Spring Security 配置为以重定向到 Keycloak(具有不同的源)来响应未经身份验证的 AJAX 调用。在这种情况下,请求将被阻止,因为在 AJAX 期间不允许重定向到不同的源。
注意 要了解有关 Spring Security 中 CORS 的更多信息,您可以查看 Laurentiu Spilca 的《Spring Security in Action》(Manning,2020)的第 10 章,其中对此主题进行了详细说明。有关 CORS 的全面解释,请参阅 Monsur Hossain 的《CORS in Action》(Manning,2014)。
当更改 Spring Security 配置以响应未经身份验证的请求返回 HTTP 401 响应时,由 SPA 处理错误并调用后端以启动身份验证流程。重定向仅在 AJAX 请求期间存在问题。这里的关键部分是调用后端以启动用户身份验证不是由 Angular 发送的 AJAX 请求。相反,它是从浏览器发送的标准 HTTP 调用,如下所示:
login(): void {
window.open('/oauth2/authorization/keycloak', '_self');
}
我想强调的是,login 调用不是从 Angular HttpClient 发送的 AJAX 请求。相反,它指示浏览器调用登录 URL。Spring Security 暴露了一个 /oauth2/authorization/{registrationId} 端点,您可以使用该端点启动基于 OAuth2/OIDC 的身份验证流程。由于 Edge Service 的客户端注册标识符是 keycloak,登录端点将是 /oauth2/authorization/keycloak。
为了使这成为可能,我们需要定义一个自定义 AuthenticationEntryPoint 来指示 Spring Security 在收到未经身份验证的受保护资源请求时返回 HTTP 401 状态。该框架已经提供了一个 HttpStatusServerEntryPoint 实现,非常适合此场景,因为它允许您指定当需要对用户进行身份验证时返回哪个 HTTP 状态。
清单 11.14 当用户未经身份验证时返回 401
@EnableWebFluxSecurity
public class SecurityConfig {
// ...
@Bean
SecurityWebFilterChain springSecurityFilterChain(
ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrationRepository) {
return http
.authorizeExchange(exchange ->
exchange.anyExchange().authenticated())
.exceptionHandling(exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
// 当因用户未通过身份验证而抛出异常时,它回复 HTTP 401 响应
new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessHandler(
oidcLogoutSuccessHandler(clientRegistrationRepository)))
.build();
}
}
此时,Angular 应用程序可以显式拦截 HTTP 401 响应并触发身份验证流程。但是,由于 SPA 现在负责启动流程,因此我们需要允许对其静态资源进行未经身份验证的访问。我们还希望在未经身份验证的情况下检索目录中的书籍,因此让我们也允许对 /books/** 端点的 GET 请求。继续更新 SecurityConfig 类中的 SecurityWebFilterChain Bean,如下所示。
清单 11.15 允许对 SPA 和书籍进行未经身份验证的 GET 请求
@EnableWebFluxSecurity
public class SecurityConfig {
// ...
@Bean
SecurityWebFilterChain springSecurityFilterChain(
ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrationRepository) {
return http
.authorizeExchange(exchange -> exchange
// 允许对 SPA 静态资源进行未经身份验证的访问
.pathMatchers("/", "/*.css", "/*.js", "/favicon.ico")
.permitAll()
.pathMatchers(HttpMethod.GET, "/books/**")
.permitAll()
// 任何其他请求都需要用户身份验证
.anyExchange().authenticated())
.exceptionHandling(exceptionHandling ->
exceptionHandling.authenticationEntryPoint(
new HttpStatusServerEntryPoint(HttpStatus.UNAUTHORIZED)))
.oauth2Login(Customizer.withDefaults())
.logout(logout -> logout.logoutSuccessHandler(
oidcLogoutSuccessHandler(clientRegistrationRepository)))
.build();
}
}
让我们测试一下 Edge Service 现在的工作方式。确保 Polar UI、Redis 和 Keycloak 容器仍在运行。接下来,构建并运行 Edge Service 应用程序(./gradlew bootRun),然后从隐身浏览器窗口转到 http://localhost:9000。首先要注意的是,您没有被重定向到登录页面,而是立即看到了 Angular 前端应用程序。您可以通过单击右上角菜单中的 Login 按钮来启动身份验证流程。
登录后,右上角菜单将包含一个 Logout 按钮,仅在当前用户成功通过身份验证时显示。单击该按钮以注销。它应该触发注销流程,但由于 CSRF 问题将无法工作。您将在下一节中了解如何修复此问题。同时,使用 Ctrl-C 停止应用程序。
11.5.3 防止跨站请求伪造
前端和后端之间的交互基于会话 cookie。用户通过 OIDC/OAuth2 策略成功通过身份验证后,Spring 将生成一个会话标识符来匹配经过身份验证的上下文,并将其作为 cookie 发送到浏览器。对后端的任何后续请求都必须包含会话 cookie,Spring Security 可以从中检索与特定用户关联的令牌并验证请求。
但是,会话 cookie 不足以验证请求,这些请求容易受到跨站请求伪造(CSRF)攻击。CSRF 影响修改 HTTP 请求,如 POST、PUT 和 DELETE。攻击者可以通过伪造旨在造成损害的请求来诱使用户执行他们不打算执行的请求。伪造的请求可以做诸如从您的银行帐户转账或破坏关键数据之类的事情。
警告 许多在线教程和指南在配置 Spring Security 时首先禁用 CSRF 保护。在没有解释原因或考虑后果的情况下这样做是危险的。我建议保持保护启用,除非有充分的理由不这样做(您将在第 12 章中看到一个充分的理由)。作为一般准则,面向浏览器的应用程序(如 Edge Service)应受到 CSRF 支击的保护。
幸运的是,Spring Security 内置了针对此类攻击的保护。该保护基于所谓的 CSRF 令牌,该令牌由框架生成,在会话开始时提供给客户端,并要求随任何状态更改请求一起发送。
注意 要了解有关 Spring Security 中 CSRF 保护的更多信息,您可以查看 Laurentiu Spilca 的《Spring Security in Action》(Manning,2020)的第 10 章,其中对此主题进行了详细说明。
在上一节中,您尝试注销,但请求失败。由于注销操作通过向 /logout 端点发送 POST 请求来提供,因此应用程序期望接收为该用户会话生成的 CSRF 令牌。默认情况下,生成的 CSRF 令牌作为 HTTP 头发送到浏览器。
但是,Angular 应用程序无法处理此问题,并期望将令牌值作为 cookie 接收。Spring Security 支持此特定要求,但默认情况下未启用。
您可以通过 ServerHttpSecurity 公开的 csrf() DSL 和 CookieServerCsrfTokenRepository 类指示 Spring Security 以 cookie 形式提供 CSRF 令牌。对于命令式应用程序,这就足够了。但是,对于像 Edge Service 这样的响应式应用程序,您需要采取额外的步骤来确保实际提供 CsrfToken 值。
在第 8 章中,您了解到响应式流需要被订阅才能激活它们。目前,CookieServerCsrfTokenRepository 不能确保订阅 CsrfToken,因此您必须在 WebFilter Bean 中显式提供解决方法。此问题应在 Spring Security 的未来版本中得到解决(参见 GitHub 上的问题 5766:https://mng.bz/XW89)。目前,按如下所示更新 SecurityConfig 类。
清单 11.16 配置 CSRF 以支持基于 cookie 的 SPA 策略
@EnableWebFluxSecurity
public class SecurityConfig {
// ...
@Bean
SecurityWebFilterChain springSecurityFilterChain(
ServerHttpSecurity http,
ReactiveClientRegistrationRepository clientRegistrationRepository) {
return http
// ...
.csrf(csrf -> csrf.csrfTokenRepository(
// 使用基于 cookie 的策略与 Angular 前端交换 CSRF 令牌
CookieServerCsrfTokenRepository.withHttpOnlyFalse()))
.build();
}
@Bean
WebFilter csrfWebFilter() {
// 一个过滤器,唯一目的是订阅 CsrfToken 响应式流并确保其值被正确提取
return (exchange, chain) -> {
exchange.getResponse().beforeCommit(() -> Mono.defer(() -> {
Mono<CsrfToken> csrfToken =
exchange.getAttribute(CsrfToken.class.getName());
return csrfToken != null ? csrfToken.then() : Mono.empty();
}));
return chain.filter(exchange);
};
}
}
让我们验证注销流程现在是否正常工作。确保 Polar UI、Redis 和 Keycloak 容器仍在运行。接下来,构建并运行应用程序(./gradlew bootRun),然后从隐身浏览器窗口转到 http://localhost:9000。通过单击右上角菜单中的 Login 按钮启动身份验证流程。然后单击 Logout 按钮。在底层,Spring Security 现在将接受您的注销请求(Angular 从 cookie 中添加 CSRF 令牌值作为 HTTP 头),终止您的 Web 会话,将请求传播到 Keycloak,最后将您重定向到主页,未经身份验证。
得益于此更改,您还可以执行任何 POST、PUT 和 DELETE 请求而不会收到 CSRF 错误。随意探索 Angular 应用程序。如果启动 Catalog Service 和 Order Service,您可以尝试向目录中添加新书籍、修改它们或下订单。
目前,Isabelle 和 Bjorn 都可以执行任何操作,这不是我们想要的,因为客户(如 Bjorn)不应该被允许管理图书目录。下一章将介绍授权,您将看到如何使用不同的访问策略保护每个端点。不过,在解决授权问题之前,我们需要编写自动测试来涵盖新功能。这将在下一节中介绍。
在继续之前,使用 Ctrl-C 停止应用程序,并使用 docker-compose down(从 polar-deployment/docker)停止所有容器。