创建授权服务器

授权服务器的主要任务是代表用户签发访问令牌。如前文所述,我们有多个可供选择的授权服务器,但是我们的项目将会使用Spring Authorization Server。Spring Authorization Server是一个实验性的项目,并没有实现OAuth 2所有的授权方式,只实现了授权码授权和客户端凭证授权。

认证服务器是一个单独的应用,不同于提供API的应用和客户端。因此,为了使用Spring Authorization Server,我们要创建一个新的Spring Boot项目,(至少)选择web和security starter依赖。对于我们的授权服务器,用户信息会通过JPA存储在关系型数据库中,所以要确保将JPA starter和H2依赖同时添加进来。除此之外,如果使用Lombok来处理getter、setter、构造器等功能,也需要把它包含进来。

Spring Authorization Server还无法通过Initializr添加依赖,所以在项目创建完之后,我们需要手动将Spring Authorization Server添加到构建文件中。例如,如下是在pom.xml文件中需要添加的Maven依赖:

<dependency>
    <groupId>org.springframework.security.experimental</groupId>
    <artifactId>spring-security-oauth2-authorization-server</artifactId>
    <version>0.1.2</version>
</dependency>

接下来,因为我们会在开发机器上运行所有的应用(至少目前是这样),所以要确保不会出现授权服务器与主Taco Cloud应用的端口冲突的情况。我们在项目的application.yml文件中添加如下的条目,使授权服务器监听9000端口:

server:
  port: 9000

现在我们深入了解一下授权服务器所使用的基本安全配置。程序清单8.2显示了一个非常简单的Spring Security配置类,它可以实现基于表单的登录,并要求所有的请求都经过认证。

程序清单8.2 基于表单登录所需的基本安全配置
package tacos.authorization;
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.
              HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.
              EnableWebSecurity;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

import tacos.authorization.users.UserRepository;

@EnableWebSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http)
            throws Exception {
        return http
            .authorizeRequests(authorizeRequests ->
                authorizeRequests.anyRequest().authenticated()
            )

            .formLogin()

            .and().build();
    }

    @Bean
    UserDetailsService userDetailsService(UserRepository userRepo) {
      return username -> userRepo.findByUsername(username);
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
      return new BCryptPasswordEncoder();
    }
}

请注意,在这里UserDetailsService bean会与TacoUserRepository协作,从而根据用户名来查找用户。为了继续配置授权服务器本身,我们将略过TacoUserRepository的具体细节,只需要知道它与我们在第3章中创建的基于Spring Data的存储库很相似。

关于TacoUserRepository,唯一需要注意的事情是,我们可以在CommandLineRunner bean中借助它来预填充数据库(以便测试),使数据库中包含一些测试用户:

@Bean
public ApplicationRunner dataLoader(
        UserRepository repo, PasswordEncoder encoder) {
  return args -> {
    repo.save(
        new User("habuma", encoder.encode("password"), "ROLE_ADMIN"));
    repo.save(
        new User("tacochef", encoder.encode("password"), "ROLE_ADMIN"));
  };
}

现在,我们可以配置应用来实现授权服务器了。配置授权服务器的第一步是创建新的配置类,导入授权服务器所需的通用配置。如下的AuthorizationServerConfig就是一个良好的起点:

@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

  @Bean
  @Order(Ordered.HIGHEST_PRECEDENCE)
  public SecurityFilterChain
    authorizationServerSecurityFilterChain(HttpSecurity http) throws
    Exception {
   OAuth2AuthorizationServerConfiguration
        .applyDefaultSecurity(http);
   return http
       .formLogin(Customizer.withDefaults())
       .build();
  }
  ...
}

bean方法authorizationServerSecurityFilterChain()定义了一个SecurityFilterChain,搭建了OAuth 2授权服务器的默认行为,同时提供了一个默认的登录页。@Order注解被设置为Ordered.HIGHEST_PRECEDENCE,确保在声明了同类型的其他bean的情况下,这个bean优先于其他bean。

这里面大多数内容都是样板式的配置。你如果愿意,可以深入研究并做一些定制化的配置。现在,我们采用默认的配置就可以了。

有个组件不是样板式的,因此OAuth2AuthorizationServerConfiguration没有提供,这就是客户端存储库。客户端存储库类似用户详情服务(user details service)或者用户存储库,只不过它维护的不是用户的详情信息,而是客户端的详情信息,这些客户端可能会代表用户申请授权。这个组件是由RegisteredClientRepository接口定义的,如下所示:

