如何使用Spring Gateway和KeyCloak构建一个OIDC系统 2020年4月22号
写于 1 年前

这篇文章介绍一下,如何搭建一个基于 Spring Gateway 和 KeyCloak 的 OAuth2 资源保护系统,并使用 OIDC 作登录认证,这里只介绍思路和核心代码,供有一定基础的读者分享思路

首先我们需要了解这个小系统需要的组件,分别是

  • OAuth2 Server,这个我们选用的是 KeyCloak
  • Api Gateway & OAuth2 Client,使用 Spring Gateway 作为 OAuth2 的客户端
  • Resource Server (RS),就是在 Api Gateway 后面隐藏的资源服务

整体的架构可以看下图

图片出自 https://spring.io/blog/2019/08/16/securing-services-with-spring-cloud-gateway

资源认证流程是,客户端(浏览器)访问应用,此时没有认证状态,然后重定向到单点登录平台,也就是 KeyCloak,然后在 KeyCloak 上进行用户名密码认证(OIDC),成功后,KeyCloak 返回认证后的信息,然后客户端(Gateway)通过这些信息,再生成一个 Token,传到被保护的 Resource Resource Server 拿到这个 Token 再向 KeyCloak 进行权限的认证,如果认证都通过,则允许对资源进行操作。

OIDC

我们会使用 OIDC 作为用户登录认证方案

什么是 OIDC

