Spring Cloud – Securing Services – Spring Cloud – 确保服务安全

最后修改: 2016年 12月 23日

中文/混合/英文(键盘快捷键:t)

1. Overview

1.概述

In the previous article, Spring Cloud – Bootstrapping, we’ve built a basic Spring Cloud application. This article shows how to secure it.

在上一篇文章Spring Cloud – Bootstrapping中,我们已经构建了一个基本的Spring Cloud应用程序。本文展示了如何保护它。

We’ll naturally use Spring Security to share sessions using Spring Session and Redis. This method is simple to set up and easy to extend to many business scenarios. If you are unfamiliar with Spring Session, check out this article.

我们自然会使用Spring Security来使用Spring SessionRedis共享会话。这种方法设置简单,而且容易扩展到许多业务场景。如果您不熟悉Spring Session,请查看这篇文章

Sharing sessions gives us the ability to log users in our gateway service and propagate that authentication to any other service of our system.

共享会话使我们有能力在我们的网关服务中记录用户,并将该认证传播到我们系统的任何其他服务。

If you’re unfamiliar with Redis or Spring Security, it’s a good idea to do a quick review of these topics at this point. While much of the article is copy-paste ready for an application, there is no replacement for understanding what happens under the hood.

如果你不熟悉Redis或Spring Security,那么此时对这些主题进行快速回顾是个好主意。虽然文章的大部分内容都可以复制粘贴到应用程序中,但对于了解引擎盖下发生的事情是无可替代的。

For an introduction to Redis read this tutorial. For an introduction to Spring Security read spring-security-login, role-and-privilege-for-spring-security-registration, and spring-security-session. To get a complete understanding of Spring Security, have a look at the learn-spring-security-the-master-class.

关于Redis的介绍,请阅读这个教程。关于Spring安全的介绍,请阅读spring-security-loginrole-and-privilege-for-spring-security-registration,以及spring-security-session。要全面了解Spring Security,请看learn-spring-security-the-master class

2. Maven Setup

2.Maven的设置

Let’s start by adding the spring-boot-starter-security dependency to each module in the system:

让我们先把spring-boot-starter-security依赖性添加到系统中的每个模块。

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

Because we use Spring dependency management we can omit the versions for spring-boot-starter dependencies.

因为我们使用Spring依赖性管理,我们可以省略spring-boot-starter依赖性的版本。

As a second step, let’s modify the pom.xml of each application with spring-session, spring-boot-starter-data-redis dependencies:

作为第二步,让我们修改每个应用程序的pom.xml spring-sessionspring-boot-starter-data-redis依赖关系。

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Only four of our applications will tie into Spring Session: discovery, gateway, book-service, and rating-service.

我们只有四个应用程序将与Spring Session挂钩。发现网关图书服务评级服务

Next, add a session configuration class in all three services in the same directory as the main application file:

接下来,在所有三个服务中添加一个会话配置类,与主应用程序文件在同一目录下。

@EnableRedisHttpSession
public class SessionConfig
  extends AbstractHttpSessionApplicationInitializer {
}

Last, add these properties to the three *.properties files in our git repository:

最后,将这些属性添加到我们git仓库中的三个*.properties文件。

spring.redis.host=localhost 
spring.redis.port=6379

Now let’s jump into service specific configuration.

现在让我们跳到服务的具体配置。

3. Securing Config Service

3.确保配置服务的安全

The config service contains sensitive information often related to database connections and API keys. We cannot compromise this information so let’s dive right in and secure this service.

配置服务包含敏感信息,通常与数据库连接和API密钥有关。我们不能损害这些信息,所以让我们直接进入并保护这个服务。

Let us add security properties to the application.properties file in src/main/resources of the config service:

让我们在配置服务的src/main/resources中的application.properties文件中添加安全属性。

eureka.client.serviceUrl.defaultZone=
  http://discUser:discPassword@localhost:8082/eureka/
security.user.name=configUser
security.user.password=configPassword
security.user.role=SYSTEM

