1. Overview
1.概述
In this tutorial, we’ll implement the OAuth2 Backend for Frontend (BFF) pattern with Spring Cloud Gateway and spring-addons.
在本教程中,我们将使用 Spring Cloud Gateway 和 spring-addons 实现 OAuth2 Backend for Frontend (BFF) 模式。
If we inspect any of the major websites known for using OAuth2 (Google, Facebook, Github, LinkedIn, etc.) we won’t find Bearer headers with tokens. Why that?
如果我们检查任何已知使用 OAuth2 的主要网站(Google、Facebook、Github、LinkedIn 等),我们都不会发现带有令牌的 Bearer 标头。为什么会这样?
According to security experts, we should not configure single-page applications (Angular, React, Vue, etc.) or mobile applications as “public” OAuth2 clients anymore. The best alternative is probably to authorize both mobile and web apps with sessions on a BFF running on a server we trust.
根据安全专家的意见,我们不应再将单页面应用程序(Angular、React、Vue 等)或移动应用程序配置为 “公共 “OAuth2 客户端。最好的替代方法可能是在我们信任的服务器上运行的 BFF 上通过会话对移动和 Web 应用程序进行授权。
As a reminder, JSON Web Tokens (JWTs) can’t be invalidated and we can hardly delete it on end-users devices when terminating sessions on the server. If we send JWTs over the network, all we can do is waiting for it to expire, access to resource servers still being authorized until then. But if tokens never leave the backend, then we can delete it with the user session on the BFF, immediately revoking access to resources.
需要提醒的是,JSON Web 标记(JWT)无法失效,而且在服务器上终止会话时,我们很难在终端用户设备上删除它。如果我们通过网络发送 JWT,我们所能做的就是等待它过期,在过期前对资源服务器的访问仍然是授权的。但是,如果令牌从未离开后端,那么我们就可以在 BFF 上将其与用户会话一起删除,从而立即取消对资源的访问权限。
The good news is that we can connect a Single Page Applications (SPAs) to an OAuth2 BFF in a few simple steps. Even better, we have no modification at all to apply to resource servers (REST APIs authorized with Bearer access tokens).
好消息是,只需几个简单的步骤,我们就可以将 Single Page Applications (SPA) 连接到 OAuth2 BFF。更妙的是,我们无需对资源服务器(使用 Bearer 访问令牌授权的 REST API)进行任何修改。
In this tutorial, we’ll use:
在本教程中,我们将使用
- Spring Cloud Gateway as OAuth2 BFF: “confidential” OAuth2 client with TokenRelay filter
- a Spring Boot REST API configured as a stateless OAuth2 resource server
- 3 frontends written with Angular, React (Next.js) and Vue (Vite)
- Keycloak as main OpenID Provider (OP), but the companion repo also contains Spring profiles to easily get started with Auth0 and Amazon Cognito.
- a reverse proxy to have the same origin for at least the SPAs and the BFF
- spring-addons-starter-oidc, an open-source Spring Boot starter, to further simplify OAuth2 configuration in Spring Boot applications
2. OAuth2 Backend for Frontend Pattern
2.前端模式的 OAuth2 后端
The Backend for Frontend pattern is an architecture with a middleware between a frontend and REST APIs. When OAuth2 is involved, requests are authorized with:
前台后台模式是一种在前台和 REST API 之间设置中间件的架构。当涉及 OAuth2 时,请求通过以下方式授权:
- session cookie and CSRF protection between the frontend and the BFF
- access token between the BFF and REST API (and between backend services)
Such an OAuth2 BFF is responsible for:
这样的 OAuth2 BFF 负责:
- driving the authorization-code flow using a “confidential” OAuth2 client
- storing tokens in session
- replacing the session cookie with the access token in session before forwarding a request from the frontend to a resource server
The OAuth2 BFF pattern is safer than configuring a single-page or mobile application as a “public” OAuth2 client because:
OAuth2 BFF 模式比将单页面或移动应用程序配置为 “公共 “OAuth2 客户端更安全,因为:
- the BFF running on a server we trust, it can keep a secret to call the authorization server token endpoint
- we can set firewall rules to allow only requests from our backend to access the token endpoint
- tokens are kept on the server (sessions). Usage of session cookies requires protection against CSRF, but cookies can be flagged with HttpOnly, Secure and SameSite, which is safer than exposing tokens to the code running on end-user devices.
As BFF, we’ll use Spring Cloud Gateway with:
作为 BFF,我们将使用 Spring 云网关:
- spring-boot-starter-oauth2-client and oauth2Login() to handle the authorization-code flow and store tokens in the session
- the TokenRelay= filter to replace the session cookie with the access token in the session when forwarding requests from the frontend to a resource server
3. Architecture
3.建筑
So far, we listed quite a few services: frontends (SPAs), REST API, BFF, authorization server, and reverse proxy. Let’s have a look at how it makes a coherent system.
到目前为止,我们已经列出了许多服务:前端(SPA)、REST API、BFF、授权服务器和反向代理。让我们看看它们是如何组成一个连贯的系统的。
3.1. System Overview
3.1 系统概述
Here is a representation of services, ports, and path-prefixes we’ll use with the main profile:
下面是我们将与主配置文件一起使用的服务、端口和路径前缀:
A few points to note from this schema are:
该模式有以下几点值得注意:
- from the perspective of the end-user device, there is a single point of contact with the backend: the reverse proxy
- we expose three different single-page applications to demo the integration with each of the major frameworks (Angular, React, and Vue)
- the reverse-proxy uses a path prefix to route requests to the right service
The reasons why the only link to the authorization server is from the reverse-proxy are:
与授权服务器的唯一链接来自反向代理的原因是
- when configuring Keycloak in section 4 we’ll set the hostname-url property with a value pointing to the reverse proxy, with /auth as path-prefix. This influences the value of tokens issuer claim, but also of all endpoint URIs in the OpenID configuration (authorization, token, endsession, …).
- the reverse proxy is configured to route to Keycloak all requests with a path starting with /auth
Note that in the companion project, the profiles for Auth0 and Amazon Cognito are configured differently: no route to the authorization server on the reverse proxy and issuer as well as endpoint URIs are kept with their defaults on the authorization servers. This leads to this slightly different alternative architecture:
请注意,在配套项目中,Auth0 和 Amazon Cognito 的配置文件配置方式不同:在反向代理上没有指向授权服务器的路由,而在授权服务器上,签发者和终端 URI 均保持默认值。这就导致了这种略有不同的替代架构:
Using path-prefix to make a distinction between SPAs is nice when working on a single dev machine, but when going to a production-like environment, we might as well configure the reverse-proxy to use (sub)domains for routing, or even use a distinct reverse-proxy for each frontend.
在单台开发机上使用路径前缀来区分 SPA 固然不错,但在生产环境中,我们不妨配置反向代理,使用(子)域进行路由,甚至为每个前端使用不同的反向代理。
3.2. Reverse Proxy
3.2.反向代理
We need the same origin for a SPA and its BFF because:
我们需要为 SPA 及其闺蜜提供相同的原产地,因为:
- the requests are authorized with session cookies between the frontend and the BFF
- Spring session cookies are flagged with SameSite=Lax
For that, we’ll use a reverse proxy as a single contact point for browsers. It will route requests to the right service using path-prefix.
为此,我们将使用反向代理作为浏览器的单一联络点。它会使用路径前缀将请求路由到正确的服务。
In the companion repo, we use a very basic Spring Cloud Gateway instance with just some routing (no security, no other filter than a StripPrefix=1 on the route to the BFF), but there are plenty of other options to achieve the same goal, some being more adapted to specific environments: nginx container in Docker, ingress on K8s, etc.
在配套的 repo 中,我们使用了一个非常基本的 Spring Cloud Gateway 实例,其中只包含一些路由(没有安全性,在通往 BFF 的路由上除了 StripPrefix=1 之外没有其他过滤器),但还有很多其他选项可以实现相同的目标,其中一些选项更适合特定的环境:Docker 中的 nginx 容器、K8s 上的 ingress 等。
3.3. Whether to Hide the Authorization Server Behind the Reverse Proxy
3.3.是否将授权服务器隐藏在反向代理后面
For security reasons, an authorization server should always set the X-Frame-Options header. As Keycloak allows to set it to SAMEORIGIN, if the authorization server and the SPA share the same origin, then it’s possible to display Keycloak login & registration forms in an iframe embedded in this SPA.
出于安全考虑,授权服务器应始终设置 X-Frame-Options 标头。由于 Keycloak 允许将其设置为 SAMEORIGIN,如果授权服务器和 SPA 共享相同的起源,则可以在嵌入此 SPA 的 iframe 中显示 Keycloak 登录和注册表单。
From the end-user perspective, it’s probably a better experience to stay in the same web app with authorization forms displayed in a modal, rather than being redirected back and forth between the SPA and an authorization server.
从最终用户的角度来看,与其在 SPA 和授权服务器之间来回重定向,不如留在同一个网络应用程序中,以模态方式显示授权表单,这样的体验可能会更好。
On the other hand, Single Sign-On (SSO) relies on cookies flagged with SameOrigin. As a consequence, for two SPAs to benefit from Single Sign-On they should not only authenticate users on the same authorization server but also use the same authority for it (both https://appa.net and https://appy.net authenticate users on https://sso.net).
另一方面,单点登录 (SSO) 依赖于标记有SameOrigin.的 cookie。因此,两个 SPA 要从单点登录中获益,不仅要在同一授权服务器上对用户进行身份验证,还要使用相同的授权(https://appa.net和https://appy.net都在https://sso.net上对用户进行身份验证)。
A solution to match both conditions is using the same origin for all SPAs and the authorization server, with URIs like:
同时满足这两个条件的解决方案是对所有 SPA 和授权服务器使用相同的来源,URI 如下
- https://domain.net/appa
- https://domain.net/appy
- https://domain.net/auth
This is the option we’ll use when working with Keycloak, but sharing the same origin between the SPAs and the authorization server isn’t a requirement for the BFF pattern to work, only sharing the same origin between the SPAs and the BFF is.
这是我们在使用 Keycloak 时将使用的选项,但 在 SPA 和授权服务器之间共享相同的源并不是 BFF 模式工作的必要条件,只有在 SPA 和 BFF 之间共享相同的源才是必要条件。
The projects in the companion repo are preconfigured to use Amazon Cognito and Auth0 with their origin (no smart proxy rewriting redirection URLs on the fly). For this reason, login from an iframe is available only when using the default profile (with Keycloak).
配套软件仓库中的项目已预先配置为使用亚马逊 Cognito 和 Auth0(没有智能代理即时重写重定向 URL)。因此,只有在使用默认配置文件(使用 Keycloak)时,才能通过 iframe 登录。
3.4. Implementation
3.4.实施
First, using our IDE or https://start.spring.io/, we create a new Spring Boot project named reverse-proxy with Reactive Gateway as a dependency.
首先,我们使用 IDE 或 https://start.spring.io/ 创建一个名为 reverse-proxy 的新 Spring Boot 项目,并将 Reactive Gateway 作为依赖项。
Then we rename src/main/resources/application.properties to src/main/resources/application.yml.
然后,我们将 src/main/resources/application.properties 重命名为 src/main/resources/application.yml..
We should then define the routing properties for Spring Cloud Gateway:
然后,我们应该为 Spring Cloud Gateway 定义路由属性:
# Custom properties to ease configuration overrides
# on command-line or IDE launch configurations
scheme: http
hostname: localhost
reverse-proxy-port: 7080
angular-port: 4201
angular-prefix: /angular-ui
angular-uri: http://${hostname}:${angular-port}${angular-prefix}
vue-port: 4202
vue-prefix: /vue-ui
vue-uri: http://${hostname}:${vue-port}${vue-prefix}
react-port: 4203
react-prefix: /react-ui
react-uri: http://${hostname}:${react-port}${react-prefix}
authorization-server-port: 8080
authorization-server-prefix: /auth
authorization-server-uri: ${scheme}://${hostname}:${authorization-server-port}${authorization-server-prefix}
bff-port: 7081
bff-prefix: /bff
bff-uri: ${scheme}://${hostname}:${bff-port}
server:
port: ${reverse-proxy-port}
spring:
cloud:
gateway:
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
routes:
# SPAs assets
- id: angular-ui
uri: ${angular-uri}
predicates:
- Path=${angular-prefix}/**
- id: vue-ui
uri: ${vue-uri}
predicates:
- Path=${vue-prefix}/**
- id: react-ui
uri: ${react-uri}
predicates:
- Path=${react-prefix}/**
# Authorization-server
- id: authorization-server
uri: ${authorization-server-uri}
predicates:
- Path=${authorization-server-prefix}/**
# BFF
- id: bff
uri: ${bff-uri}
predicates:
- Path=${bff-prefix}/**
filters:
- StripPrefix=1
With this configuration added, we can start our reverse-proxy!
添加了这些配置后,我们就可以启动反向代理了!
4. Authorization Server
4.授权服务器
In the companion project on GitHub the default profile is designed for Keycloak but, thanks to spring-addons-starter-oidc, switching to any other OpenID Provider is just a matter of editing application.yml. The files provided in the companion project contain profiles to get started easily with Auth0 and Amazon Cognito.
在 GitHub 上的配套项目中,默认配置文件是专为 Keycloak 设计的,但由于使用了 Spring加载项启动器-oidc,因此只需编辑 application.yml 即可切换到任何其他 OpenID 提供商。配套项目中提供的文件包含可轻松使用 Auth0 和 Amazon Cognito 的配置文件。
4.1. Keycloak in Docker
4.1.Docker 中的 Keycloak
The companion repo contains a docker compose file. To use it, all we need to do is:
配套软件源中包含一个 docker compose 文件。要使用它,我们只需要
- edit the .env file to change the KEYCLOAK_ADMIN_PASSWORD
- run “docker compose -f docker-compose.yaml up” from the keycloak directory
4.2. Standalone Keycloak
4.2.独立密钥斗篷
We could also download a distribution powered by Quarkus from the Keycloak website.
我们还可以从 Keycloak 网站下载由 Quarkus 支持的发行版。
First, we’d need to edit keycloak.conf to add something like the following:
首先,我们需要编辑 keycloak.conf 以添加类似下面的内容:
hostname-url=http://localhost:7080/auth
hostname-admin-url=http://localhost:7080/auth
http-relative-path=/auth
http-port=8080
Then we should:
那我们就应该
- check that the reverse proxy is running
- start Keycloak with either “bin\kc.bat start-dev” or “bash ./bin/kc.sh start-dev”
- visit http://localhost:7080/auth/ to set the admin password
4.3. Realm and Test User
4.3.境界和测试用户
To sandbox the labs in a Keycloak realm, we’ll:
要在 Keycloak 境界中对实验室进行沙盒测试,我们需要
- click the dropdown in the top left corner displaying master
- click the Create Realm button
- input baeldung as Realm name
Then, we’ll create a user:
然后,我们将创建一个用户:
- click on Users from the left menu
- click the Add user button
- fill the form
- click the Create button
- switch to Credentials tab
- click the Set password button
4.3. Confidential Client With Authorization-Code
4.3.带授权代码的保密客户端
By browsing http://localhost:7080/auth/admin/master/console/#/baeldung/clients, we can create a baeldung-confidential client:
通过浏览 http://localhost:7080/auth/admin/master/console/#/baeldung/clients, 我们可以创建一个 baeldung-confidential 客户端:
Client authentication is turned on to specify we want a “confidential” client and only Standard flow is selected because we’ll only use authorization-code.
打开客户端验证以指定我们需要一个 “保密 “客户端,并且只选择标准流程,因为我们只使用授权代码。
We should have as a very minimum:
我们至少应该
- http://localhost:7080/bff/login/oauth2/code/baeldung as redirect URI
- http://localhost:7080/* as post logout URI
- + as web origin (allows origins configured in redirect and post logout URIs)
When debugging from other devices (like mobile devices or emulators), we should add more network interfaces for our dev machine than just localhost (and adapt hostname-url as well as hostname-admin-url in the Keycloak configuration).
从其他设备(如移动设备或模拟器)进行调试时,我们应为开发机添加更多网络接口,而不仅仅是 localhost(并在 Keycloak 配置中调整 hostname-url 和 hostname-admin-url)。
4.4. Working With Other OpenID Providers
4.4.与其他 OpenID 提供商合作
Each OpenID Provider has its way of declaring “confidential” OAuth2 clients, therefore we should refer to its documentation for details, but all have similar configuration parameters.
每个 OpenID 提供商都有自己声明 OAuth2 客户端 “机密 “的方式,因此我们应参考其文档了解详情,但所有提供商都有类似的配置参数。
For instance, on Auth0, we’d create a new Regular Web Application named baeldung-confidential and its Settings tab would expect the same values as those visible in the second Keycloak screenshot from the preceding section. It would also be the place to collect client-id and client-secret. Last, we’d create an API with bff.baeldung.com as an identifier and with baeldung-confidential enabled in the Machine To Machine Applications tab.
例如,在 Auth0 上,我们将创建一个名为baeldung-confidential的新常规 Web 应用程序,其设置选项卡期望的值与上一节第二张 Keycloak 截图中可见的值相同。此外,它还将用于收集 client-id 和 client-secret 。最后,我们将创建一个以 bff.baeldung.com 作为标识符的 API,并在 Machine To Machine Applications 选项卡中启用 baeldung-confidential。
5. BFF Implementation With Spring Cloud Gateway and spring-addons-starter-oidc
5.利用 Spring 云网关和 spring-addons-starter-oidc 实施 BFF
First, using our IDE or https://start.spring.io/, we create a new Spring Boot project named bff with Reactive Gateway and OAuth2 client as dependencies.
首先,我们使用集成开发环境或 https://start.spring.io/ 创建一个名为 bff 的新 Spring Boot 项目,并将 Reactive Gateway 和 OAuth2 客户端作为依赖项。
Then we rename src/main/resources/application.properties to src/main/resources/application.yml.
然后,我们将 src/main/resources/application.properties 重命名为 src/main/resources/application.yml 。
Last, we’ll add spring-addons-starter-oidc to our dependencies:
最后,我们将在依赖项中添加 spring-addons-starter-oidc :
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.5.3</version>
</dependency>
5.1. Re-Used Properties
5.1.再利用属性
Let’s start with a few constants in application.yml that will help us in other sections and when needing to override some values on the command line or IDE launch configuration:
让我们从application.yml中的几个常量开始,它们将在其他部分以及需要覆盖命令行或 IDE 启动配置中的某些值时为我们提供帮助:
scheme: http
hostname: localhost
reverse-proxy-port: 7080
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
authorization-server-prefix: /auth
issuer: ${reverse-proxy-uri}${authorization-server-prefix}/realms/baeldung
client-id: baeldung-confidential
client-secret: change-me
username-claim-json-path: $.preferred_username
authorities-json-path: $.realm_access.roles
bff-port: 7081
bff-prefix: /bff
resource-server-port: 7084
audience:
Of course, we’ll have to override the value of client-secret with, for instance, an environment variable, a command line argument, or an IDE launch configuration.
当然,我们必须使用环境变量、命令行参数或 IDE 启动配置等来覆盖 client-secret 的值。
5.2. Server Properties
5.2.服务器属性
Now come the usual server properties:
现在是常规的服务器属性:
server:
port: ${bff-port}
5.3. Spring Cloud Gateway Routing
5.3.Spring 云网关路由
As we have a single resource server behind the gateway, we need only one route definition:
由于网关后面只有一个资源服务器,我们只需要一个路由定义:
spring:
cloud:
gateway:
routes:
- id: bff
uri: ${scheme}://${hostname}:${resource-server-port}
predicates:
- Path=/api/**
filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
- TokenRelay=
- SaveSession
- StripPrefix=1
The most important parts are the SaveSession and TokenRelay= which form a cornerstone for the OAuth2 BFF pattern implementation: the first ensures that the session is persisted (with the tokens fetched by oauth2Login()) and the second replaces the session cookie with the access token in session when routing a request.
最重要的部分是SaveSession和TokenRelay=,它们构成了实现 OAuth2 BFF 模式的基石:第一个部分确保会话被持久化(使用 oauth2Login() 获取的令牌),第二个部分在路由请求时用会话中的访问令牌替换会话 cookie。
The StripPrefix=1 filter removes /api prefix from the path when routing a request. Notably, the /bff prefix was already stripped during the reverse-proxy routing. As a consequence, a request sent from the frontend to /bff/api/me lands as /me on the resource server.
StripPrefix=1 过滤器会在路由请求时删除路径中的 /api 前缀。值得注意的是,在反向代理路由过程中,/bff 前缀已被去除。因此,从前端发送到 /bff/api/me 的请求在资源服务器上会显示为 /me。
5.4. Spring Security
5.4.Spring安全
We can now get into configuring OAuth2 client security with the standard Boot properties:
现在我们可以使用标准 Boot 属性配置 OAuth2 客户端安全性:
spring:
security:
oauth2:
client:
provider:
baeldung:
issuer-uri: ${issuer}
registration:
baeldung:
provider: baeldung
authorization-grant-type: authorization_code
client-id: ${client-id}
client-secret: ${client-secret}
scope: openid,profile,email,offline_access
Really nothing special here, just a very standard authorization-code registration with the required provider.
其实没什么特别的,只是在所需的提供商处进行了非常标准的授权代码注册。
5.5. spring-addons-starter-oidc
5.5.spring-addons-starter-oidc
To complete the configuration, let’s tune the security with spring-addons-starter-oidc:
要完成配置,让我们使用 spring-addons-starter-oidc 调整安全性:
com:
c4-soft:
springaddons:
oidc:
# Trusted OpenID Providers configuration (with authorities mapping)
ops:
- iss: ${issuer}
authorities:
- path: ${authorities-json-path}
aud: ${audience}
# SecurityFilterChain with oauth2Login() (sessions and CSRF protection enabled)
client:
client-uri: ${reverse-proxy-uri}${bff-prefix}
security-matchers:
- /api/**
- /login/**
- /oauth2/**
- /logout
permit-all:
- /api/**
- /login/**
- /oauth2/**
csrf: cookie-accessible-from-js
oauth2-redirections:
rp-initiated-logout: ACCEPTED
# SecurityFilterChain with oauth2ResourceServer() (sessions and CSRF protection disabled)
resourceserver:
permit-all:
- /login-options
- /error
- /actuator/health/readiness
- /actuator/health/liveness
Let’s understand the three main sections:
让我们来了解一下这三个主要部分:
- ops, with provider(s) specific values. This enables us to specify the JSON path of the claims to convert to Spring authorities (with optional prefixes and case transformation for each). If aud property is not empty, spring-addons adds an audience validator to the JWT decoders.
- client, when security-matchers are not empty, this section triggers the creation of a SecurityFilterChain bean with oauth2Login(). Here, with client-uri property, we force the usage of the reverse-proxy URI as a base for all redirections (instead of the BFF internal URI). Also, as we are using SPAs, we ask the BFF to expose the CSRF token in a cookie accessible to Javascript. Last, to prevent CORS errors, we ask that the BFF respond to the RP-Initiated Logout with 201 status (instead of 3xx), which gives SPAs the ability to intercept this response and ask the browser to process it in a request with a new origin.
- resourceserver, this requests for a second SecurityFilterChain bean with oauth2ResourceServer(). This filter chain having an @Order with the lowest precedence will process all of the requests that weren’t matched by the security matchers from the client SecurityFilterChain. We use it for all resources for which a session is not desirable: endpoints that aren’t involved in login or routing with TokenRelay.
We can now run the BFF application, carefully providing the client-secret on the command line or run configuration.
现在我们可以运行 BFF 应用程序,在命令行或运行配置中小心提供 client-secret 。
5.6. /login-options Endpoint
5.6./login-options 端点
The BFF is the place where we define login configuration: Spring OAuth2 client registration(s) with authorization code. To avoid configuration duplication in each SPA (and possible inconsistencies), we’ll host on the BFF a REST endpoint exposing the login option(s) it supports for users.
BFF 是我们定义登录配置的地方:带有授权代码的 Spring OAuth2 客户端注册。为了避免在每个 SPA 中重复配置(以及可能出现的不一致),我们将在 BFF 上托管一个 REST 端点,为用户提供其支持的登录选项。
For that, all we have to do is expose a @RestController with a single endpoint returning a payload built from configuration properties:
为此,我们只需公开一个 @RestController,该控制器有一个端点,可返回由配置属性构建的有效载荷:
@RestController
public class LoginOptionsController {
private final List<LoginOptionDto> loginOptions;
public LoginOptionsController(OAuth2ClientProperties clientProps, SpringAddonsOidcProperties addonsProperties) {
final var clientAuthority = addonsProperties.getClient()
.getClientUri()
.getAuthority();
this.loginOptions = clientProps.getRegistration()
.entrySet()
.stream()
.filter(e -> "authorization_code".equals(e.getValue().getAuthorizationGrantType()))
.map(e -> {
final var label = e.getValue().getProvider();
final var loginUri = "%s/oauth2/authorization/%s".formatted(addonsProperties.getClient().getClientUri(), e.getKey());
final var providerId = clientProps.getRegistration()
.get(e.getKey())
.getProvider();
final var providerIssuerAuthority = URI.create(clientProps.getProvider()
.get(providerId)
.getIssuerUri())
.getAuthority();
return new LoginOptionDto(label, loginUri, Objects.equals(clientAuthority, providerIssuerAuthority));
})
.toList();
}
@GetMapping(path = "/login-options", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<List<LoginOptionDto>> getLoginOptions() throws URISyntaxException {
return Mono.just(this.loginOptions);
}
public static record LoginOptionDto(@NotEmpty String label, @NotEmpty String loginUri, boolean isSameAuthority) {
}
}
5.7. Non-Standard RP-Initiated Logout
5.7.非标准 RP 启动的注销
RP-Initiated Logout is part of the OpenID standard, but some providers don’t implement it strictly. This is the case of Auth0 and Amazon Cognito for instance which don’t provide an end_session endpoint in their OpenID configuration and use some non-standard query parameters for logout.
RP-Initiated Logout 是 OpenID 标准的一部分,但有些提供商并未严格执行。例如,Auth0 和 Amazon Cognito 就没有在其 OpenID 配置中提供 end_session 端点,而是使用一些非标准查询参数来注销。
spring-addons-starter-oidc supports such logout endpoints “almost” complying with the standard. The BFF configuration in the companion project contains profiles with the required configuration:
spring-addons-starter-oidc 支持 “几乎 “符合标准的注销端点。配套项目中的 BFF 配置包含具有所需配置的配置文件:
---
spring:
config:
activate:
on-profile: cognito
issuer: https://cognito-idp.us-west-2.amazonaws.com/us-west-2_RzhmgLwjl
client-id: 12olioff63qklfe9nio746es9f
client-secret: change-me
username-claim-json-path: username
authorities-json-path: $.cognito:groups
com:
c4-soft:
springaddons:
oidc:
client:
oauth2-logout:
baeldung:
uri: https://spring-addons.auth.us-west-2.amazoncognito.com/logout
client-id-request-param: client_id
post-logout-uri-request-param: logout_uri
---
spring:
config:
activate:
on-profile: auth0
issuer: https://dev-ch4mpy.eu.auth0.com/
client-id: yWgZDRJLAksXta8BoudYfkF5kus2zv2Q
client-secret: change-me
username-claim-json-path: $['https://c4-soft.com/user']['name']
authorities-json-path: $['https://c4-soft.com/user']['roles']
audience: bff.baeldung.com
com:
c4-soft:
springaddons:
oidc:
client:
authorization-request-params:
baeldung:
- name: audience
value: ${audience}
oauth2-logout:
baeldung:
uri: ${issuer}v2/logout
client-id-request-param: client_id
post-logout-uri-request-param: returnTo
In the snippet above, baeldung is a reference to the client registration in Spring Boot properties. If we used another key in spring.security.oauth2.client.registration, we’d have to use it here too.
在上面的代码段中,baeldung 是对 Spring Boot 属性中客户端注册的引用。如果我们在spring.security.oauth2.client.registration中使用另一个密钥,我们也必须在此处使用它。
In addition to the required properties overrides, we can note in the second profile, the specification for an additional request parameter when we send an authorization request to Auth0: audience.
除了所需的属性重载外,我们还可以在第二个配置文件中注意到,当我们向 Auth0 发送授权请求时,还指定了一个额外的请求参数:受众。
6. Resource Server With spring-addons-starter-oidc
6.使用 spring-addons-starter-oidc 的资源服务器
Our need for this system is very simple: a stateless REST API authorized with JWT access tokens, exposing a single endpoint to reflect some user info contained in the token (or a payload with empty values if the request isn’t authorized).
我们对该系统的需求非常简单:使用 JWT 访问令牌授权的无状态 REST 应用程序接口,暴露一个单一的端点,以反映令牌中包含的一些用户信息(如果请求未获授权,则暴露一个带空值的有效载荷)。
For this, we’ll create a new Spring Boot project named resource-server with Spring Web and OAuth2 Resource Server as dependencies.
为此,我们将创建一个名为 resource-server 的新 Spring Boot 项目,并将 Spring Web 和 OAuth2 Resource Server 作为依赖项。
Then we rename src/main/resources/application.properties to src/main/resources/application.yml.
然后,我们将 src/main/resources/application.properties 重命名为 src/main/resources/application.yml 。
Last, we’ll add spring-addons-starter-oidc to our dependencies:
最后,我们将在依赖项中添加 spring-addons-starter-oidc :
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.5.3</version>
</dependency>
6.1. Configuration
6.1.配置
Here are the properties we need for our resource server:
以下是资源服务器所需的属性:
scheme: http
hostname: localhost
reverse-proxy-port: 7080
reverse-proxy-uri: ${scheme}://${hostname}:${reverse-proxy-port}
authorization-server-prefix: /auth
issuer: ${reverse-proxy-uri}${authorization-server-prefix}/realms/baeldung
username-claim-json-path: $.preferred_username
authorities-json-path: $.realm_access.roles
resource-server-port: 7084
audience:
server:
port: ${resource-server-port}
com:
c4-soft:
springaddons:
oidc:
ops:
- iss: ${issuer}
username-claim: ${username-claim-json-path}
authorities:
- path: ${authorities-json-path}
aud: ${audience}
resourceserver:
permit-all:
- /me
- /actuator/health/readiness
- /actuator/health/liveness
Thanks to spring-addons-starter-oidc, this is enough to declare a stateless resource server with:
多亏了 spring-addons-starter-oidc 的帮助,这足以声明一个无状态资源服务器:
- authorities mapping from a claim of our choice (realm_access.roles in the case of Keycloak with realm roles)
- making /me accessible to anonymous requests
The application.yaml in the companion repo contains profiles for other OpenID Providers using other private claims for roles.
配套软件仓库中的 application.yaml 包含使用其他角色私有声明的其他 OpenID 提供商的配置文件。
6.2. @RestController
6.2.@RestController
Let’s implement a REST endpoint returning some data from the Authentication in the security context (if any):
让我们实现一个 REST 端点,从安全上下文(如果有)中的 Authentication 返回一些数据:
@RestController
public class MeController {
@GetMapping("/me")
public UserInfoDto getMe(Authentication auth) {
if (auth instanceof JwtAuthenticationToken jwtAuth) {
final var email = (String) jwtAuth.getTokenAttributes()
.getOrDefault(StandardClaimNames.EMAIL, "");
final var roles = auth.getAuthorities()
.stream()
.map(GrantedAuthority::getAuthority)
.toList();
final var exp = Optional.ofNullable(jwtAuth.getTokenAttributes()
.get(JwtClaimNames.EXP)).map(expClaim -> {
if(expClaim instanceof Long lexp) {
return lexp;
}
if(expClaim instanceof Instant iexp) {
return iexp.getEpochSecond();
}
if(expClaim instanceof Date dexp) {
return dexp.toInstant().getEpochSecond();
}
return Long.MAX_VALUE;
}).orElse(Long.MAX_VALUE);
return new UserInfoDto(auth.getName(), email, roles, exp);
}
return UserInfoDto.ANONYMOUS;
}
/**
* @param username a unique identifier for the resource owner in the token (sub claim by default)
* @param email OpenID email claim
* @param roles Spring authorities resolved for the authentication in the security context
* @param exp seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time when the access token expires
*/
public static record UserInfoDto(String username, String email, List<String> roles, Long exp) {
public static final UserInfoDto ANONYMOUS = new UserInfoDto("", "", List.of(), Long.MAX_VALUE);
}
}
6.3. Resource Server Multi-Tenancy
6.3.资源服务器多租户
What if the frontends consuming our REST API don’t all authorize their users on the same authorization server or realm (or if they offer a choice of authorization servers)?
如果使用我们的 REST API 的前端并不都在同一授权服务器或域上对用户进行授权(或提供授权服务器选择),该怎么办?
With spring-security-starter-oidc, this is dead simple: com.c4-soft.springaddons.oidc.ops configuration property is an array and we can add as many issuers as we trust, each with its mapping for user name and authorities. A valid token issued by any of these issuers will be accepted by our resource server and roles correctly mapped to Spring authorities.
有了spring-security-starter-oidc,这将变得非常简单:com.c4-soft.springaddons.oidc.ops配置属性是一个数组,我们可以添加尽可能多的发行者,每个发行者都有用户名和授权的映射。我们的资源服务器将接受这些发行者中任何一个发行的有效令牌,并将角色正确映射到 Spring 权限。
7. SPAs
7.SPA
Because there are some differences between the frameworks used to connect SPAs to an OAuth2 BFF, we’ll cover the three major ones: Angular, React, and Vue.
由于用于将 SPA 连接到 OAuth2 BFF 的框架之间存在一些差异,我们将介绍三个主要框架:Angular、React和Vue。
But, creating SPAs is out of the scope of this article. Hereafter, we’ll focus only on what it takes for a web application to log users in & out on an OAuth2 BFF and query a REST API behind it. Please refer to the companion repo for complete implementations.
但是,创建 SPA 不在本文讨论范围之内。下面,我们将只关注网络应用程序如何通过 OAuth2 BFF 登录用户并查询其背后的 REST API。有关完整的实现,请参阅配套软件仓库。
An effort was made for the apps to have the same structure:
我们努力使这些应用程序具有相同的结构:
- two routes to demo how the current one can be restored after authentication
- a Login component offers a choice of login experience if both iframe and default are available. It also handles iframe display status or redirection to the authorization server.
- a Logout component sends a POST request to the BFF /logout endpoint and then redirects to the authorization server for RP-Initiated Logout
- a UserService fetches current user data from the resource server through the BFF. It also holds some logic for scheduling a refresh of this data just before the access token on the BFF expires.
There is however a difference in the way the current user data is managed because of the very different way frameworks handle state:
不过,由于框架处理状态的方式截然不同,因此管理当前用户数据的方式也有所不同:
- in an Angular app, the UserService is a singleton managing current user with a BehaviorSubject
- in a React app, we used createContext in app/layout.tsx to expose the current user to all components, and useContext wherever we need to access it
- in a Vue app, the UserService is a singleton (instantiated in main.ts) managing the current user with a ref
7.1. Running SPAs in Companion Repo
7.1.在同伴 Repo 中运行 SPA
The first thing to do is to cd in the folder of the project we want to run.
首先要做的是在我们要运行的项目文件夹中cd。
Then, we should run “npm install” to pull all required npm packages.
然后,我们应该运行 “npm install” 来获取所有需要的 npm 软件包。
Lastly, depending on the framework:
最后,取决于框架:
- Angular: run “npm run start” and open http://localhost:7080/angular-ui/
- Vue: run “npm run serve” and open http://localhost:7080/vue-ui/
- React (Next.js): run “npm run dev” and open http://localhost:7080/react-ui/
We should be careful to use only URLs pointing to the reverse proxy and not to the SPAs dev-servers (http://localhost:7080, not http://localhost:4201, http://localhost:4202 and http://localhost:4203).
我们应注意只使用指向反向代理的 URL,而不是指向 SPA 开发服务器的 URL(http://localhost:7080,而不是 http://localhost:4201、http://localhost:4202 和 http://localhost:4203)。
7.2. User Service
7.2.用户服务
The responsibility for the UserService is to:
用户服务的职责是
- define the user representations (internal and DTO)
- expose a function to fetch user data from the resource server through the BFF
- schedule a refresh() call just before the access token expires (keep the session alive)
7.3. Login
7.3.登录
As we already saw, when possible, we provide two different login experiences:
正如我们已经看到的,在可能的情况下,我们会提供两种不同的登录体验:
- the user is redirected to the authorization server using the current browser tab (the SPA temporarily “exits”). This is the default behavior and is always available.
- authorization server forms are displayed in an iframe inside the SPA, which requires SameOrigin for the SPA and the authorization server and, as so, works only when the BFF and resource server run with the default profile (with Keycloak)
The logic is implemented by a Login component which displays a drop-down to select the login experience (iframe or default) and a button.
逻辑由 Login 组件实现,该组件显示一个下拉菜单,用于选择登录体验(iframe 或 default)和一个按钮。
Login options are fetched from the BFF when the component is initialized. In the case of this tutorial, we expect only one option, so we pick only the 1st entry in the response payload.
登录选项是在组件初始化时从 BFF 获取的。在本教程中,我们预计只有一个选项,因此我们只选择响应有效载荷中的第一个条目。
When the user clicks the Login button, what happens depends on the chosen login experience:
当用户单击 Login 按钮时,会发生什么取决于所选择的登录体验:
- if iframe is selected, the iframe source is set to the login URI, and the modal div containing the iframe displayed
- otherwise, the window.location.href is set to the login URI, which “exits” the SPA and sets the current tab with a brand-new origin
When the user selects the iframe login experience, we register an event listener for the iframe load events to check if the user authentication is successful and hide the modal. This call-back runs each time a redirection happens in the iframe.
当用户选择 iframe 登录体验时,我们会为 iframe load 事件注册一个事件监听器,以检查用户验证是否成功,并隐藏模态。每次在 iframe 中发生重定向时,都会运行该回调。
Last, we can note how the SPAs add a post_login_success_uri request parameter to the authorization-code flow initiation request. spring-addons-starter-oidc saves the value of this parameter in session and, after the authorization code is exchanged for tokens, uses it to build the redirection URI returned to the frontend.
最后,我们可以注意到 SPA 是如何在授权代码流程启动请求中添加 post_login_success_uri 请求参数的。spring-addons-starter-oidc 将该参数的值保存在会话中,并在授权代码被交换为令牌后,使用它来构建返回给前端的重定向 URI。
7.4. Logout
7.4.注销
The logout button and associated logic are handled by the Logout component.
注销按钮和相关逻辑由 Logout 组件处理。
By default, Spring /logout endpoint expects a POST request and, as any request modifying state on a server with sessions, it should contain a CSRF token. Angular and React handle transparently CSRF cookies flagged with http-only=false and request headers, but we have to manually read the XSRF-TOKEN cookie and set the X-XSRF-TOKEN header in Vue for every POST, PUT, PATCH and DELETE requests.
默认情况下,Spring /logout 端点希望收到一个 POST 请求,并且与任何修改会话服务器状态的请求一样,该请求应包含一个 CSRF 标记。Angular 和 React 可以透明地处理使用 http-only=false 和请求头标记的 CSRF cookie、但是,我们必须在 Vue 中为每个 POST、PUT、PATCH 和 DELETE 请求手动读取 XSRF-TOKEN cookie 并设置 X-XSRF-TOKEN 标头。
When involving a Spring OAuth2 client, the RP-Initiated Logout happens in two requests:
当涉及 Spring OAuth2 客户端时,RP 发起的注销分两个请求进行:
- first, a POST request is sent to the Spring OAuth2 client which closes its own session
- the response of the 1st request has a Location header with a URI on the authorization server to close the other session that the user has there
The default Spring behavior is to use 302 status for the 1st request, which makes the browser follow automatically to the authorization server, but keeping the same origin. To avoid CORS errors, we configured the BFF to use a status in the 2xx field. This requires the SPA to manually follow the redirection but gives it the opportunity to do it with window.location.href (request having a new origin).
Spring 的默认行为是对第一个请求使用 302 状态,这将使浏览器自动跟踪到授权服务器,但保持相同的来源。为了避免 CORS 错误,我们将 BFF 配置为使用 2xx 字段中的状态。这就要求 SPA 手动跟踪重定向,但它有机会使用 window.location.href(请求具有新的原点)。
Last, we can note how the post-logout URI is provided by SPAs using a X-POST-LOGOUT-SUCCESS-URI header with the logout request. spring-addons-starter-oidc uses the value of this header to insert a request parameter in the URI of the logout request from the authorization server.
最后,我们可以注意到 SPA 是如何在注销请求中使用 X-POST-LOGOUT-SUCCESS-URI 标头来提供注销后 URI 的。spring-addons-starter-oidc使用该header的值在来自授权服务器的注销请求的URI中插入一个请求参数。
7.5. Client Multi-Tenancy
7.5.客户端多租户
In the companion project, there is a single OAuth2 client registration with an authorization code. But what if we had more? This might happen for instance if we share a BFF across several frontends, some having distinct issuer or scope.
在配套项目中,只有一个带有授权代码的 OAuth2 客户端注册。但如果我们有更多呢?例如,如果我们在多个前端共享一个 BFF,其中一些前端有不同的发行方或范围,就可能出现这种情况。
The user should be prompted to choose only between OpenID Providers he can authenticate on, and in many cases, we can filter the login options.
我们应该提示用户只能在他可以进行身份验证的 OpenID 提供商中进行选择,在很多情况下,我们可以过滤登录选项。
Here are a few samples of situations where we can drastically shrink the number of possible choices, ideally to one so that the user isn’t prompted for a choice:
下面是一些例子,我们可以在这些情况下大幅减少可能的选择数量,最好是减少到一个,这样就不会提示用户做出选择:
- the SPA is configured with a specific option to use
- there are several reverse-proxies and each can set something like a header with the option to use
- some technical info, like the IP of the frontend device, can tell us that a user should be authorized here or there
In such situations, we have two choices:
在这种情况下,我们有两种选择:
- send the filtering criteria with the request to /login-options and filter in the BFF controller
- filter /login-options response inside the frontend
8. Back-Channel Logout
8.后方通道注销
What if, in a SSO configuration, a user with an opened session on our BFF logs-out using another OAuth2 client?
如果在 SSO 配置中,在我们的 BFF 上已打开会话的用户使用另一个 OAuth2 客户端注销,该怎么办?
In OIDC, the Back-Channel Logout specification was made for such scenarios: when declaring a client on an authorization server, we can register an URL to be called when a user logs-out.
在 OIDC 中,Back-Channel Logout 规范就是针对这种情况制定的:在授权服务器上声明客户端时,我们可以注册一个 URL,以便在用户注销时调用。
Because the BFF runs on a server, it can expose an endpoint to be notified with such log-out events. Since version 6.2, Spring Security supports Back-Channel Logout and spring-addons-starter-oidc exposes a flag to enable it.
由于 BFF 在服务器上运行,因此它可以暴露一个端点,以便收到此类注销事件的通知。自 6.2 版起,Spring Security 支持 Back-Channel Logout 和 spring-addons-starter-oidc 公开了一个启用该功能的标志。
Once the session ended on the BFF with Back-Channel Logout, the requests from the frontend to the resource server(s) won’t be authorized anymore (even before tokens expiration). So for a perfect user experience, when activating Back-Channel Logout on a BFF, we should probably also add a mechanism like WebSockets to notify frontends with user status changes.
一旦 BFF 上的会话通过 “后向通道注销”(Back-Channel Logout)结束,前端向资源服务器发出的请求将不再获得授权(甚至在令牌过期之前)。因此,为了获得完美的用户体验,在 BFF 上激活 “后向通道注销”(Back-Channel Logout)时,我们或许还应该添加类似 WebSockets 的机制,以便在用户状态发生变化时通知前端。
9. Why Using spring-addons-starter-oidc?
9.为什么使用 spring-addons-starter-oidc?
All along this article, we modified quite a few default behaviors of both spring-boot-starter-oauth2-client and spring-boot-starter-oauth2-resource-server:
在本文中,我们修改了 spring-boot-starter-oauth2-client 和 spring-boot-starter-oauth2-resource-server 的许多默认行为:
- change OAuth2 redirect URIs to point to a reverse-proxy instead of the internal OAuth2 client
- give SPAs the control of where the user is redirected after login / logout
- expose CSRF token in a cookie accessible to Javascript code running in a browser
- adapt to not exactly standard RP-Initiated Logout (Auth0 and Amazon Cognito for instance)
- add optional parameters to the authorization request (Auth0 audience or whatever)
- change the HTTP status of OAuth2 redirections so that SPAs can choose how to follow to Location header
- register two distinct SecurityFilterChain beans with respectively oauth2Login() (with session-based security and CSRF protection) and oauth2ResourceServer() (stateless, with token-based security) to secure different groups of resources
- define which endpoints are accessible to anonymous
- on resource servers, accept tokens issued by more than just one OpenID Provider
- add an audience validator to JWT decoder(s)
- map authorities from any claim(s) (and add prefix or force upper / lower case)
This usually requires quite some Java code and a deep knowledge of Spring Security. But here, we did it with just application properties and could use the guidance of our IDE auto-completion!
这通常需要一些 Java 代码和对 Spring Security 的深入了解。但在这里,我们只需使用应用程序属性,并在集成开发环境自动完成的指导下即可完成!
We should refer to the starter README on Github for a complete list of features, auto-configuration tuning and defaults overrides.
我们应该参考 Github 上的 starter README 以获取完整的功能、自动配置调整和默认设置覆盖列表。
10. Conclusion
10.结论
In this tutorial, we saw how to implement the OAuth2 Backend for Frontend pattern with Spring Cloud Gateway and spring-addons.
在本教程中,我们了解了如何使用 Spring Cloud Gateway 和 spring-addons 实现 OAuth2 Backend for Frontend 模式。
We also saw:
我们还看到
- why we should favor this solution over configuring SPAs as “public” OAuth2 clients
- introducing a BFF has very little impact on the SPA itself
- this pattern changes nothing at all on resource servers
- because we use a server-side OAuth2 client, we can get complete control on user session, even in SSO configurations, thanks to Back-Channel Logout
Last, we started to explore how convenient spring-addons-starter-oidc can be to configure, with just properties, what usually requires quite some Java configuration.
最后,我们开始探索 spring-addons-starter-oidc 是如何方便地仅使用属性就配置通常需要大量 Java 配置的内容。
As usual, all the code implementations are available over on GitHub.
与往常一样,所有代码的实现都可以访问 GitHub。