看一下官方的介绍(http://openid.net/connect/)

OpenID Connect 1.0 is a simple identity layer on top of the OAuth 2.0 protocol. It allows Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server, as well as to obtain basic profile information about the End-User in an interoperable and REST-like manner.

OpenID Connect allows clients of all types, including Web-based, mobile, and JavaScript clients, to request and receive information about authenticated sessions and end-users. The specification suite is extensible, allowing participants to use optional features such as encryption of identity data, discovery of OpenID Providers, and session management, when it makes sense for them.

简单的来说,就是在 OAuth2 上多做了一个身份层,是一个基于 OAuth2 协议的身份认证标准协议。OIDC 使用 OAuth2 的授权服务器来为第三方客户端提供用户的身份认证,并把对应的身份认证信息传递给客户端,且可以适用于各种类型的客户端(比如服务端应用,移动APP,前端 SPA ),且完全兼容 OAuth2。

所以,我们只需要使用 Spring Security 的 OAuth2 模块进行配置即可

Api Gateway 搭建

第一步我们可以先进行 Api Gateway 的搭建,然后再回过头来设置 KeyCloak

我们假设这个 Demo 应用的名字为 orange

然后我们要搭建 api gateway,使用 https://start.spring.io 来生成项目,十分方便

我们勾选了 Gateway 和 OAuth2 Client 这两个依赖,然后下载下来,在 IDE 中打开,尝试运行一下,成功的话应该会在 8080 端口运行。

KeyCloak 搭建

搭建一个 KeyCloak demo 也是十分简单,可以直接从官方网站上下载 java 包,然后通过命令也是可以一键运行,不过这里还是推荐使用 Docker 来运行,十分方便和干净

这里给出 Docker KeyCloak 容器启动命名,我们把端口映射到 6180

docker run -p 6180:8080 -e KEYCLOAK_USER=admin -e KEYCLOAK_PASSWORD=123456 -d jboss/keycloak

这个 Admin 密码设成 123456,在正式一点的环境肯定也是不行的,不过我们是 Demo,就不需要管那么多了

然后创建一个 Realm

然后创建一个客户端 Client

这里注意需要设置 Client 的 Access Type 为 confident,这样第二个 Tab 页 Credentials 才能出来,我们需要在那里获取密钥

接着创建一个用户

最后,我们需要使用到 OpenID 的一些 URI,我们可以打开这个 URL 来查到全面的 URI,这些 URI 会 OAuth 客户端中用到

http://192.168.50.251:6180/auth/realms/orange/.well-known/openid-configuration

实际操作中,需要将的 ip 和 realm 名字替换掉

与 Gateway 集成

授权类型

然后我们需要在 Gateway 上集成 OAuth2,我们选择的授权类型是 Authorization Code Grant,虽然我们这个 Demo 的前端也是一个 SPA,可以直接用前端作为一个 OAuth2 客户端,然后选择 Implicit Grant 作为授权类型,但是我们还是选择了 Authorization Code Grant,这种授权类型的流程见下图

图片出自 An OAuth 2.0 introduction for beginners

为什么要选择 Authorization Code Grant 而不是 Implicit Grant,考虑的其实是一个安全性问题。

在使用隐式许可类型时需要对它严苛的局限性有所认识。首先,使用这种许可流程的客户端无法持有客户端密钥,因为无法对浏览器隐藏密钥。但由于这种许可流程只使用授权端点而不使用令牌端点,因此这个限制不会影响其功能,因为不要求客户端在授权端点上进行身份认证。然而,由于缺少对客户端进行身份认证的手段,确实会影响这种许可类型的安全等级,因此要谨慎使用。另外,隐式许可流程不可用于获取刷新令牌。因为浏览器内的应用具有短暂运行的特点,只会在被加载到浏览器的期间保持会话,所以刷新令牌在这里的作用非常有限。而且,和其他许可类型不同,这种许可类型会假设资源拥有者一直在场,必要时可以对客户端重新授权。在这种许可类型下,授权服务器仍然可以遵循首次使用时信任(TOFU)的原则,通过允许重新授权获得无缝的用户体验。

贾斯廷·里彻,安东尼奥·桑索. OAuth 2实战 (Chinese Edition) (Kindle 位置 1940-1945). Kindle 版本.

Maven 依赖

确定 Gateway 的依赖包,可以通过 https://start.spring.io 来生成

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-oauth2-client</artifactId>
</dependency>

Application.yaml DSL 配置

然后现在 application.yaml 中配置 OAuth2,只需要在 Provider 下面的 KeyCloak 中配置 issuer-uri 即可,这个地址可以在 keycloak 的 Admin 控制台中找到

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://192.168.50.251:6180/auth/realms/humpback_dev
        registration:
          keycloak:
            client-id: orange
            client-secret: a1246398-4c2f-46bd-b83d-9b1313f3378d

然后我们继续通过 yaml 文件配置 gateway 的配置,其中 http://localhost:8260 是我们接下来要创建 RS 服务。

spring:
  cloud:
    gateway:
      routes:
        - id: orange
          uri: http://localhost:8260
          predicates:
            - Path=/**
          filters:
#            - TokenRelay
            - TokenRelayWithTokenRefresh
            - RemoveRequestHeader=Cookie, Set-Cookie

注意上面被注释掉的 TokenRelay,这是一个 GatewayFilterFactory,不过这个 Filter 现在还有个比较大的问题,就是如果 Access Token 过期的话,还是会把请求发到 RS 那里,导致后续请求都是 401 的状态。

这个问题可以看 https://github.com/spring-cloud/spring-cloud-security/issues/175 这个 Github Issue

这个 Issue 下面有一个现成的解决方案,就是自定义一个 TokenRelay,实现如下:

import java.time.Duration;

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.client.registration.ReactiveClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.security.oauth2.client.web.server.ServerOAuth2AuthorizedClientRepository;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;

import reactor.core.publisher.Mono;

/**
 * Token Relay Gateway Filter with Token Refresh. This can be removed when issue {@see https://github.com/spring-cloud/spring-cloud-security/issues/175} is closed.
 * Implementierung in Anlehnung an {@link ServerOAuth2AuthorizedClientExchangeFilterFunction}
 */
@Component
public class TokenRelayWithTokenRefreshGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {

    private final ReactiveOAuth2AuthorizedClientManager authorizedClientManager;

    private static final Duration accessTokenExpiresSkew = Duration.ofSeconds(3);

    public TokenRelayWithTokenRefreshGatewayFilterFactory(ServerOAuth2AuthorizedClientRepository authorizedClientRepository,
                                                          ReactiveClientRegistrationRepository clientRegistrationRepository) {
        super(Object.class);
        this.authorizedClientManager = createDefaultAuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
    }

    private static ReactiveOAuth2AuthorizedClientManager createDefaultAuthorizedClientManager(
            ReactiveClientRegistrationRepository clientRegistrationRepository,
            ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {

        final ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
                ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
                        .authorizationCode()
                        .refreshToken(configurer -> configurer.clockSkew(accessTokenExpiresSkew))
                        .clientCredentials(configurer -> configurer.clockSkew(accessTokenExpiresSkew))
                        .password(configurer -> configurer.clockSkew(accessTokenExpiresSkew))
                        .build();
        final DefaultReactiveOAuth2AuthorizedClientManager authorizedClientManager = new DefaultReactiveOAuth2AuthorizedClientManager(
                clientRegistrationRepository, authorizedClientRepository);
        authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);

        return authorizedClientManager;
    }

    public GatewayFilter apply() {
        return apply((Object) null);
    }

    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> exchange.getPrincipal()
                // .log("token-relay-filter")
                .filter(principal -> principal instanceof OAuth2AuthenticationToken)
                .cast(OAuth2AuthenticationToken.class)
                .flatMap(this::authorizeClient)
                .map(OAuth2AuthorizedClient::getAccessToken)
                .map(token -> withBearerAuth(exchange, token))
                // TODO: adjustable behavior if empty
                .defaultIfEmpty(exchange).flatMap(chain::filter);
    }

    private ServerWebExchange withBearerAuth(ServerWebExchange exchange, OAuth2AccessToken accessToken) {
        return exchange.mutate().request(r -> r.headers(headers -> headers.setBearerAuth(accessToken.getTokenValue()))).build();
    }

    private Mono<OAuth2AuthorizedClient> authorizeClient(OAuth2AuthenticationToken oAuth2AuthenticationToken) {
        final String clientRegistrationId = oAuth2AuthenticationToken.getAuthorizedClientRegistrationId();
        return Mono.defer(() -> authorizedClientManager.authorize(createOAuth2AuthorizeRequest(clientRegistrationId, oAuth2AuthenticationToken)));
    }

    private OAuth2AuthorizeRequest createOAuth2AuthorizeRequest(String clientRegistrationId, Authentication principal) {
        return OAuth2AuthorizeRequest.withClientRegistrationId(clientRegistrationId).principal(principal).build();
    }
}