This will set up our service to login with discovery. In addition, we are configuring our security with the application.properties file.

这将设置我们的服务用发现登录。此外,我们正在用application.properties文件来配置我们的安全。

Let’s now configure our discovery service.

现在我们来配置我们的发现服务。

4. Securing Discovery Service

4.确保发现服务的安全

Our discovery service holds sensitive information about the location of all the services in the application. It also registers new instances of those services.

我们的发现服务持有关于应用程序中所有服务的位置的敏感信息。它还注册了这些服务的新实例。

If malicious clients gain access, they will learn network location of all the services in our system and be able to register their own malicious services into our application. It is critical that the discovery service is secured.

如果恶意客户获得访问权,他们将了解我们系统中所有服务的网络位置,并能够将自己的恶意服务注册到我们的应用程序中。发现服务是安全的,这一点至关重要。

4.1. Security Configuration

4.1.安全配置

Let’s add a security filter to protect the endpoints the other services will use:

让我们添加一个安全过滤器来保护其他服务将使用的端点。

@Configuration
@EnableWebSecurity
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

   @Autowired
   public void configureGlobal(AuthenticationManagerBuilder auth) {
       auth.inMemoryAuthentication().withUser("discUser")
         .password("discPassword").roles("SYSTEM");
   }

   @Override
   protected void configure(HttpSecurity http) {
       http.sessionManagement()
         .sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
         .and().requestMatchers().antMatchers("/eureka/**")
         .and().authorizeRequests().antMatchers("/eureka/**")
         .hasRole("SYSTEM").anyRequest().denyAll().and()
         .httpBasic().and().csrf().disable();
   }
}

This will set up our service with a ‘SYSTEM‘ user. This is a basic Spring Security configuration with a few twists. Let’s take a look at those twists:

这将为我们的服务设置一个”SYSTEM“用户。这是一个基本的Spring Security配置,但有一些变化。让我们看一下这些变化。

  • @Order(1) – tells Spring to wire this security filter first so that it is attempted before any others
  • .sessionCreationPolicy – tells Spring to always create a session when a user logs in on this filter
  • .requestMatchers – limits what endpoints this filter applies to

The security filter, we just set up, configures an isolated authentication environment that pertains to the discovery service only.

我们刚刚设置的安全过滤器,配置了一个隔离的认证环境,只与发现服务有关。

4.2. Securing Eureka Dashboard

4.2.确保Eureka仪表板的安全

Since our discovery application has a nice UI to view currently registered services, let’s expose that using a second security filter and tie this one into the authentication for the rest of our application. Keep in mind that no @Order() tag means that this is the last security filter to be evaluated:

由于我们的发现应用程序有一个很好的UI来查看当前注册的服务,让我们使用第二个安全过滤器来公开它,并将这个过滤器与我们应用程序的其他部分的认证联系起来。请记住,没有@Order()标签意味着这是最后一个要被评估的安全过滤器。

@Configuration
public static class AdminSecurityConfig
  extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) {
   http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER)
     .and().httpBasic().disable().authorizeRequests()
     .antMatchers(HttpMethod.GET, "/").hasRole("ADMIN")
     .antMatchers("/info", "/health").authenticated().anyRequest()
     .denyAll().and().csrf().disable();
   }
}

Add this configuration class within the SecurityConfig class. This will create a second security filter that will control access to our UI. This filter has a few unusual characteristics, let’s look at those:

SecurityConfig类中添加这个配置类。这将创建第二个安全过滤器,它将控制对我们的用户界面的访问。这个过滤器有一些不寻常的特点,让我们来看看这些。

  • httpBasic().disable() – tells spring security to disable all authentication procedures for this filter
  • sessionCreationPolicy – we set this to NEVER to indicate we require the user to have already authenticated prior to accessing resources protected by this filter

This filter will never set a user session and relies on Redis to populate a shared security context. As such, it is dependent on another service, the gateway, to provide authentication.

