本文棣属于 Spring API 服务开发系列。

  1. Spring Boot API 服务开发指南
  2. Spring Boot API 服务测试指南
  3. Spring Cloud 微服务开发指南

Spring Cloud 简介

Spring Cloud 是一系列框架的集合,它利用 Spring Boot 来简化分布式系统中各种基础组件的开发,包括服务注册与发现、配置中心、消息总线、负载均衡、断路器、数据监控等。Spring Cloud 并没有重复制造轮子,它只是将各家公司开发的比较成熟且经过实际考验的各种框架组合起来,按照 Spring Boot 风格进行封装以屏蔽掉复杂的配置和实现,最终呈现给开发者一套简单易用的分布式系统开发工具包。微服务是可以独立开发和部署的服务单元,采用微服务架构的应用本质上是一个分布式系统。单体应用只有一个服务,系统中的各个功能模块通过进程内的函数或方法调用进行通信,高效可靠。升级到微服务架构之后,各个功能模块独立成为了运行在不同进程甚至不同机器上的服务,只能通过调用网络服务来进行通信。随之就会出现服务地址管理、配置管理、服务稳定性等问题,Spring Cloud 提供了各种组件来分别解决不同的问题。

Spring Cloud 包含非常多的组件,其中有些由第三方公司开发和维护,下面是一些常用的。

  • 服务注册与发现 Spring Cloud Consul、Spring Cloud Zookeeper、Spring Cloud Alibaba Nacos、Spring Cloud Netflix Eureka
  • 服务通信协议 REST、Spring Cloud Alibaba Dubbo
  • 服务调用客户端 Spring Cloud OpenFeign、Spring Cloud Netflix Feign
  • 服务调用负载均衡 Spring Cloud LoadBalancer、Spring Cloud Netflix Ribbon
  • 服务降级 Spring Cloud Circuit Breaker、Spring Cloud Alibaba Sentinel、Spring Cloud Netflix Hystrix
  • 服务追踪 Spring Cloud Sleuth
  • 服务网关 Spring Cloud Gateway、Spring Cloud Netflix Zuul
  • 安全保护 Spring Cloud Security
  • 配置管理 Spring Cloud Config、Spring Cloud Alibaba Nacos、Spring Cloud Netflix Archaius
  • 消息总线 Spring Cloud Bus
  • 数据流 Spring Cloud Data Flow
  • 事件驱动服务 Spring Cloud Stream
  • 任务执行 Spring Cloud Task
  • 分布式事务 Spring Cloud Alibaba Seata
  • 云存储 Spring Cloud Alibaba OSS

其中 Spring Cloud Netflix 由 Netflix 开发,使用比较广泛,不过目前已进入维护状态,不再更新,不建议新项目使用。Spring Cloud Alibaba 由阿里巴巴开发,2019.7 月从 Spring 孵化器毕业,2019.10 月正式挂牌于 Spring Cloud 官方平台,对 Dubbo、Nacos、RocketMQ 等阿里巴巴开源组件比较熟悉的可以使用。除了这两家大公司提供的组件,Spring Cloud 官方也不断在开发和集成各种组件,目前已经可以满足大多数场景的需求。如果对其它公司提供的组件的稳定性和长久性存疑,完全可以全部使用由官方开发和维护的组件。本文开发项目所用组件均为官方提供,包括 Spring Cloud Consul、Spring Cloud LoadBalancer、Spring Cloud Circuit Breaker、Spring Cloud Gateway、Spring Cloud Security、Spring Cloud Config 等。

系统架构

接下来我们将把之前采用 Spring Boot 框架开发的一个 API 服务 Spring Boot in Practice 改造成为一个 Spring Cloud 微服务应用,最终代码可从 GitHub 获取 Spring Cloud in Practice。采用微服务架构的应用有三个问题需要优先解决,一是众多的微服务如何安全可控地对外暴露,二是认证状态如何在各个微服务之间共享,三是如何保障各个微服务之间的调用可靠而稳定。下面依次来看一下我们的选择方案。

服务暴露

