微服务实战:基于Spring Cloud Gateway + AWS Cognito 的BFF案例

背景

微服务架构的分布式特性可以带来很多好处,但是单个微服务并不能独立对外提供服务,一个微服务群组需要作为一个整体对外提供完整的服务体验,而如何实现支撑整体的通用功能就需要好好考虑一番了。

就我司的需求来说,我们需要实现的通用功能包括路由(Routing)、认证(Authorization)、鉴权(Authentication),以及后端API的组合(API Composition)。我们计划在同一个地方即API Gateway,部署这一组功能,而不是在每个微服务都重复部署。

ℹ️ 在[微服务/API时代的前端开发] BFF入门--5个实用的BFF使用案例一文中,介绍了5个典型的BFF应用场景,API Gateway就是其中一个。

OIDC是一个适合微服务的认证方式,由于我们的服务主要构筑在AWS上,所以计划采用AWS Cognito作为ID Provider(或称为Authorization Server)。

关于OAuth和OIDC

OAuth和OIDC有什么区别呢?简单的说,OAuth是个框架(Framework),而Open ID Connect是一个协议。在OAuth2.0框架中,定义了认证过程中的各种角色以及5种认证流程。

角色包括:

  • 资源所有者(Resource Owner),能够授予对受保护资源的访问权限的实体。当资源所有者是一个人时,它被称为最终用户。

  • 资源服务器(Resource Server),托管受保护资源的服务器,能够通过访问令牌,对受保护资源的请求做出响应。

  • 客户端(Client),代表资源所有者并经其授权向受保护资源发出请求的应用程序。 术语“客户端”并不意味着任何特定的实现特征(例如,应用程序是否在服务器、桌面或其他设备上执行)。为了方便区分,也经常被称为OAuth客户端。

  • 认证服务器(Authorization Server),该服务器在成功验证资源所有者并获得授权后向客户端颁发访问令牌。

  • 用户代理(User Agent), 资源所有者的用户代理是客户端与资源所有者交互的中介 (通常是 Web 浏览器)。

5种认证流程分别是:

  1. 授权码模式(Authorization Code Grant)
  2. 简单模式(Implicit Grant)
  3. 密码模式(Resource Owner Password Credentials Grant)
  4. 客户端模式(Client Credentials Grant)
  5. 扩展模式(Extension Grants)

以最常见的授权码模式为例,具体认证流程如下:

     +----------+
     | Resource |
     |   Owner  |
     |          |
     +----------+
          ^
          |
         (B)
     +----|-----+          Client Identifier      +---------------+
     |         -+----(A)-- & Redirection URI ---->|               |
     |  User-   |                                 | Authorization |
     |  Agent  -+----(B)-- User authenticates --->|     Server    |
     |          |                                 |               |
     |         -+----(C)-- Authorization Code ---<|               |
     +-|----|---+                                 +---------------+
       |    |                                         ^      v
      (A)  (C)                                        |      |
       |    |                                         |      |
       ^    v                                         |      |
     +---------+                                      |      |
     |         |>---(D)-- Authorization Code ---------'      |
     |  Client |          & Redirection URI                  |
     |         |                                             |
     |         |<---(E)----- Access Token -------------------'
     +---------+       (w/ Optional Refresh Token)
复制代码

OAuth 2.0 的设计仅用于认证,用于将数据和功能从一个应用程序授予另一个应用程序的访问权限。 OpenID Connect (OIDC) 位于 OAuth 2.0 之上,它添加了用户登录(Login)和个人资料信息(Profile)。当认证服务器支持 OIDC 时,它有时被称为身份提供者(ID Provider),因为它向客户端提供有关资源所有者的信息。

OpenID Connect 支持可以跨多个应用程序使用一次登录的场景,也称为单点登录 (SSO)。 例如,应用程序可以通过社交网络服务(如 Facebook 或 Twitter)支持 SSO,以便用户可以选择使用他们既有的登录信息。

OpenID Connect 流程看起来与 OAuth 相同。 唯一的区别是,在初始请求中,使用了特定的SCOPE:openid,并且在最终交换时,客户端同时收到一个 访问令牌(Access Token) 和一个 ID 令牌(ID Token)

关于Cognito

AWS Cognito可以为 Web 和移动应用程序添加用户注册、登录和访问控制功能。可以将用户规模扩展到数百万,并支持通过 SAML 2.0 和 OpenID Connect,使用社交身份提供商(如 Apple、Facebook、Google 和 Amazon)以及企业身份提供商进行登录。简单的说Cognito就是AWS提供的ID Provider,它可以很方便的和ALB、AWS API Gateway、CloudFront进行集成。