这个过滤器永远不会设置一个用户会话,它依赖于Redis来填充一个共享的安全上下文。因此,它依赖于另一个服务,即网关,以提供认证。

4.3. Authenticating With Config Service

4.3.使用配置服务进行认证

In the discovery project, let’s append two properties to the bootstrap.properties in src/main/resources:

在发现项目中,让我们在 src/main/resources 中的 bootstrap.properties 中追加两个属性。

spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword

These properties will let the discovery service authenticate with the config service on startup.

这些属性将让发现服务在启动时与配置服务进行验证。

Let’s update our discovery.properties in our Git repository

让我们在Git仓库中更新我们的 discovery.properties

eureka.client.serviceUrl.defaultZone=
  http://discUser:discPassword@localhost:8082/eureka/
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

We have added basic authentication credentials to our discovery service to allow it to communicate with the config service. Additionally, we configure Eureka to run in standalone mode by telling our service not to register with itself.

我们在discovery服务中添加了基本认证凭证,以使其与config服务进行通信。此外,我们将Eureka配置为以独立模式运行,告诉我们的服务不要向自己注册。

Let’s commit the file to the git repository. Otherwise, the changes will not be detected.

让我们把文件提交到git存储库。否则,这些变化将不会被发现。

5. Securing Gateway Service

5.确保网关服务的安全

Our gateway service is the only piece of our application we want to expose to the world. As such it will need security to ensure that only authenticated users can access sensitive information.

我们的网关服务是我们的应用程序中唯一要暴露给世界的部分。因此,它需要安全性,以确保只有经过认证的用户才能访问敏感信息。

5.1. Security Configuration

5.1.安全配置

Let’s create a SecurityConfig class like our discovery service and overwrite the methods with this content:

让我们像我们的发现服务一样创建一个SecurityConfig类,并用这些内容覆盖方法。

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
    auth.inMemoryAuthentication().withUser("user").password("password")
      .roles("USER").and().withUser("admin").password("admin")
      .roles("ADMIN");
}

@Override
protected void configure(HttpSecurity http) {
    http.authorizeRequests().antMatchers("/book-service/books")
      .permitAll().antMatchers("/eureka/**").hasRole("ADMIN")
      .anyRequest().authenticated().and().formLogin().and()
      .logout().permitAll().logoutSuccessUrl("/book-service/books")
      .permitAll().and().csrf().disable();
}

This configuration is pretty straightforward. We declare a security filter with form login that secures a variety of endpoints.

这种配置是非常直接的。我们声明一个带有表单登录的安全过滤器,以确保各种端点的安全。