对于服务暴露,微服务系统里的各个微服务一般不直接暴露给外面,而是通过一个网关来统一对外提供服务。这样可以在一个地方来统一实施安全、限流、监控等控制方案,而不是把它们分散在系统各处。这里我们选择了 Spring Cloud Gateway 来作为我们的网关,它基于 Spring、Project Reactor 和 Spring Boot 来构建,跟 Spring 生态能够很好地融合。除了其强大的路由功能,由于底层采用了事件驱动模型(使用 Netty 作为应用容器),因此性能上也非常地高效。

统一认证

对于认证,我们使用 ORY/Hydra 这个开源的 OAuth 2.0 认证服务器来进行认证授权,同时结合网关的安全配置来实现单点登录。具体的认证流程如下(图片来源于网络)。

OAuth 2.0 微服务认证流程

首先需要在网关统一配置各微服务的安全策略。当网关发现某个客户端的请求不满足认证和权限要求时,将重定向客户端浏览器到认证服务器(图中的单点登录服务器),然后认证服务器依次让用户完成登录和授权操作。用户登录和授权完成后,认证服务器将重定向客户端浏览器到网关的授权回调页。此时网关会先从回调地址的 Query 参数里获取到认证服务器传回的授权码(Authorization Code),然后使用该授权码到认证服务器换取访问 Token(Access Token),最后将该 Token 下发给客户端。后续请求客户端就会带着该 Token 来请求资源服务器(微服务),网关验证通过就会放行。

服务调用

微服务架构里除了网关会调用各个下游微服务,各微服务之间也会相互调用。服务调用的前提是必须先知道被调用的服务有哪些可用的节点,这里就需要服务注册与发现,我们使用 Spring Cloud Consul 来实现这个功能。Spring Cloud Consul 会自动将当前服务注册到 Consul 服务器里,以及自动从 Consul 服务器获取每个服务的可用节点信息,并且在服务节点状态发生变化时自动更新这些信息。得益于 Spring Cloud Consul 的良好封装,我们只需做极少配置和简单注解即可达成此目的。

部署架构

下面是系统的整体部署架构:

spring-cloud-micro-service-architecture

其中需要关注的点如下:

  1. 每个微服务的后端数据库应互相隔离,每个微服务应尽量避免直接访问其它微服务的数据库,而是通过 API 来访问,这样可以降低微服务之间的耦合。如果两个微服务之间调用 API 过多,那么请确认微服务划分是否合理,必要的话可以合并成一个。
  2. 网关的主要作用是将外部请求路由到对应的后端微服务,同时还会承担安全、限流、监控等各微服务的横向共性需求。
  3. 各微服务之间可以相互调用(图中省略了),但不应过多,耦合较紧的功能模块应划分到一个微服务中。不要为了拆分而拆分,需避免出现分布式单体。
  4. 网关在转发请求给后端微服务时,会一并传递客户端请求里的访问 Token,以便后端微服务可以获取到认证和授权信息。为了验证 Token 的合法性,网关和各个微服务需要向认证服务器请求验证密钥。
  5. Hydra 服务提供 OAuth 2.0 认证服务,使用同名开源软件自行搭建。
  6. Consul 服务作为分布式系统的协调中心,负责在各节点之间同步服务和配置信息,使用同名开源软件搭建。

项目结构

为了方便代码编写,本项目采用了 Maven 多模块目录结构,这样可以在一个工程里编写所有微服务的代码。实际项目开发中,为了更好地隔离各个微服务的代码,可以将每个微服务独立成为一个项目。各模块共用的配置,比如基础依赖、依赖包版本号等,统一放在了根模块的 POM 文件中,以避免重复。此外还将各微服务中公用的代码,比如异常处理、各微服务接口封装等,抽取到了 common 模块中。具体的目录结构如下。

.
├── common # 公共库
│   ├── pom.xml
│   └── src
├── docker-compose.yml # Docker Compose 配置文件
├── file # 文件服务
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
├── gateway # 网关
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
├── pom.xml
├── post # 动态服务
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
├── stat # 统计服务
│   ├── Dockerfile
│   ├── pom.xml
│   └── src
└── user # 用户服务
    ├── Dockerfile
    ├── pom.xml
    └── src