SecurityConfig

接下来就是 Spring Security 的设置了,具体可以看这个方法

这个是 WebFlux 的 Security 配置,跟 Spring MVC 的配置还是挺不一样的

@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                        ReactiveClientRegistrationRepository clientRegistrationRepository) {
    http.cors();

    http.oauth2Login().authenticationSuccessHandler(myServerAuthenticationSuccessHandler);

    http.logout(logout -> logout.logoutSuccessHandler(
            new OidcClientInitiatedServerLogoutSuccessHandler(clientRegistrationRepository)));
    http.logout().logoutUrl("/auth/logout");

    http.authorizeExchange().anyExchange().authenticated();

    http.headers().frameOptions().disable().xssProtection().disable();
    http.csrf().disable();
    http.httpBasic().disable();
    http.formLogin().disable();
    return http.build();
}

这个配置先设置了 OAuth2,包括 login,logout 等,然后把一些安全保护方法都去掉,不然等会在前端调用会十分麻烦

当然在生产环境还是要老老实实配置好 csrf,xss 这些参数。

处理重定向问题

最后我们还有一个问题需要考虑

因为 orange 这个应用的 OAuth2 客户端是在 Gateway,前端也有多种方法可以构造授权 Url 到 KeyCloak 进行登陆授权,但是我们选择一种比较简单的方法,那就是在页面中直接重定向到我们的 Gateway