AWS Cognito的文档中,对OAuth授权码模式的支持如下图所示:

image.png

方案

如前所述,我们采用AWS Cognito作为认证服务器,至于OAuth客户端(即API Gateway)的实现,我们采用的是Spring Cloud GatewaySpring Security的组合。

认证流程

image.png

上图中的微服务将扮演资源服务器的角色,用来展示如何使用 Spring Security 5.2+ 来确保服务的安全。调用它的任何用户(机器)负责提供有效的 访问令牌(Access Token),在我们的例子中使用的是 JWT 格式的不记名令牌。除了典型的 访问令牌JWT 还允许传输与 AuthN/AuthZ 相关的声明,例如用户名或角色/权限。这样微服务就无需为此类信息不断向原始身份提供者发出请求。

API 网关将使用基于会话(Session)的登录,用于展示 OAuth 2 的授权码模式。另外,这里会展示如何根据资源服务器的要求使用适当的 OAuth 令牌来控制 HTTP 访问请求。关键是从 Cognito 获取的访问/刷新令牌永远不会暴露给浏览器。Spring Cloud Gateway支持 WebFlux 模式和传统的MVC模式,由于所有的后端请求都需要经过API网关,对高吞吐量和低延迟有较高的要求,所以我们选择了非阻塞式的WebFlux模式。

相关时序图如下所示:

image.png

鉴权

我们计划在后端的一个账户服务中管理用户,用户所属的角色,以及角色对应的URL权限。Spring Security虽然支持如下所示的鉴权方式,但是只能硬编码,不能满足我们的需求。

http.authorizeExchange()
    .pathMatchers(HttpMethod.GET, "/api/account/**")
    .hasRole("account.access");
复制代码

我们需要考虑如何让API网关从账户服务中获取实时的权限配置,实现动态鉴权。我们将实现一个自定义的ReactiveAuthorizationManager,作为鉴权过滤器,并通过如下方式添加到配置中。在自定义的过滤器中,定时从后端账户服务获取最新的权限配置,并刷新到API网关。

http.authorizeExchange()
    .pathMatchers("/api/**")
    .access(authorizationManager);
复制代码

对于用户的角色,我们计划使用Cognito的群组(Group)功能,以群组作为角色进行权限控制。原因是我们可以很方便的利用Cognito维护用户和群组的关系,同时在令牌中也很容易获取用户所在群组的信息。关键是我们需要手动将Cognito群组信息转换为Spring Security能够识别的角色/权限。我们需要自定义一个ReactiveOAuth2UserService的Bean,实现群组到角色的转换。之后就可以使用GrantedAuthority来校验用户的权限了。

API组合

我们希望在API网关实现API组合的功能,将后端多个微服务的API组合成一个在功能上相对完整的API,对外提供服务。例如“显示页面时,从日记列表API获取日记的列表信息,同时从评论列表API获取多个评论”。在这种情况下,需要同时请求多个后端API。而在本文的代码示例中,我将把订单API和库存API组合在一起,返回给前端。

很遗憾,Spring Cloud Gateway官方并没有提供这个功能,虽然在社区的讨论中有很多这样的呼声(参考 Have Routes Support Multiple URIs?)。不过社区成员 spencergibb 建议使用Spring Cloud Gateway提供的 ProxyExchange 对象来实现这种功能。我们的示例中也采用了同样的实现方式。

 @PostMapping("/proxy")
 public Mono<ResponseEntity<Foo>> proxy(ProxyExchange<Foo> proxy) throws Exception {
    return proxy.uri("http://localhost:9000/foos/") //
        .post(response -> ResponseEntity.status(response.getStatusCode()) //
            .headers(response.getHeaders()) //
            .header("X-Custom", "MyCustomHeader") //
            .body(response.getBody()) //
        );
 }
复制代码

实现

AWS Cognito

  • 创建AWS实例

首先使用Terraform构筑AWS Cognito实例。

cd ./aws-cognito
terraform init
terraform apply
复制代码

Client ID, user pool ID 会被打印在窗口中,后续会使用到。

  • 获取 client secret

出于安全方面的考虑,Terrafrom不允许将client secret 打印在窗口中,所以需要用以下命令获取。

aws cognito-idp describe-user-pool-client --user-pool-id <your-user-pool-id> \
--client-id <your-client-id>
复制代码
  • 注册用户并确认

注册用户[email protected]

aws cognito-idp sign-up --region <your-aws-region> \
--client-id <your-client-id> --username [email protected] \
--password password123