开发微服务

每个微服务的长相都比较类似,下面以动态服务为例。由于是升级改造,这里我们只关注跟微服务架构有关的部分,有关 Spring Boot API 服务开发,可以参考 Spring Boot API 服务开发指南

Maven POM 配置

<?xml version="1.0" ?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>net.jaggerwang</groupId>
        <artifactId>spring-cloud-in-practice</artifactId>
        <version>1.0.0-SNAPSHOT</version>
    </parent>

    <groupId>net.jaggerwang</groupId>
    <artifactId>spring-cloud-in-practice-post</artifactId>
    <version>1.0.0-SNAPSHOT</version>
    <name>spring-cloud-in-practice-post</name>
    <description>Spring cloud in practice post</description>

    <dependencies>
        <dependency>
            <groupId>net.jaggerwang</groupId>
            <artifactId>spring-cloud-in-practice-common</artifactId>
            <version>${scip-common.version}</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!--...-->

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-consul-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-consul-config</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
        </dependency>

        <!--...-->
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>

            <!--...-->
        </plugins>
    </build>
</project>

上面的配置里,首先设置父模块为父目录模块 spring-cloud-in-practice,里面包含了 Spring Boot 和 Spring Cloud 的 BOM(物料清单),以及其它本项目中要用到的依赖包的版本号,其目的是为了统一维护各个子模块里的依赖包版本。接下来依次引入了本模块的依赖包,具体如下。

  1. 公共库 spring-cloud-in-practice-common
  2. Spring Boot 应用相关 spring-boot-starter-web
  3. Spring Security OAuth 2.0 认证相关 spring-security-oauth2-resource-serverspring-security-oauth2-jose
  4. Spring Cloud Consul 服务管理和配置管理 spring-cloud-starter-consul-discoveryspring-cloud-starter-consul-configspring-cloud-starter-circuitbreaker-reactor-resilience4j

断路器配置

package net.jaggerwang.scip.post.api.config;

...

@Configuration
public class CommonConfig {
    ...

    @Bean
    public Customizer<ReactiveResilience4JCircuitBreakerFactory> cbFactoryCustomizer() {
        return factory -> factory.configureDefault(id -> {
            var timeout = Duration.ofSeconds(2);
            if (id.equals("fast")) {
                timeout = Duration.ofSeconds(1);
            } else if (id.equals("slow")) {
                timeout = Duration.ofSeconds(5);
            }

            return new Resilience4JConfigBuilder(id)
                    .circuitBreakerConfig(CircuitBreakerConfig.ofDefaults())
                    .timeLimiterConfig(TimeLimiterConfig.custom()
                            .timeoutDuration(timeout)
                            .build())
                    .build();
        });
    }
}

上面配置了三种超时级别,分别是默认的 2s,fast 的 1s,以及 slow 的 5s。在调用服务时可以依据当前 API 来决定使用哪种级别的断路器。

服务配置

package net.jaggerwang.scip.post.api.config;

...

@Configuration
public class ServiceConfig {
    @Bean
    @LoadBalanced
    public RestTemplate userServiceRestTemplate(RestTemplateBuilder builder) {
        return builder.rootUri("lb://spring-cloud-in-practice-user").build();
    }

    @Bean
    public UserService userService(@Qualifier("userServiceRestTemplate") RestTemplate restTemplate,
                                   CircuitBreakerFactory cbFactory,
                                   ObjectMapper objectMapper) {
        return new UserSyncService(restTemplate, cbFactory, objectMapper);
    }
}

动态服务会调用用户服务的 API 来获取用户关注关系,以便计算关注的动态,因此这里定义了 UserService Bean。UserService 只是一个接口,包含同步和异步两个版本,其实现分别为 UserSyncServiceUserAsyncService。它们均放在 common 模块里,以便在各个微服务之间共享。传递给 UserSyncServicerestTemplate 对象来自于自定义的 RestTemplate Bean,我们为其指定了 rootUri 并通过 @LoadBalanced 注解为其添加了负载均衡能力,注意 rootUri 的协议使用的是 lb(Load Balance 的缩写)。此外还给 UserSyncService 传递了 cbFactory 对象,Spring Cloud 会自动为我们创建此对象,这里会用到前面定义的断路器配置。