public interface RegisteredClientRepository {

    @Nullable
    RegisteredClient findById(String id);

    @Nullable
    RegisteredClient findByClientId(String clientId);

}

在生产环境的配置中,我们可能会编写一个自定义的RegisteredClientRepository实现,以便从数据库或其他数据源检索客户端的详情。但Spring Authorization Server提供了一个“开箱即用”的基于内存的实现,非常适合进行演示和测试。你可以按照任何你认为合适的方式实现RegisteredClientRepository,但我们会使用基于内存的实现将一个客户端注册到授权服务器上。只需要添加如下的bean方法到AuthorizationServerConfig中:

@Bean
public RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder) {
  RegisteredClient registeredClient =
    RegisteredClient.withId(UUID.randomUUID().toString())
      .clientId("taco-admin-client")
      .clientSecret(passwordEncoder.encode("secret"))
      .clientAuthenticationMethod(
              ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
      .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
      .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
      .redirectUri(
          "http://127.0.0.1:9090/login/oauth2/code/taco-admin-client")
      .scope("writeIngredients")
      .scope("deleteIngredients")
      .scope(OidcScopes.OPENID)
      .clientSettings(
          clientSettings -> clientSettings.requireUserConsent(true))
      .build();
  return new InMemoryRegisteredClientRepository(registeredClient);
}

可以看到,这里涉及很多RegisteredClient的细节信息。按代码中从上到下的顺序,客户端定义相关属性的方式如下。

  • ID:随机的唯一标识符。

  • 客户端ID:类似于用户名。但它代表的不是用户,而是客户端。在这里,其值为“taco-admin-client”。

  • 客户端secret:类似于客户端的密码。在这里,使用“secret”作为客户端的secret。

  • 授权类型:客户端所支持的授权类型。在这里,使用授权码授权和刷新令牌授权。

  • 重定向URL:一个或多个注册的URL,授权服务器在获得授权之后会重定向到这些URL。这使安全性上升到另一个层级,防止任意的其他应用收到能够替换成令牌的授权码。

  • Scope:客户端允许访问的一个或多个OAuth 2 scope。在这里,我们设置了3个scope,分别是writeIngredients、deleteIngredients和常量OidcScopes.OPENID(会解析为“openid”)。后文将授权服务器作为Taco Cloud管理应用的单点登录方案时,这个“openid”是必要的。

  • 客户端设置:一个允许我们自定义客户端设置的lambda表达式。在这里,我们要求在授予所请求的scope之前得到用户的明确许可。如果不这样做,在用户登录后,scope就会被隐式地授予。

最后,因为我们的授权服务器将会生成JWT令牌,令牌需要包含一个使用JWK(即JSON web key)作为秘钥所创建的签名。因此,我们需要一些bean来生成JWK。请在AuthorizationServerConfig中添加如下bean方法(以及私有的辅助方法),以实现相关的功能:

@Bean
  public JWKSource<SecurityContext> jwkSource()
          throws NoSuchAlgorithmException {
  RSAKey rsaKey = generateRsa();
  JWKSet jwkSet = new JWKSet(rsaKey);
  return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}

private static RSAKey generateRsa() throws NoSuchAlgorithmException {
  KeyPair keyPair = generateRsaKey();
  RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
  RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
  return new RSAKey.Builder(publicKey)
      .privateKey(privateKey)
      .keyID(UUID.randomUUID().toString())
      .build();
}

private static KeyPair generateRsaKey() throws NoSuchAlgorithmException {
    KeyPairGenerator keyPairGenerator =
   KeyPairGenerator.getInstance("RSA");
    keyPairGenerator.initialize(2048);
    return keyPairGenerator.generateKeyPair();
}

@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
  return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}

这里似乎做了很多的事情。但是总结起来,JWKSource创建了2048位的RSA密钥对,将其用于对令牌的签名。令牌会使用私钥签名。资源服务器会通过从授权服务器获取到的公钥验证请求中收到的令牌是否有效。当创建资源服务器时,我们会进一步讨论这个问题。

授权服务器的各个组成部分已经就绪。接下来就可以将它启动起来并尝试使用了。构建并运行应用,我们就会得到一个监听9000端口的授权服务器。

因为我们现在还没有客户端,所以可以使用Web浏览器和curl命令行来模拟客户端。首先,我们让浏览器访问 http://localhost:9000/oauth2/authorize?response_type = code&client_id = tacoadmin-client&redirect_uri =http://127.0.0.1:9090/login/oauth2/code/taco-admin-client&scope =writeIngredients + deleteIngredients。我们将会看到如图8.2所示的登录页。