aws cognito-idp admin-confirm-sign-up --region <your-aws-region> \
--user-pool-id <your-user-pool-id> \
--username [email protected]
复制代码
  • 加入群组

将测试用户加入account.access群组。

aws cognito-idp admin-add-user-to-group --user-pool-id <your-user-pool-id> \
--username [email protected] \
--group-name account.access
复制代码

API网关

  • 依赖

在build.gradle中添加如下依赖:

    implementation  "org.springframework.boot:spring-boot-starter-webflux"
    implementation  "org.springframework.boot:spring-boot-starter-oauth2-client"
    implementation  "org.springframework.security:spring-security-oauth2-jose"
    implementation  "org.springframework.security:spring-security-config"
    implementation  "org.springframework.cloud:spring-cloud-starter-gateway"
    implementation  "org.springframework.cloud:spring-cloud-gateway-webflux"
复制代码
  • 配置

在application.yaml中添加OAuth2的配置。这里需要将上一步中获得的Cognito的client-id, client-secret等信息配置到该文件中。

spring:
  security:
    oauth2:
      client:
        provider:
          cognito:
            issuerUri: https://cognito-idp.<region-id>.amazonaws.com/<region-id>_<user-pool-id>
            user-name-attribute: username
        registration:
          cognito:
            client-id: <client-id>
            client-secret: <client-secret>
            client-name: scg-cognito-sample-user-pool
            provider: cognito
            scope: openid
            redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
            authorization-grant-type: authorization_code

复制代码

另外,还需要添加路由相关的配置,这里是用来TokenRelayFilter,用于通过Http Header将Token传递给后端微服务。

spring:
  cloud:
    gateway:
      default-filters:
        - TokenRelay
      routes:
        - id: account_service_route
          uri: http://localhost:8082
          predicates:
            - Path=/api/account/**
        - id: order_service_route
          uri: http://localhost:8083
          predicates:
            - Path=/api/order/**
        - id: storage_service_route
          uri: http://localhost:8084
          predicates:
            - Path=/api/storage/**
复制代码
  • 代码

SecurityWebFilterChain中设置自定义的鉴权过滤器MyAuthorizationManager

@Bean
SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
                                                 ReactiveClientRegistrationRepository clientRegistrationRepository,
                                                 MyAuthorizationManager authorizationManager) {
    // Authenticate through configured OpenID Provider
    http.oauth2Login(withDefaults());

    // Also logout at the OpenID Connect provider
    http.logout(logout -> logout.logoutSuccessHandler(new OidcClientInitiatedServerLogoutSuccessHandler(
        clientRegistrationRepository)));

    // add authorization filters
    http.authorizeExchange()
        .pathMatchers("/api/**")
        .access(authorizationManager);

    // Require authentication for all requests
    http.authorizeExchange().anyExchange().authenticated();

    // Disable CSRF in the gateway to prevent conflicts with proxied service CSRF
    http.csrf().disable();

    return http.build();
}
复制代码

使用ReactiveOAuth2UserService,将Cognito群组信息转换为Spring Security能够识别的角色/权限。

@Bean
public ReactiveOAuth2UserService<OidcUserRequest, OidcUser> oidcUserService() {
    final OidcReactiveOAuth2UserService delegate = new OidcReactiveOAuth2UserService();

    return (userRequest) -> {
        // Delegate to the default implementation for loading a user
        return delegate.loadUser(userRequest)
            .map(user -> {
                Set<GrantedAuthority> mappedAuthorities = new HashSet<>();

                user.getAuthorities().forEach(authority -> {
                    if (authority instanceof OidcUserAuthority) {
                        OidcUserAuthority oidcUserAuthority = (OidcUserAuthority) authority;
                        // get cognito groups from token
                        JSONArray groups = oidcUserAuthority.getIdToken().getClaim("cognito:groups");
                        if (Objects.nonNull(groups)) {
                            groups.stream()
                                // map group to role
                                .map(roleName -> "ROLE_" + roleName)
                                .map(SimpleGrantedAuthority::new)
                                .forEach(mappedAuthorities::add);
                        }
                    }
                });

                return new DefaultOidcUser(mappedAuthorities, user.getIdToken(), user.getUserInfo());
            });
    };
}
复制代码

自定义的鉴权过滤器MyAuthorizationManager代码如下:

@Component
public class MyAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {

    // ...
    
    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> authenticationMono,
                                             AuthorizationContext authorizationContext) {
        ServerHttpRequest request = authorizationContext.getExchange().getRequest();
        // pass OPTIONS request of CORS
        if (request.getMethod() == HttpMethod.OPTIONS) {
            return Mono.just(new AuthorizationDecision(true));
        }

        // authenticate
        String url = request.getURI().getPath();
        return authenticationMono
                .map(auth -> new AuthorizationDecision(urlAuthorityChecker.check(auth.getAuthorities(), url)))
                .defaultIfEmpty(new AuthorizationDecision(false));
    }

}
复制代码

具体的权限校验逻辑在UrlAuthorityChecker中实现。

@Component
@EnableScheduling
public class UrlAuthorityChecker {

// ...

    /**
     * map for permission and url
     */
    private Map<String, String> permissionUrlMap;


    /**
     * check granted authorities of user
     */
    public boolean check(Collection<? extends GrantedAuthority> authorities, String requestedUrl) {
        // loop all the authorities of user to find out if the url is authenticated
        for (GrantedAuthority authority : authorities) {
            String authorizationUrl = permissionUrlMap.get(authority.getAuthority());
            if (authorizationUrl != null && antPathMatcher.match(authorizationUrl, requestedUrl)) {
                return true;
            }
        }
        return false;
    }

    /**
     * create newPermissionUrlMap and replace the old one.
     * add scheduled task to refresh the map every certain ms.
     */
    @Scheduled(initialDelay = 0, fixedDelay = REFRESH_DELAY)
    private void updatePermissionNameAuthorizationUrlMap() {
        Map<String, String> permissions = permissionManager.getPermissions();
        Map<String, String> newPermissionUrlMap = new ConcurrentHashMap<>();
        permissions.forEach((k,v) -> newPermissionUrlMap.put("ROLE_" + k, v));
        permissionUrlMap = newPermissionUrlMap;
    }
}
复制代码