安全配置

package net.jaggerwang.scip.post.api.config;

...

@Configuration
public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .authorizeRequests()
                .antMatchers("/actuator/**")
                .permitAll()
                .anyRequest()
                .hasAuthority("SCOPE_post")
                .and()
                .oauth2ResourceServer()
                .jwt();
    }
}

虽然我们会在网关进行了统一的安全检查,但并不妨碍我们在微服务里再进一步执行更细致的安全检查,网关会把认证授权后获得的 Token 传递给后端微服务。通过配置 oauth2ResourceServer()jwt(),告知 Spring Security 把本微服务当做一个 OAuth 2.0 资源服务器来保护,并且使用 JWT(Json Web Token)作为 Token 格式。

应用配置

由于我们启用了 Spring Cloud Consul 的配置管理功能,因此需要新增一个 bootstrap.yml 配置文件,以便在加载主应用配置文件 application.yml 之前提前加载保存在 Consul 服务里的应用配置。其中最重要的是配置应用名称和 Consul 服务的连接地址。

spring:
  application:
    name: spring-cloud-in-practice-post
  cloud:
    consul:
      host: ${SCIP_SPRING_CLOUD_CONSUL_HOST:localhost}
      port: ${SCIP_SPRING_CLOUD_CONSUL_PORT:8500}
      config:
        enabled: true
        default-context: spring-cloud-in-practice

主应用配置文件里除了通常的 Spring Boot 应用配置,其余的是跟服务注册与发现、OAuth 2.0 认证相关的。

...

spring:
    security:
        oauth2:
            resourceserver:
                jwt:
                    jwk-set-uri: ${SCIP_SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI:http://localhost:4444/.well-known/jwks.json}
    cloud:
        loadbalancer:
            ribbon:
                enabled: false
        consul:
            discovery:
                enabled: true
                register: true
                healthCheckPath: /actuator/health

上面配置了获取 JWT Token 验证密钥的认证服务器地址 jwk-set-uri,认证服务器的部署后面会讲到。禁用了不再更新的 Ribbon 负载均衡器,以启用 Spring Cloud 官方的 LoadBalancer。此外同时开启了 Spring Cloud Consul 的服务注册与发现功能,因为动态服务既要被其它微服务调用,又需要调用其它微服务。

开发网关

网关没多少开发工作,主要是配置。

路由配置

package net.jaggerwang.scip.gateway.api.config;

...

@Configuration
public class RouteConfig {
    private TokenRelayGatewayFilterFactory tokenRelayGatewayFilterFactory;

    public RouteConfig(TokenRelayGatewayFilterFactory tokenRelayGatewayFilterFactory) {
        this.tokenRelayGatewayFilterFactory = tokenRelayGatewayFilterFactory;
    }

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(p -> p.path("/user/**")
                        .filters(f -> f.filters(tokenRelayGatewayFilterFactory.apply())
                                .removeRequestHeader("Cookie"))
                        .uri("lb://spring-cloud-in-practice-user"))
                .route(p -> p.path("/post/**")
                        .filters(f -> f.filters(tokenRelayGatewayFilterFactory.apply())
                                .removeRequestHeader("Cookie"))
                        .uri("lb://spring-cloud-in-practice-post"))
                .route(p -> p.path("/file/**").or().path("/files/**")
                        .filters(f -> f.filters(tokenRelayGatewayFilterFactory.apply())
                                .removeRequestHeader("Cookie"))
                        .uri("lb://spring-cloud-in-practice-file"))
                .route(p -> p.path("/stat/**")
                        .filters(f -> f.filters(tokenRelayGatewayFilterFactory.apply())
                                .removeRequestHeader("Cookie"))
                        .uri("lb://spring-cloud-in-practice-stat"))
                .build();
    }
}