image 2024 03 13 22 03 58 768
Figure 1. 图8.2 授权服务器的登录页面

在登录之后(使用“tacochef”和“password”,或者TacoUserRepository下数据库里其他的用户名-密码组合),我们将会看到如图8.3所示的页面,要求我们许可所申请的scope。

image 2024 03 13 22 04 29 889
Figure 2. 图8.3 授权服务器的许可页面

在授予许可之后,浏览器将会重定向回客户端URL。我们目前还没有客户端,也就没有回调页面,所以我们会接收到一个错误。但是,这没关系,我们是在模拟客户端,可以从URL本身获取授权码。

请查看浏览器的地址栏,我们会发现URL上有一个code参数。复制该参数的完整值,并使用它来替换如下curl命令行的$code:

$ curl localhost:9000/oauth2/token \
    -H"Content-type: application/x-www-form-urlencoded" \
    -d"grant_type = authorization_code" \
    -d"redirect_uri = http://127.0.0.1:9090/login/oauth2/code/taco-admin-
     client"\
    -d"code = $code" \
    -u taco-admin-client:secret

在这里,我们使用所接收到的授权码去交换访问令牌。请求体的载荷是“application/x- www-form-urlencoded”格式,并且要发送授权类型(“authorization_code”)、重定向URL(为了实现额外的安全性)和授权码本身。如果一切顺利,我们将会得到如下所示的JSON响应(调整了格式):

{
  "access_token":"eyJraWQ...",
  "refresh_token":"HOzHA5s...",
  "scope":"deleteIngredients writeIngredients",
  "token_type":"Bearer",
  "expires_in":"299"
}

其中,access_token属性包含了客户端可以用来向API发送请求的访问令牌。实际上,它比这里所显示的要长得多。与之类似,refresh_token在这里也被部分省略以节省空间。现在,我们可以使用访问令牌向资源服务器发送请求,以实现对需要“writeIngredients”或 “deleteIngredients”scope的资源的访问。访问令牌将在299秒(或者说不到5分钟)后过期,因此我们如果想使用它,就必须迅速行动。如果它过期,我们可以使用刷新令牌去获取一个新的访问令牌,而不需要再经历一遍授权流程。

那么,我们该如何使用访问令牌呢?按照推测,我们在发往Taco Cloud API的请求中,将其作为Authorization的一部分,大致如下:

$ curl localhost:8080/ingredients \
  -H"Content-type: application/json" \
  -H"Authorization: Bearer eyJraWQ..." \
  -d'{"id":"FISH","name":"Stinky Fish", "type":"PROTEIN"}'

此时,访问令牌并不能帮我们做任何事情。这是因为Taco Cloud API目前还没有被设置为资源服务器。但是,为了代替实际的资源服务器和客户端API,我们仍然可以复制访问令牌并将其粘贴到JWT网站,以探查它的内容,结果如图8.4所示。

image 2024 03 13 22 14 34 837
Figure 3. 图8.4 在JWT网站解码JWT令牌

可以看到,令牌被解码成了三部分,分别是头信息、载荷和签名。仔细看一下载荷可以发现,这个令牌是为名为tacochef的用户签发的,令牌具有“writeIngredients”和“deleteIngredients”scope。这正是我们所申请的。

大约5分钟后,访问令牌将会过期。我们依然可以在JWT网站探查它的内容,但是如果在真正发往API的请求中使用它,请求会被拒绝。但是,我们可以申请一个新的访问令牌,而不需再经历一遍授权码授权的流程。我们需要做的就是向授权服务器发送一个新的请求,使用refresh_token授权类型,并在refresh_token参数中将刷新令牌传递过去。如果使用curl,这样的请求如下所示:

$ curl localhost:9000/oauth2/token \
    -H"Content-type: application/x-www-form-urlencoded" \
    -d"grant_type = refresh_token&refresh_token = HOzHA5s..." \
    -u taco-admin-client:secret

这个请求的响应与我们最初使用授权码交换访问令牌的响应相同,只是响应中会有一个全新的访问令牌。

虽然将访问令牌粘贴到JWT网站很有意思,但访问令牌的真正能力和使用它的目的是获得对API的访问。因此,接下来我们看一下如何在Taco Cloud API上启用资源服务器。