The security on /eureka/** is to protect some static resources we will serve from our gateway service for the Eureka status page. If you are building the project with the article, copy the resource/static folder from the gateway project on Github to your project.

/eureka/**上的安全是为了保护我们将从我们的网关服务中为Eureka状态页面提供的一些静态资源。如果您正在与文章一起构建项目,请将resource/static文件夹从Github上的网关项目复制到您的项目。

Now we modify the @EnableRedisHttpSession annotation on our config class:

现在我们修改我们的配置类上的@EnableRedisHttpSession注解。

@EnableRedisHttpSession(
  redisFlushMode = RedisFlushMode.IMMEDIATE)

We set the flush mode to immediate to persist any changes on the session immediately. This helps in preparing the authentication token for redirection.

我们将刷新模式设置为立即刷新,以立即保持会话上的任何变化。这有助于为重定向准备认证令牌。

Finally, let’s add a ZuulFilter that will forward our authentication token after login:

最后,让我们添加一个ZuulFilter,它将在登录后转发我们的认证令牌。

@Component
public class SessionSavingZuulPreFilter
  extends ZuulFilter {

    @Autowired
    private SessionRepository repository;

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() {
        RequestContext context = RequestContext.getCurrentContext();
        HttpSession httpSession = context.getRequest().getSession();
        Session session = repository.getSession(httpSession.getId());

        context.addZuulRequestHeader(
          "Cookie", "SESSION=" + httpSession.getId());
        return null;
    }

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 0;
    }
}

This filter will grab the request as it is redirected after login and add the session key as a cookie in the header. This will propagate authentication to any backing service after login.

这个过滤器将抓取登录后重定向的请求,并将会话密钥作为cookie添加到标头中。这将在登录后向任何后援服务传播认证。

5.2. Authenticating With Config and Discovery Service

5.2.使用配置和发现服务进行认证

Let us add the following authentication properties to the bootstrap.properties file in src/main/resources of the gateway service:

让我们在网关服务的src/main/resources中的bootstrap.properties文件中添加以下认证属性。

spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
  http://discUser:discPassword@localhost:8082/eureka/

Next, let’s update our gateway.properties in our Git repository

接下来,让我们在Git仓库中更新我们的 gateway.properties

management.security.sessions=always

zuul.routes.book-service.path=/book-service/**
zuul.routes.book-service.sensitive-headers=Set-Cookie,Authorization
hystrix.command.book-service.execution.isolation.thread
    .timeoutInMilliseconds=600000

zuul.routes.rating-service.path=/rating-service/**
zuul.routes.rating-service.sensitive-headers=Set-Cookie,Authorization
hystrix.command.rating-service.execution.isolation.thread
    .timeoutInMilliseconds=600000

zuul.routes.discovery.path=/discovery/**
zuul.routes.discovery.sensitive-headers=Set-Cookie,Authorization
zuul.routes.discovery.url=http://localhost:8082
hystrix.command.discovery.execution.isolation.thread
    .timeoutInMilliseconds=600000

We have added session management to always generate sessions because we only have one security filter we can set that in the properties file. Next, we add our Redis host and server properties.

我们已经添加了会话管理,总是生成会话,因为我们只有一个安全过滤器,我们可以在属性文件中设置。接下来,我们添加我们的Redis主机和服务器属性。

In addition, we added a route that will redirect requests to our discovery service. Since a standalone discovery service will not register with itself we must locate that service with a URL scheme.

此外,我们添加了一个路由,将请求重定向到我们的发现服务。由于一个独立的发现服务不会向自己注册,我们必须用一个URL方案来定位该服务。

We can remove the serviceUrl.defaultZone property from the gateway.properties file in our configuration git repository. This value is duplicated in the bootstrap file.

我们可以从配置git仓库的gateway.properties文件中删除serviceUrl.defaultZone属性。这个值在bootstrap文件中是重复的。

Let’s commit the file to the Git repository, otherwise, the changes will not be detected.

让我们把文件提交到Git仓库,否则,将无法检测到这些变化。

6. Securing Book Service

6.确保图书服务

The book service server will hold sensitive information controlled by various users. This service must be secured to prevent leaks of protected information in our system.

图书服务服务器将保存由不同用户控制的敏感信息。这项服务必须是安全的,以防止受保护的信息在我们的系统中泄漏。

6.1. Security Configuration

6.1.安全配置

To secure our book service we will copy the SecurityConfig class from the gateway and overwrite the method with this content:

为了保护我们的图书服务,我们将从网关中复制SecurityConfig类,并用这个内容覆盖该方法。

@Override
protected void configure(HttpSecurity http) {
    http.httpBasic().disable().authorizeRequests()
      .antMatchers("/books").permitAll()
      .antMatchers("/books/*").hasAnyRole("USER", "ADMIN")
      .authenticated().and().csrf().disable();
}

6.2. Properties

6.2.属性

Add these properties to the bootstrap.properties file in src/main/resources of the book service:

将这些属性添加到图书服务的src/main/resources中的bootstrap.properties文件。

spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
  http://discUser:discPassword@localhost:8082/eureka/

Let’s add properties to our book-service.properties file in our git repository:

让我们把属性添加到我们的git仓库中的book-service.properties文件。

management.security.sessions=never

We can remove the serviceUrl.defaultZone property from the book-service.properties file in our configuration git repository. This value is duplicated in the bootstrap file.

我们可以从配置git仓库的book-serviceUrl.defaultZone文件中删除book-service.properties。这个值在bootstrap文件中是重复的。

Remember to commit these changes so the book-service will pick them up.

记住要提交这些修改,这样图书服务就会接收到它们。

7. Securing Rating Service

7.确保评级服务

The rating service also needs to be secured.

评级服务也需要得到保障。

7.1. Security Configuration

7.1.安全配置

To secure our rating service we will copy the SecurityConfig class from the gateway and overwrite the method with this content:

为了保护我们的评级服务,我们将从网关中复制SecurityConfig类,并用这个内容覆盖该方法。

@Override
protected void configure(HttpSecurity http) {
    http.httpBasic().disable().authorizeRequests()
      .antMatchers("/ratings").hasRole("USER")
      .antMatchers("/ratings/all").hasAnyRole("USER", "ADMIN").anyRequest()
      .authenticated().and().csrf().disable();
}

We can delete the configureGlobal() method from the gateway service.

我们可以从gateway服务中删除configureGlobal()方法。

7.2. Properties

7.2.属性

Add these properties to the bootstrap.properties file in src/main/resources of the rating service:

将这些属性添加到评级服务的src/main/resources中的bootstrap.properties文件。

spring.cloud.config.username=configUser
spring.cloud.config.password=configPassword
eureka.client.serviceUrl.defaultZone=
  http://discUser:discPassword@localhost:8082/eureka/

Let’s add properties to our rating-service.properties file in our git repository:

让我们把属性添加到我们的git仓库中的评级服务.properties文件。

management.security.sessions=never

We can remove the serviceUrl.defaultZone property from the rating-service.properties file in our configuration git repository. This value is duplicated in the bootstrap file.

我们可以从配置git仓库中的评级服务.properties文件中删除serviceUrl.defaultZone属性。这个值在bootstrap文件中是重复的。

Remember to commit these changes so the rating service will pick them up.

记住要提交这些变化,这样评级服务就会接收到它们。

8. Running and Testing

8.运行和测试

Start Redis and all the services for the application: config, discovery, gateway, book-service, and rating-service. Now let’s test!

启动Redis和该应用程序的所有服务。config, discovery, gateway, book-service, rating-service。现在让我们来测试一下!

First, let’s create a test class in our gateway project and create a method for our test:

首先,让我们在我们的gateway项目中创建一个测试类,并为我们的测试创建一个方法。

public class GatewayApplicationLiveTest {
    @Test
    public void testAccess() {
        ...
    }
}

Next, let’s set up our test and validate that we can access our unprotected /book-service/books resource by adding this code snippet inside our test method:

接下来,让我们设置我们的测试,并通过在我们的测试方法中添加这个代码段来验证我们可以访问我们的未受保护的/book-service/books资源。

TestRestTemplate testRestTemplate = new TestRestTemplate();
String testUrl = "http://localhost:8080";

ResponseEntity<String> response = testRestTemplate
  .getForEntity(testUrl + "/book-service/books", String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());

Run this test and verify the results. If we see failures confirm that the entire application started successfully and that configurations were loaded from our configuration git repository.

运行这个测试并验证结果。如果我们看到失败,请确认整个应用程序成功启动,并且配置已从我们的配置git仓库加载。

Now let’s test that our users will be redirected to log in when visiting a protected resource as an unauthenticated user by appending this code to the end of the test method:

现在让我们测试一下,当用户以非认证用户的身份访问受保护的资源时,是否会被重定向登录,在测试方法的末尾添加这段代码。

response = testRestTemplate
  .getForEntity(testUrl + "/home/index.html", String.class);
Assert.assertEquals(HttpStatus.FOUND, response.getStatusCode());
Assert.assertEquals("http://localhost:8080/login", response.getHeaders()
  .get("Location").get(0));

Run the test again and confirm that it succeeds.

再次运行测试并确认其成功。

Next, let’s actually log in and then use our session to access the user protected result:

接下来,让我们实际登录,然后使用我们的会话来访问用户保护的结果。

MultiValueMap<String, String> form = new LinkedMultiValueMap<>();
form.add("username", "user");
form.add("password", "password");
response = testRestTemplate
  .postForEntity(testUrl + "/login", form, String.class);

now, let us extract the session from the cookie and propagate it to the following request:

现在,让我们从cookie中提取会话,并将其传播到以下请求。

String sessionCookie = response.getHeaders().get("Set-Cookie")
  .get(0).split(";")[0];
HttpHeaders headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
HttpEntity<String> httpEntity = new HttpEntity<>(headers);

and request the protected resource:

并请求保护资源。

response = testRestTemplate.exchange(testUrl + "/book-service/books/1",
  HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());

Run the test again to confirm the results.

再次运行测试以确认结果。

Now, let’s try to access the admin section with the same session:

现在,让我们尝试用同一个会话访问管理区。

response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
  HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.FORBIDDEN, response.getStatusCode());

Run the test again, and as expected we are restricted from accessing admin areas as a plain old user.

再次运行测试,正如预期的那样,我们被限制以普通用户的身份访问管理区。

The next test will validate that we can log in as the admin and access the admin protected resource:

下一个测试将验证我们是否能以管理员身份登录并访问管理员保护的资源。

form.clear();
form.add("username", "admin");
form.add("password", "admin");
response = testRestTemplate
  .postForEntity(testUrl + "/login", form, String.class);

sessionCookie = response.getHeaders().get("Set-Cookie").get(0).split(";")[0];
headers = new HttpHeaders();
headers.add("Cookie", sessionCookie);
httpEntity = new HttpEntity<>(headers);

response = testRestTemplate.exchange(testUrl + "/rating-service/ratings/all",
  HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());
Assert.assertNotNull(response.getBody());

Our test is getting big! But we can see when we run it that by logging in as the admin we gain access to the admin resource.

我们的测试越来越大了!但我们可以看到,当我们运行它时,通过以管理员身份登录,我们获得了对管理员资源的访问。

Our final test is accessing our discovery server through our gateway. To do this add this code to the end of our test:

我们最后的测试是通过我们的网关访问我们的发现服务器。要做到这一点,请在我们的测试结束时添加这段代码。

response = testRestTemplate.exchange(testUrl + "/discovery",
  HttpMethod.GET, httpEntity, String.class);
Assert.assertEquals(HttpStatus.OK, response.getStatusCode());

Run this test one last time to confirm that everything is working. Success!!!

最后再运行一次这个测试,以确认一切正常。成功了!!!。

Did you miss that? Because we logged in on our gateway service and viewed content on our book, rating, and discovery services without having to log in on four separate servers!

你错过了吗?因为我们在我们的网关服务上登录,并在我们的图书、评级和发现服务上查看内容,而不必在四个独立的服务器上登录!

By utilizing Spring Session to propagate our authentication object between servers we are able to log in once on the gateway and use that authentication to access controllers on any number of backing services.

通过利用Spring Session在服务器之间传播我们的认证对象,我们能够在网关上登录一次,并使用该认证来访问任何数量的支持服务上的控制器。

9. Conclusion

9.结论

Security in the cloud certainly becomes more complicated. But with the help of Spring Security and Spring Session, we can easily solve this critical issue.

云中的安全当然变得更加复杂。但在Spring SecuritySpring Session的帮助下,我们可以轻松解决这一关键问题。

We now have a cloud application with security around our services. Using Zuul and Spring Session we can log users in only one service and propagate that authentication to our entire application. This means we can easily break our application into proper domains and secure each of them as we see fit.

我们现在有了一个围绕着我们服务的安全的云应用程序。使用ZuulSpring Session,我们可以只在一个服务中登录用户,并将该认证传播到我们的整个应用程序。这意味着我们可以轻松地将我们的应用程序分成适当的域,并按我们认为合适的方式保护每个域。

As always you can find the source code on GitHub.

一如既往,你可以在GitHub上找到源代码。