上面的配置里分别将不同路径前缀的请求路由到对应的后端服务,注意协议用的是 lb,服务名字为各个微服务应用的名称 spring.application.name。此外还通过 TokenRelayGatewayFilterFactory 来把客户端请求里的 Token Relay 到后端微服务,并移除 Cookie 头。

安全配置

package net.jaggerwang.scip.gateway.api.config;

...

@Configuration
@EnableWebFluxSecurity
public class SpringSecurityConfig {
    @Bean
    public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http) {
        http.csrf().disable()
                .authorizeExchange(exchanges -> exchanges
                        .pathMatchers("/", "/actuator/**", "/hydra/**", "/graphql", "/user/register",
                                "/user/logged")
                        .permitAll()
                        .anyExchange()
                        .authenticated()
                );
        return http.build();
    }
}

其中配置了需要认证后才能访问的资源路径,除了白名单里的资源允许所有人访问,其它资源都需要认证之后才能访问。如果资源有更细致的权限要求,可以在对应的资源服务器里进一步配置。

服务配置

package net.jaggerwang.scip.gateway.api.config;
...

@Configuration
public class ServiceConfig {
    @Bean
    @LoadBalanced
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }

    @Bean
    public WebClient userServiceWebClient(WebClient.Builder builder) {
        return builder.baseUrl("lb://spring-cloud-in-practice-user").build();
    }

    @Bean
    public UserService userService(@Qualifier("userServiceWebClient") WebClient webClient,
                                   ReactiveCircuitBreakerFactory cbFactory,
                                   ObjectMapper objectMapper) {
        return new UserAsyncService(webClient, cbFactory, objectMapper);
    }

    ...
    
    @Bean
    public WebClient hydraServiceWebClient(@Value("${service.hydra.admin-url}") String adminUrl) {
        return WebClient.builder().baseUrl(adminUrl).build();
    }

    @Bean
    public HydraService hydraService(@Qualifier("hydraServiceWebClient") WebClient webClient,
                                     ReactiveCircuitBreakerFactory cbFactory,
                                     ObjectMapper objectMapper) {
        return new HydraAsyncService(webClient, cbFactory, objectMapper);
    }
}

这里配置了网关需要调用的各个服务,包括实现 GraphQL API 时需要访问的各个后端微服务,以及提供给 Hydra 认证服务器使用的登录和授权页,认证服务器的实现后面会讲到。微服务的配置类似于前面“开发微服务”里所讲,不同的是这里使用了异步访问,因此需要使用 WebClient,不过同样可以使用 @LoadBalanced 注解来为其添加负载均衡能力。

认证和授权

在微服务架构里由于应用拆分成为了多个服务,因此需要实现单点登录。简单场景可以使用分布式会话(Session)方案,实现方式有粘滞(Sticky)会话、会话复制(Replication)和集中式(Centralized)会话存储,这几种方式都各有缺点。粘滞会话要求负载均衡器能够将同一用户的所有请求发送到固定的处理节点,当因某种原因需要切换处理节点时,之前的会话数据将丢失。会话复制要求每个处理节点保存所有会话数据,当某个节点新建会话时,需要同步给所有其它节点,这会造成网络带宽和存储空间的浪费。集中式会话将会话数据存储到 Redis 这样的外部存储中,所有微服务共享一份数据,避免了前面两种方式的缺点,但存储服务容易成为性能瓶颈,并且不支持第三方应用的认证和授权。

我们最终选择了 OAuth 2.0 + JWT 的方案,除了可以满足内部应用的认证和授权,还可用于开放服务给第三方应用访问。由于认证和授权信息直接保存在了 JWT Token 里,因此各个微服务不需要每次请求都去认证服务器获取信息,从而避免了认证服务成为性能瓶颈。

Hydra 简介

自己开发一个安全的 OAuth 2.0 认证服务器需要比较专业的知识,包括 Spring Security 官方也宣布不再继续提供认证服务器支持 Spring Security OAuth 2.0 Roadmap Update,建议大家使用第三方服务。不过国内提供 OAuth 2.0 认证服务的很少,即便有有些时候也不放心使用第三方服务,这时可以使用开源的认证服务器来自行搭建。这里我们选择了 ORY/Hydra,它使用 Go 语言开发,性能高效,功能完善,使用者较多,GitHub Star 数近万。