假设我们前端运行在 http://localhost:3000 这个域名端口下,然后我们打开了 http://localhost:3000/orange_list 这页面,在这个页面进行登陆授权

然后我们 redirect 重定向到我们的 gateway 地址 http://localhost:8080,因为我们并没有授权过的 session,所以 gateway 会构造 URL 到 KeyCloak 中授权,这个 URL 大概长这样 http://192.168.50.251:6180/auth/realms/orange/protocol/openid-connect/auth?response_type=code&client_id=humpback-gateway&scope=openid%20address%20email%20microprofile-jwt%20offline_access%20phone%20profile%20roles%20user%20web-origins&state=VHp-YIiBsy9G-Kxm206bGHmm2gGRjF7D8Eu5rGpZVtM%3D&redirect_uri=http://localhost:8080/login/oauth2/code/keycloak&nonce=KzOiAXpzqrRXK67qzYdF5wK2pH_KGCUaBEHdz3pdnYI

可以看到这个 URL 中的 redirect_url 指的是 gateway 地址,因为在 keycloak 授权完成之后,keycloak 重定向到 Gateway,URL长这样👇:

http://192.168.50.251:6180/auth/realms/humpback_dev/protocol/openid-connect/auth?response_type=code&client_id=humpback-gateway&scope=openid%20address%20email%20microprofile-jwt%20offline_access%20phone%20profile%20roles%20user%20web-origins&state=54Hy3lHVo1l2AMGPUWRDvBQIoLru328qr3p-5ynpp20%3D&redirect_uri=http://localhost:8080/login/oauth2/code/keycloak&nonce=frqYBfSEjaScFuYLI3KF6TE1vNVwjht0minWWSbDzZ0

然后 gateway 会再次请求 OAuth Server(也就是 KeyCloak)获取 Access Token

当然,如果 Implicit Grant 的话,就不需要这么麻烦,直接用前端作为 OAuth 客户端即可,也不需要 Server 端处理 OAuth 流程了

这个时候,作为登录这个用例来看,已经是登录成功了,那么 Gateway 就需要重定向回我们的前端页面了,不过这个时候 Gateway 并不知道之前来的 http://localhost:3000/orange_list

当然也是做到 redirect 回之前的页面的,但是十分麻烦,思路是前端重定向到 Gateway 的时候带上 redirect_url,例如 http://localhost:8080/oauth/keycloak?redirect_url=http://localhost:3000/orange_list ,然后把 http://localhost:3000/orange_list 保存到 Session 中,登录完成后从 session 中拿到 http://localhost:3000/orange_list 进行重定向

这种方法在 server 端带来了额外的状态,而且这个逻辑会跟正常的 API 请求有冲突,所以 gateway 索性把这个重定向功能还给前端,让前端通过 session storage 或者其他方法来处理

最后,登录成功之后,通过自定义的 SuccessHandler 重定向回前端页面,下面是 ServerAuthenticationSuccessHandler 的实现代码

@Component
public class MyServerAuthenticationSuccessHandler implements ServerAuthenticationSuccessHandler {
    private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy();

    @Value("${application.frontend_url}")
    private String DEFAULT_LOGIN_SUCCESS_URL; // http://localhost:3000

    @Override
    public Mono<Void> onAuthenticationSuccess(WebFilterExchange webFilterExchange, Authentication authentication) {
        URI url = URI.create(DEFAULT_LOGIN_SUCCESS_URL);
        return this.redirectStrategy
                .sendRedirect(webFilterExchange.getExchange(), url);
    }
}

Resource Service 搭建

Resource Service 就是被保护的资源,当然也可以是其他类型的服务。

创建一个 kotlin 的 Orange data 类

data class Orange(var name: String, var queryUserId: String) {}

实现简单的 RestController,同样也是 Kotlin 代码

import org.springframework.http.MediaType
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*