关于API组合功能,我们使用ProxyExchange实现了订单API和库存API的整合。

@GetMapping("/composition/{id}")
public Mono<? extends ResponseEntity<?>> proxy(@PathVariable Integer id, ProxyExchange<?> proxy) throws Exception {

    return proxy.uri("http://localhost:8083/api/order/get/" + id)
        .get(resp -> ResponseEntity.status(resp.getStatusCode())
            .body(resp.getBody()))
        .flatMap(re1 -> proxy.uri("http://localhost:8084/api/storage/get/" + id)
            .get(resp -> ResponseEntity.status(resp.getStatusCode())
                .body(Map.of("order",re1.getBody(),"storage",resp.getBody()))));
}
复制代码

资管服务器(微服务)

  • 依赖

在build.gradle中添加如下依赖:

    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
复制代码
  • 配置

application.yaml中,增加OAuth2的资源服务器配置。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://cognito-idp.<region-id>.amazonaws.com/<region-id>_<user-pool-id>
复制代码
  • 代码

SecurityConfig中配置资源服务器,启用 JWT令牌 校验,并采取API网关相同的措施,将令牌中的Cognito群组信息装换为角色/权限。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

   @Override
   protected void configure(HttpSecurity http) throws Exception {
      // Validate tokens through configured OpenID Provider
      http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());
      // Permit request for permissions
      http.authorizeRequests().antMatchers(HttpMethod.GET, "/api/account/permissions").permitAll();
      // Require authentication for the other requests
      http.authorizeRequests().anyRequest().authenticated();
   }

   private JwtAuthenticationConverter jwtAuthenticationConverter() {
      JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
      // Convert realm_access.roles claims to granted authorities, for use in access decisions
      jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
         jwt -> {
            Object groups = jwt.getClaims().get("cognito:groups");
            if (Objects.nonNull(groups)) {
               return ((List<?>) groups).stream()
                  .map(roleName -> "ROLE_" + roleName)
                  .map(SimpleGrantedAuthority::new)
                  .collect(Collectors.toList());
            } else {
               return new ArrayList<>();
            }
         });
      return jwtAuthenticationConverter;
   }
}
复制代码

完整的示例代码可以从我的Github上下载。

其他方案

image.png

除了采用OAuth的授权码模式,还可以选择简单模式,将登录以及令牌的获取交给前端,API Gateway只作为资源服务器,而后端的微服务则只对API Gateway开放访问。前端是本地应用(Native Application)的情况下,这样做是十分可取的。本地应用是指安装在设备上的客户端软件,区别于基于浏览器的SPA,因为基于浏览器的应用代码是公开的,令牌容易泄露,所以不适用简单模式。

相关文章

参考链接

猜你喜欢

转载自juejin.im/post/7036245199453945887