Hydra 支持 OAuth 2.0 的各种 授权类型,除了 Password Grant,具体原因可参考官方文档 Why is the Resource Owner Password Credentials grant not supported?。简单来说,Password Grant 需要用户直接在客户端应用里直接输入帐号密码来从认证服务器换取 Access Token,因此只适合完全受信任的客户端应用(比如自家应用)。不过即便是这样,也无法防止某个冒牌应用(外观上无法跟原应用区分,不像浏览器里可以通过地址栏)诱骗用户输入并窃取其帐号密码。

除了 Password Grant 类型,Implicit Flow 也不再推荐使用,虽然 Hydra 支持。Implicit Flow 跳过了使用 Authorization Code 换取 Access Token 的步骤,在用户登录授权完成后直接颁发 Access Token。Implicit Flow 原本是设计给原生应用和 JavaScript 应用这些不方便通过服务端去换取 Access Token 的场景。由于跳过了服务端,认证服务器无法验证客户端的合法性(客户端密钥需要存放在服务端才安全),所以容易被其它应用冒充其身份。对于这些场景,最新标准推荐的方式是使用标准的 Authorization Code 流程结合 PKCE 扩展来实现。更多说明可参考官方文档 OAuth 2.0 Implicit Grant

我们的应用将使用最常用也是最完善的 Authorization Code + Refresh Token 模式。网关如果检测到用户需要登录则响应 401 状态码给客户端应用(比如移动应用,如果是 Web 应用则可直接重定向客户端浏览器),此时客户端将在外部浏览器里打开认证入口页。接下来用户完成登录和授权操作,认证服务器将重定向客户端浏览器到预设的授权回调页,服务端使用 Query 参数里的 Authorization Code 到认证服务器换取 Access Token 和 Refresh Token,然后把 Access Token 下发给客户端,并把 Refresh Token 保存在服务端。Access Token 生命周期较短,如果过期,服务端将使用 保存的 Refresh Token 去换取新的 Access Token。

Hydra 服务器只保存跟认证相关的信息,不保存用户信息,需要使用者实现一个 Login & Consent Provider,其中 Login 页让用户完成登录,Consent(同意)页让用户完成授权。虽然看起来有点麻烦,但是这种方式更灵活,更方便集成 Hydra 到现有用户系统里。Hydra 跟 Login & Consent Provider 的具体交互流程如下(图片来源于官方文档)。

login-consent-flow

实现 Login & Consent Provider

Login & Consent Provider 比较简单,只有几个页面,因此我们将它合并到了网关里。

package net.jaggerwang.scip.gateway.adapter.controller;

...

@Controller
@RequestMapping("/hydra")
public class HydraController extends AbstractController {
    @GetMapping("/login")
    public Mono<String> login(@RequestParam(name = "login_challenge") String challenge,
                              Model model) {
        return hydraService
                .getLoginRequest(challenge)
                .flatMap(loginRequest -> {
                    if (loginRequest.getSkip()) {
                        var loginAccept = LoginAcceptDto.builder()
                                .subject(loginRequest.getSubject())
                                .build();
                        return hydraService
                                .directlyAcceptLoginRequest(challenge, loginAccept)
                                .map(redirectTo -> "redirect:"+redirectTo);
                    }

                    model.addAttribute("challenge", challenge);
                    return Mono.just("hydra/login");
        });
    }