@RestController
class OrangeController() {
    @RequestMapping(value = ["/oranges"], method = [RequestMethod.GET], produces = [MediaType.APPLICATION_JSON_VALUE])
    fun querySpaces(@AuthenticationPrincipal() principal: Jwt): List<Orange> {
        val orange1 = Orange("Orange1", principal.claims.get("userId") as String)
        val orange2 = Orange("Orange2", principal.claims.get("userId") as String)
        return listOf(orange1, orange2)
    }
}

然后我们配置 Spring Security 的 resource server

我们需要用到 spring-boot-starter-oauth2-resource-server 这个包

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

然后在 application.yaml 中进行配置 Jwt Resource Server

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://[keycloak_host]/auth/realms/orange
          jwk-set-uri: http://[keycloak_host]/auth/realms/orange/protocol/openid-connect/certs
@Configuration
class SecurityConfig : WebSecurityConfigurerAdapter() {

    @Throws(Exception::class)
    override fun configure(http: HttpSecurity) {
        // TODO: check role
        // https://stackoverflow.com/questions/47069345/how-to-use-spring-security-remotetokenservice-with-keycloak
        // https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide
        http
                .authorizeRequests()
                .antMatchers("/**")
                .hasAuthority("SCOPE_openid")
                // .antMatchers("/**")
                // .hasRole("USER")
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable().xssProtection().disable()
                .and()
                .formLogin().disable()
                .oauth2ResourceServer()
                .jwt()
    }
}

上面的代码中,还有一个问题还没有解决,就是 hasRole 这个方法一直不生效,也上网查阅过很多资料,包括在 keyCloak 中对 Role 进行 Mapping,但是还是不生效,在 Spring Security 的源码中看了一下,好像在 Web Reactive 的代码中没有找到 hasRole 的相关代码,也就暂时放下了,读者在参考这篇文章实现的时候要多注意一下。

最后我们在 Postman 中进行最后的测试,Get 请求 http://localhost:8080/oranges 来获取两个 Orange 资源

在 Postman 的 Authorization 选项卡中获取 OAuth2 的 Access Token,Spring Security 会自动 Decode 去再向 KeyCloak 获取一遍 Access Token,然后创建 Session

当然 Postman 的步骤比较麻烦,也可以直接通过浏览器打开 http://localhost:8080, 浏览器会重定向到 KeyCloak 进行认证,不过认证成功后会跳转到前端 http://localhost:3000,这时候再访问 http://localhost:8080/oranges 即可

可以看到我们已经获取到了两个 Orange JSON 对象

前端

前端的集成方案就比较简单,不论是服务端模版生成的前端,还是单页面 SPA 应用程序,只要没有登录,就直接重定向到 Gateway 的地址 http://localhost:8080/oauth2/authorization/keycloak,然后 Gateway 处理完 KeyCloak 的登录流程后,就会自动跳转回前端页面。

如前文所说,这种方案的 Url 重定向需要由前端来处理,假如你在 http://localhost:3000/welcome 页面,如果登录完之后,最好还是跳转回 /welcome,但是 Gateway 重定向到 http://localhost:3000

而判断用户是否已经登录,已经重定向到 Gateway 进行登录这一个动作也是前端页面完成的,那么可以在前端重定向之前,把当前的 Url 记录到 SessionStorage 中,然后从 Gateway 登录回来之后再读取 SessionStorage 中的内容,进行重定向。

一些总结

最后,总的来说,现在 Spring 的 WebFlux 技术栈虽然说已经发展挺久的,但是相对来说资料还是比较少,而且看上起问题还不少,特别是 WebFlux + Spring Security OAuth 这种技术栈,所以没有特殊要求还是选择 Zuul 作为 Gateway 比较省心。

其次,如果后端对登录这一块没有更强的安全要求,或者对登录态有控制要求的话,前端可以直接使用 Implicit Grant 这种授权模式来获取 Access Token,Gateway 就只需要做转发即可,也能省下不少工作量。

参考资料

Copyright belongs to Fangwei.Chen
2015 - 2021 @ 陳放為 Starfish 项目强力驱动