    @PostMapping("/login")
    public Mono<String> login(@RequestParam String challenge,
                              @RequestParam(required = false) String username,
                              @RequestParam(required = false) String mobile,
                              @RequestParam(required = false) String email,
                              @RequestParam String password,
                              @RequestParam(required = false, defaultValue = "0") Boolean remember,
                              @RequestParam String submit,
                              Model model) {
        if (submit.equals("No")) {
            var loginReject = LoginRejectDto.builder()
                    .error("login_rejected")
                    .errorDescription("The resource owner rejected to log in")
                    .build();
            return hydraService
                    .rejectLoginRequest(challenge, loginReject)
                    .map(redirectTo -> "redirect:" + redirectTo);
        }

        Mono<UserDto> mono;
        if (username != null) {
            mono = userService.verifyPasswordByUsername(username, password);
        } else if (mobile != null) {
            mono = userService.verifyPasswordByMobile(mobile, password);
        } else if (email != null) {
            mono = userService.verifyPasswordByEmail(email, password);
        } else {
            model.addAttribute("error", "用户名、手机或邮箱不能都为空");
            return Mono.just("hydra/login");
        }

        return mono
                .flatMap(userDto -> {
                    var loginAccept = LoginAcceptDto.builder()
                            .subject(userDto.getId().toString())
                            .remember(remember)
                            .rememberFor(86400)
                            .build();
                    return hydraService
                            .acceptLoginRequest(challenge, loginAccept)
                            .map(redirectTo -> "redirect:"+redirectTo);
                });
    }

    ...
}

由于 Spring Cloud Gateway 基于 Spring WebFlux 实现,因此这里控制器的实现采用了响应式(Reactive)编程方式,所有 IO 操作均为异步。对于登录页,首先使用跳转链接里的 login_challenge 去认证服务器获取登录请求信息,如果请求信息里指示跳过本次登录(已处于登录状态),则直接调用认证服务器接口接受本次登录请求,否则展示登录页。用户在登录页输入帐号密码后提交表单,服务端验证帐号密码是否匹配,如果匹配则调用认证服务器接口接受本次登录请求。同意页的流程与登录页类似,只不过是需要用户确认授权而已。更多说明可参考官方文档 Implementing a Login & Consent Provider

应用配置

...

spring:
    security:
        oauth2:
            client:
                registration:
                    hydra:
                        client-id: scip
                        client-secret: ilxzM0AdA7BVaL7c
                        authorization-grant-type: authorization_code
                        redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
                        scope: offline,user,post,file,stat
                provider:
                    hydra:
                        authorization-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_AUTHORIZATION_URI:http://localhost:4444/oauth2/auth}
                        token-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_TOKEN_URI:http://localhost:4444/oauth2/token}
                        user-info-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_USER_INFO_URI:http://localhost:4444/userinfo}
                        user-name-attribute: sub
                        jwk-set-uri: ${SCIP_SPRING_SECURITY_OAUTH2_PROVIDER_HYDRA_JWK_SET_URI:http://localhost:4444/.well-known/jwks.json}

service:
    hydra:
        url: ${SCIP_SERVICE_HYDRA_URL:http://localhost:4444/}
        admin-url: ${SCIP_SERVICE_HYDRA_ADMIN_URL:http://localhost:4445/}

在网关的主应用配置文件里,需要配置 Spring Security OAuth 2.0 客户端信息,包括当前应用在认证服务器注册的客户端信息 registration,一个应用可以在多个 OAuth 2.0 Provider 服务商里注册。由于我们使用了自己的 Provider,需要提供 Provider 的访问信息,包括授权地址、Token 获取地址等。如果使用的是 Google、Facebook 等官方支持的 Provider,可以无需提供这些信息。由于在 Login & Consent Provider 里需要调用认证服务器接口,因此还配置了认证服务器的地址 service.hydra.urlservice.hydra.admin-url

部署服务

本应用共包含 8 个服务,分别是网关、四个微服务(用户、动态、文件、统计)、认证服务、Consul 服务和数据库服务(MySQL)。部署起来还是比较麻烦,这也是微服务架构的弊端之一,好在可以借助 Docker Compose 来简化部署工作。关于部署详细步骤,可以查看本项目的 README 文档,其中包含了本地手动部署和 Docker Compose 容器部署两种方式。

参考资料

  1. Spring Cloud in Practice
  2. Spring Boot
  3. Spring Data JPA
  4. Querydsl JPA
  5. Spring Security
  6. GraphQL Java
  7. Flyway
  8. Spring Cloud Gateway
  9. Spring Cloud Consul
  10. Spring Cloud Circuit Breaker
  11. ORY/Hydra