微服务架构实战第八节 微服务安全框架,认证与授权

25 服务安全:如何理解微服务访问的安全需求和实现方案?

今天,我们又将进入一个全新的话题,讨论微服务架构中的服务访问安全性相关的需求和实现方案。在设计微服务架构时,安全性是一个重要但又往往被忽略的主题,很多开发人员缺乏对微服务安全访问机制的认识。另一方面,微服务安全性又是一个非常综合的话题,涉及的技术体系也比较复杂,让我们一起来看一下。

微服务架构中的安全性设计

对于微服务架构而言,安全性设计的最核心考虑点还是认证(Authentication)授权(Authorization)

认证与授权

在软件系统中,我们可以把需要访问的内容定义为是一种资源(Resource),而安全性设计的核心目标就是对这些资源进行保护,确保对它们的访问是安全受控的。在微服务架构中,一个个的微服务就可以被理解为是资源。对于资源的安全性访问,业界也存在一些常见的技术体系。在讲解这些技术体系之前,我们先来理解在安全领域中非常常见但又容易混淆的两个概念,即认证和授权。

Lark20201215-153117.png

我们首先需要明确,所谓认证,解决的是“你是谁”这一个问题,也就是说对于每一次访问请求,系统都能判断出访问者是否具有合法的身份标识。

一旦明确 “你是谁”之后,下一步就可以判断“你能做什么”,这个步骤就是授权。通用的授权模型通常都是基于权限管理体系的,也就是说是对资源、权限、角色和用户的一种组合处理。

如果我们将认证和授权结合起来,就构成了对系统中资源进行安全性管理的最常见解决方案,即先判断资源访问者的有效身份,然后再来确定其是否有对这个资源进行访问的合法权限,如下图所示:

Drawing 0.png

基于认证和授权机制的资源访问安全性示意图

上图代表的是一种通用方案,而不同的应用场景以及技术体系下可以衍生出很多具体的实现策略。微服务架构中的认证和授权模型与上图中的类似,但在具体设计和实现过程中也有其特殊性。

微服务架构中的认证与授权

在微服务架构下,我们设想一下服务访问过程中需要考虑的安全性问题。因为一个微服务系统中服务之间可以存在相互的调用关系,对于每一个服务而言,我们一方面需要考虑来自客户端的请求,同时也要考虑可能来自另一个服务的请求。因此,面临着从客户端到服务、从服务到服务的多种认证和授权场景。

针对上述场景下的认证环节,比较容易想到的一种实现方案是分布式 Session 机制。Session 本质上是一种服务器端技术,即服务器对请求进行认证,并将已经通过的认证信息的用户信息存储在一个共享存储空间中。这样,每次对于微服务的请求都可以带着 Session ID,服务器根据共享存储空间中的数据检查用户是否认证过。这种方案显然需要消耗服务器端的存储空间,也容易受到攻击。

与服务器端保存认证信息相对应的,另一种思路是将认证信息存储在客户端。我们可以在客户端生成认证信息并保存在本地。然后,在每次请求中,客户端将这个认证信息通过HTTP请求传递到服务器端,服务器端再基于这个认证信息执行用户身份验证。显然,这种机制是无状态的,而且有利于减轻服务端存储压力。在这种方案下,我们通常把认证信息称为一个 Token(令牌),业界也存在诸如 JWT(JSON Web Tokens)这样的实现方案,课程后面会具体讲到。

讲完对认证信息的处理方式,我们来看微服务架构中的授权。对于某一个特定的微服务而言,我们面临的第一个问题是如何判断一个 HTTP 请求具备访问自己的权限呢?更进一步,就算这个请求具备访问该微服务的权限,但并不意味着它能够访问该服务中的所有功能。对于某些核心功能,需要具备较高的权限才能访问,而有些则不需要。这就是我们需要解决的第二个问题,也就是说,如何对服务访问的权限进行精细化管理?如下图所示:

Drawing 1.png

微服务授权效果示意图

在上图中,我们假设该请求具备服务 A 的权限,但不具备访问服务 A 中功能 1 的权限。想要达到这种效果,一般的做法是引入角色体系。我们对不同的用户设置不同等级的角色,角色等级不同对应的访问权限也不同。而每一个请求都可以绑定到某一个角色,也就具备了访问权限。

接下来,我们把认证和授权结合起来,梳理出服务访问场景下的安全性实现方案,如下所示:

Drawing 2.png

认证和授权整合示意图

可以看到,在上图中存在一个授权中心,授权中心首先会获取客户端请求中所带有的身份凭证信息,然后基于这个身份凭证信息生成一个 Token。客户端获取 Token 之后就可以基于这个 Token 发起对微服务的访问。这时候,我们需要对这个 Token 进行认证,并通过授权中心获取该请求所能访问的特定资源。在微服务系统中,对外的资源表现形式可以理解为就是一个个 HTTP 端点。

关于如何实现上图中给出的技术方案,业界也存在了一些特定的工具和协议。针对授权,最具代表性的就是 OAuth2 协议。而针对授权,采用JWT是目前非常主流的做法。

授权:OAuth2 协议

OAuth 是 Open Authorization 的简称,该协议解决的是授权问题而不是认证问题,目前普遍被采用的是 OAuth 2.0 协议。OAuth2 是一个相对复杂的协议,对涉及的角色和授权模式给出了明确的定义,让我们先来看一下这些基本概念。

OAuth2 协议中把需要访问的接口或服务统称为资源,而每个资源都有一个拥有者(Resource Owner)。这些资源拥有者所拥有的资源统一存放在资源服务器(Resource Server)中。同时,协议规定需要有一台授权服务器(Authorization Server),即专门用来处理对访问请求进行授权的服务器,也就是上图中的授权中心。

OAuth2 协议的作用就是让客户端程序安全可控地获取用户的授权信息,并与资源服务器进行交互。OAuth2 协议在客户端程序和资源服务器之间设置了一个授权层,所以客户端程序不能直接访问资源服务器,而是只能先登录授权层。资源拥有者会首先授权给客户端,客户端获得授权之后,向授权服务器申请一个 Token,Token 中就包含了权限范围和有效期。然后,客户端使用这个申请到的 Token 向资源服务器申请获取资源,资源服务器就根据 Token 的权限范围和有效期向客户端开放拥有者的资源。

对应到微服务系统中,服务提供者所充当的角色就是资源服务器,而服务消费者就是客户端。所以各个服务本身都可以是客户端,也可以作为资源服务器,或者两者兼之。当客户端拿到 Token 之后,该 Token 就能在各个服务之间进行传递。如下所示:

Lark20201215-153100.png

OAuth2 协议在服务访问场景中的应用

在整个 OAuth2 协议中,最关键的就是如何获取客户端授权。OAuth 2.0 定义了四种授权方式,即密码模式、授权码模式、简化模式和客户端模式。本课程无意对这四种授权方式做一一详细展开,在下一课时中将演示密码模式作为 OAuth 协议授权模式的默认实现方式。

认证:JWT 机制

JWT 是一种表示数据的标准,所有人都可以遵循这种标准来传递数据。在安全领域,我们通常用它来传递被认证的用户身份信息,以便从资源服务器获取资源。同时,JWT 在结构上也提供了良好的扩展性,开发人员可以根据需求增加一些额外信息用于处理复杂的业务逻辑。因为 JWT 中的数据都是被加密的,所以除了可以直接被用于认证之外,也可以处理加密需求。

JWT 具有很多优秀的功能特性,它的数据表示方式采用语言无关的 JSON 格式,可以与各个异构系统进行集成。同时,基于 JWT 表示的 Token 简洁紧凑,也便于网络之间的高效传输。最重要的,JWT 中的 Token 不存储在服务端,所以提供了一种无状态性。

同时,我们也应该注意到,正因为 Token 不存储在服务端,所以我们要关注它的注销方式。区别于服务器端技术,当用户注销时,我们无法实时控制 Token 的失效时间。所以一般可以把这个失效时间设置为一个较小的值,从而降低 Token 可用性的风险。

本质上,JWT 和 OAuth2 面向不同的应用场景,本身并没有任何关联,但在很多情况下,在讨论 OAuth2 的实现时,会把 JWT 作为一种认证机制进行使用。

OAuth2 协议和 JWT 机制在实现上体系比较复杂,综合应用摘要认证、签名认证、HTTPS 等安全性手段,同时需要开发者进行精细化的权限粒度控制。一般我们应该避免自己去实现如此复杂的协议,而是倾向于借助于特定工具以避免重复造轮子。幸好,Spring Cloud 为我们提供了一个专门实现 OAuth2 协议以及整合 JWT 机制的框架,这就是 Spring Cloud Security。Spring Cloud Security 框架基于 Spring 家族中的 Spring Security 框架。在本课程中,我们将采用 Spring Cloud Security 来实现微服务架构下服务之间访问的安全性。

小结与预告

今天我们进入到微服务安全性领域的探讨,在这个领域中,认证和授权仍然是最基本的安全性控制手段。我们系统分析了微服务架构中的认证和授权解决方案,并分别引入了 OAuth2 协议和 JWT 机制来实现这一解决方案。

这里给你留一道思考题:你能正确描述 OAuth2 协议和 JWT 机制之间的区别和联系吗?

介绍完系统概念和原理,接下来我们将基于 SpringHealth 案例系统详细介绍 OAuth2 协议和 JWT 机制的实现过程。首先我们关注的是如何使用 Spring Cloud Security 集成 OAuth2 协议,这就是下一课时的内容。


26 服务授权:如何基于 Spring Cloud Security 集成 OAuth2 协议?

在上一课时中,我们讨论了如何在微服务架构中实现认证和授权这两个基本的安全性控制手段,我们知道可以使用 OAuth2 协议来实现服务访问的授权,以及使用 JWT 来实现定制化的用户认证机制。同时,上一课时中也引出了 Spring Cloud 中专门用于提供服务访问安全性的 Spring Cloud Security 框架。今天,我们就将基于这一框架,讨论如何构建 OAuth2 授权服务器,并基于常用的密码模式生成对应的 Token,从而为下一节中的服务访问控制提供基础。

构建 OAuth2 授权服务器

在微服务架构的实现过程中,OAuth2 授权服务器和注册中心服务器、配置服务器一样也表现为一个独立的微服务,因此构建授权服务器的方法也是创建一个 Spring Boot 应用程序,我们需要引入合适的 Maven 依赖以及提供一个 Bootstrap 类作为访问的入口。

让我们回到 SpringHealth 案例,在前面各个服务的基础上,我们将在整个系统中创建一个新的代码工程并取名为 auth-server,同时引入与 OAuth2 协议相关的依赖,如下所示:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-security</artifactId>
</dependency>
 
<dependency>
    <groupId>org.springframework.security.oauth</groupId>
    <artifactId>spring-security-oauth2</artifactId>
</dependency>

其中第一个依赖 spring-cloud-security 属于 Spring Cloud 家族中的一员,而 spring-security-oauth2 则是来自于 Spring Security 中的 OAuth2 库。

现在 Maven 依赖已经添加完毕,下一步就是构建 Bootstrap 类,以下代码位于 auth-server 工程的 AuthApplication 类中。

@SpringCloudApplication
@RestController
@EnableResourceServer
@EnableAuthorizationServer
public class AuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(AuthApplication.class, args);
    }
}

注意到这里出现了一个新的注解 @EnableAuthorizationServer。顾名思义,@EnableAuthorizationServer 注解的作用在于为微服务运行环境提供一个基于 OAuth2 协议的授权服务,该授权服务会暴露一系列基于 RESTful 风格的端点(例如 /oauth/authorize 和 /oauth/token)供 OAuth2 授权流程进行使用。

构建 OAuth2 授权服务只是集成 OAuth2 协议的第一步。授权服务器是一种集中式系统,管理着所有与安全性流程相关的客户端和用户信息。因此,我们接下来需要在授权服务器中对这些基础信息进行初始化,而 Spring Cloud Security 框架为我们提供了各种配置类来实现这一目标。

基于密码模式生成 Token

在上一课时中,我们提到 OAuth2 协议存在四种授权模式。在本课程中,我们以简单但常用的密码模式为例进行展开。在密码模式下,用户向客户端提供用户名和密码,并将用户名和密码发给授权服务器从而请求 Token。授权服务器首先会对密码凭证信息进行认证,确认无误后,向客户端发放 Token。整个流程如下图所示:

1.png

密码模式示意图

请注意,授权服务器在这里执行认证操作的目的,是验证所传入的用户名和密码是否正确。在密码模式下,这一步是必须的,而如果采用其他授权模式,则不一定会有用户认证这一环节。

确定了采用密码模式之后,我们来看为了实现这一授权模式,我们需要对授权服务器做哪些开发工作。首先我们需要设置一些基础数据,包括客户端信息和用户信息。然后基于这些基础数据,就可以通过 HTTP 请求获取所需的 Token。如下所示:

Drawing 1.png

密码模式下的 OAuth2 协议集成开发流程

设置客户端信息

我们首先来看如何设置客户端信息。设置客户端时,用到的配置类是 ClientDetailsServiceConfigurer。显然,该配置类用来配置客户端详情服务 ClientDetailsService,而用于描述客户端详情的 ClientDetails 接口则包含了与安全性控制相关的多个重要方法,该接口中的部分方法定义如下:

public interface ClientDetails extends Serializable {
 
    //客户端唯一性 Id
    String getClientId();
    Set<String> getResourceIds();
    boolean isSecretRequired();
    //客户端安全码
    String getClientSecret();
    boolean isScoped();
    //客户端的访问范围
    Set<String> getScope();
    //客户端可以使用的授权模式
    Set<String> getAuthorizedGrantTypes();
	…
}

我们无意对这些方法都详细进行展开,但有必要介绍与日常开发紧密相关的几个属性。首先,clientId 是一个必备属性,用来唯一标识客户的 Id,而 clientSecret 代表客户端安全码。

这里的 scope 用来限制客户端的访问范围,这个属性如果为空的话,客户端就拥有全部的访问范围。常见的设置方式可以是 webclient 或 mobileclient,分别代表 Web 端和移动端。

最后,authorizedGrantTypes 代表客户端可以使用的授权模式,可选的范围包括代表授权码模式的 authorization_code、代表隐式授权模式 implicit、代表密码模式的 password 以及代表客户端凭据模式的 client_credentials。这个属性在设置上也可以添加 refresh_token,用来通过刷新操作获取以上授权模式下所产生的新 Token。

Spring Security 提供了 AuthorizationServerConfigurerAdapter 类来简化配置类的使用方式,我们可以通过继承该类并覆写其中的 configure() 方法来进行配置。使用 AuthorizationServerConfigurerAdapter 进行客户端信息配置的基本代码结构如下所示:

@Configuration
public class SpringHealthAuthorizationServerConfigurer extends AuthorizationServerConfigurerAdapter {
 
    @Autowired
    private AuthenticationManager authenticationManager;
 
    @Autowired
    private UserDetailsService userDetailsService;
 
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.authenticationManager(authenticationManager).userDetailsService(userDetailsService);
    }
 
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
 
        clients.inMemory().withClient("springhealth").secret("{noop}springhealth_secret")
                .authorizedGrantTypes("refresh_token", "password", "client_credentials")
                .scopes("webclient", "mobileclient");
    }
}

可以看到这里我们设置了授权模式为密码模式。而在授权服务器中存储客户端信息有两种方式,一种就是如上述代码所示的基于内存级别的存储,另一种则是通过 JDBC 在数据库中存储详情信息。同时,我们注意到在设置客户端安全码时使用了"{noop}springhealth_secret"这种格式。这是因为在 Spring Security5 中统一使用 PasswordEncoder 来对密码进行编码,在设置密码时要求格式为“{id}password”。而这里的前缀“{noop}”就是代表具体 PasswordEncoder 的 id,表示我们使用的是 NoOpPasswordEncoder。

我们已经在前面的内容中提到,@EnableAuthorizationServer 注解会暴露一系列的端点,而授权是使用 AuthorizationEndpoint 这个端点来进行控制的。要想对该端点的行为进行配置,可以使用 AuthorizationServerEndpointsConfigurer 这个配置类。和ClientDetailsServiceConfigurer 配置类一样,我们也通过继承 AuthorizationServerConfigurerAdapter 并且覆写其中的 configure() 方法来进行配置。

因为我们指定了授权模式为密码模式,而密码模式包含认证环节。所以针对 AuthorizationServerEndpointsConfigurer 配置类需要指定一个认证管理器 AuthenticationManager,用于对用户名和密码进行认证。同样因为我们指定了基于密码的授权模式,所以需要指定一个自定义的 UserDetailsService 来替换全局的实现。关于 UserDetailsService 我们会放到下文中设置用户认证部分内容中进行介绍,这里只需要明确我们应该在 SpringHealthAuthorizationServerConfigurer 类中添加如下代码用来配置 AuthorizationServerEndpointsConfigurer:

@Autowired
private AuthenticationManager authenticationManager;
 
@Autowired
private UserDetailsService userDetailsService;
	 
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
 
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
}

至此,客户端设置工作全部完成,我们所做的事情就是实现了一个自定义的 SpringHealthAuthorizationServerConfigurer 配置类。

设置用户认证信息

设置用户认证信息所依赖的配置类是 WebSecurityConfigurer 类, Spring Security 同样提供了 WebSecurityConfigurerAdapter 类来简化该配置类的使用方式,我们可以继承 WebSecurityConfigurerAdapter 类并且覆写其中的 configure() 的方法来完成配置工作。

关于 WebSecurityConfigurer 配置类,我们首先需要明确配置的内容。实际上,设置用户信息非常简单,只需要指定用户名(User)、密码(Password)和角色(Role)这三项数据即可。这部分工作就是通过前文中提到的认证管理器 AuthenticationManager 来完成的,该接口非常简单,只包含一个用于认证的 authenticate 方法,如下所示:

public interface AuthenticationManager {

    Authentication authenticate(Authentication authentication)
            throws AuthenticationException
;
}

在 Spring Security 中,我们可以使用 AuthenticationManagerBuilder 类轻松实现基于内存、LADP 和 JDBC 的认证机制。在 SpringHealth 案例中,我们使用的是该类中的 inMemoryAuthentication() 方法来实现基于内存的用户信息认证。完整的 SpringHealthWebSecurityConfigurer 类代码如下所示:

@Configuration
public class SpringHealthWebSecurityConfigurer extends WebSecurityConfigurerAdapter {
 
    @Override
    @Bean
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
 
    @Override
    @Bean
    public UserDetailsService userDetailsServiceBean() throws Exception {
        return super.userDetailsServiceBean();
    }
 
    @Override
    protected void configure(AuthenticationManagerBuilder builder) throws Exception {
     builder.inMemoryAuthentication().withUser("springhealth_user").password("{noop}password1").roles("USER").and()
                .withUser("springhealth_admin").password("{noop}password2").roles("USER", "ADMIN");
    }
}

从上面的代码中,我们看到构建了具有不同角色和密码的两个用户,请注意"springhealth_user"代表的角色是一个普通用户,而"springhealth_admin"则具有管理员角色。注意到我们在设置密码时,同样需要添加前缀“{noop}”。同时,我们还看到 authenticationManagerBean() 和 userDetailsServiceBean() 方法分别返回了父类的默认实现,而这里返回的 UserDetailsService 和 AuthenticationManager 在前面设置客户端时会用到。

生成 Token

当 OAuth2 授权服务器启动完毕,下一步就可以获取 Token。我们在构建 OAuth2 服务器时已经提到授权服务器中会暴露一批端点供HTTP请求进行访问。而获取Token的端点就是http://localhost:8080/oauth/token,在使用该端点时,我们需要提供前面所配置的客户端信息和用户信息。

这里使用 Postman 来模拟 HTTP 请求,客户端信息设置方式如下图所示:

Drawing 2.png

客户端信息设置示意图

我们在“Authorization”请求头中指定认证类型为“Basic Auth”,然后设置客户端名称和客户端安全码分别为“springhealth”和“springhealth_secret”。

接下去我们来指定针对授权模式的专用配置信息,首当其冲的是用于指定授权模式的 grant_type 属性,以及用于指定客户端访问范围的 scope 属性,这里分别设置为“password”和“webclient”。当然,既然设置了密码模式,所以也需要指定用户名和密码用于识别用户身份。这里,我们以“springhealth_user”这个用户为例进行设置,如下所示:

Drawing 3.png

用户信息设置示意图

在 Postman 中执行这个请求,会得到如下所示的返回结果:

{
    "access_token": "868adf52-f524-4be8-a9e7-24c1c41aa7d6",
    "token_type": "bearer",
    "refresh_token": "96de5815-7935-4ca7-a24e-0d7441345696",
    "expires_in": 43199,
    "scope": "webclient"
}

可以看到,除了作为请求参数的 scope 之外,这个返回结果中还包含了 access_token、token_type、refresh_token 和 expires_in 等属性。这些属性都很重要,我们一一进行解释。其中最重要的就是 access_token ,代表一个 OAuth2 Token;针对 token_type,在 OAuth2 协议中存在很多种 Token 类型可供选择,包括 bearer 类型、mac 类型等,这里返回的是最常见的一种类型,即 bearer 类型;refresh_token 的作用在于当 access_token 过期之后,用于重新下发一个新的 access_token;而 expires_in 属性用于指定 access_token 的有效时间,当超过这个有效时间时,access_token 将会自动失效。当然,因为每次请求都生成的 Token 都是唯一的,所以你在尝试时所获取的结果应该与我的不同。

小结与预告

对微服务访问进行安全性控制的首要条件是生成一个访问 Token。本课时从构建 OAuth2 服务器开始讲起,基于密码模式给出了如何设置客户端信息、用户认证信息以及如何最终生成 Token的实现过程。这个过程中需要开发人员熟悉 OAuth2 协议的相关概念以及 Spring Security 框架中所提供的各项配置功能。

这里给你留一道思考题:基于 OAuth2 协议所生成的一个合法的 Token 信息中应该包含哪些核心属性?

现在,我们已经成功获取了可用于访问各个服务的 Token 信息。在下一课时中,我们将在具体演示如何使用该 Token 来进行服务访问控制。


27 服务授权:如何使用 OAuth2 协议实现对服务访问进行授权?

上一课时,我们构建了 OAuth2 授权服务器,并掌握了如何生成 Token 的系统方法。今天,我们关注如何使用 Token 来实现对服务访问的具体授权。在日常开发过程中,我们需要对每个服务的不同功能进行不同粒度的权限控制,并且希望这种控制方法足够灵活。同时,在微服务架构中,我们还需要考虑如何在多个服务中对 Token 进行有效的传播,确保整个服务访问的链路都能够得到授权管理。借助于 Spring Cloud 框架,实现这些需求都很简单,让我们一起来看一下。

在微服务中集成 OAuth2 授权机制

现在让我们回到 SpringHealth 案例,看看如何基于上一课时构建的 OAuth2 授权服务来完成对单个微服务访问的有效授权。同样,我们还是先关注于 user-service 这个微服务。

我们知道在 OAuth2 协议中,单个微服务的定位就是资源服务器。Spring Cloud Security 框架为此提供了专门的 @EnableResourceServer 注解。通过在 Bootstrap 类中添加 @EnableResourceServer 注解,相当于就是声明了该服务中的所有内容都是受保护的资源。以 user-service 类为例,示例代码如下所示:

@SpringCloudApplication
@EnableResourceServer
public class UserApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(UserApplication.class, args);
    }
}

一旦我们在 user-service 中添加了 @EnableResourceServer 注解之后,user-service 会对所有的 HTTP 请求进行验证以确定 Header 部分中是否包含 Token 信息,如果没有 Token 信息,则会直接限制访问。如果有 Token 信息,就会通过访问 OAuth2 服务器并进行 Token 的验证。那么问题就来了,user-service 是如何与 OAuth2 服务器进行通信并获取所传入 Token 的验证结果呢?

要想回答这个问题,我们要明确将 Token 传递给 OAuth2 授权服务器的目的就是获取该 Token 中包含的用户和授权信息。这样,势必需要在 user-service 和 OAuth2 授权服务器之间建立起一种交互关系,我们可以在 user-service 中添加如下所示的 security.oauth2.resource.userInfoUri 配置项来实现这一目标:

security:
  oauth2:
    resource:
	  userInfoUri: http://localhost:8080/userinfo

这里的http://localhost:8080/user 指向 OAuth2服务中的一个端点,我们需要进行构建。相关代码如下所示:

@RequestMapping(value = "/userinfo", produces = "application/json")
public Map<String, Object> user(OAuth2Authentication user) {
        Map<String, Object> userInfo = new HashMap<>();
        userInfo.put("user", user.getUserAuthentication().getPrincipal());
        userInfo.put("authorities", AuthorityUtils.authorityListToSet(
user.getUserAuthentication().getAuthorities()));
        return userInfo;
}

这个端点的作用就是为了获取可访问那些受保护服务的用户信息。这里用到了 OAuth2Authentication 类,该类保存着用户的身份(Principal)和权限(Authority)信息。

当使用 Postman 访问http://localhost:8080/userinfo 端点时,我们就需要传入一个有效的 Token。这里以上一课时生成的 Token“868adf52-f524-4be8-a9e7-24c1c41aa7d6”为例,在 HTTP 请求中添加一个“Authorization”请求头。请注意,因为我们使用的是 bearer 类型的 Token,所以需要在 access_token 的具体值之前加上“bearer”前缀。当然,我们也可以直接在“Authorization”业中选择协议类型为 OAuth 2.0,然后输入 Access Token,这样相当于就是添加了请求头信息,如下图所示:

Drawing 0.png

通过 Token 发起 HTTP 请求示意图

在后续的 HTTP 请求中,我们都将以这种方式发起对微服务的调用。该请求的结果如下所示:

{
     "user":{
         "password":null,
         "username":"springhealth_user",
         "authorities":[
             {
                 "autority":"ROLE_USER"
             }
         ],
         "accountNonExpired":true,
         "accountNonLocker":true,
         "credentialsNonExpired":true,
         "enabled":true
     },
     "authorities":[
         "ROLE_USER"
     ]
 }

我们知道“868adf52-f524-4be8-a9e7-24c1c41aa7d6”这个 Token 是由“springhealth_user”这个用户生成的,可以看到该结果中包含了用户的用户名、密码以及该用户名所拥有的角色,这些信息与我们在上一课时中所初始化的“springhealth_user”用户信息保持一致。我们也可以尝试使用“springhealth_admin”这个用户来重复上述过程。

在微服务中嵌入访问授权控制

在《服务安全:如何理解微服务访问的安全需求和实现方案?》课时中,我们讨论了作为资源服务器,每个微服务对于自身资源的保护粒度并不是固定的,而是可以根据需求对访问权限进行精细化控制。在 Spring Cloud Security 中对访问的不同控制层级进行了抽象,形成了用户、角色和请求方法这三种粒度,如下图所示:

Drawing 1.png

用户、角色和请求方法三种控制粒度示意图

基于上图,我们可以对这三种粒度进行排列组合,形成用户、用户+角色以及用户+角色+请求方法这三种层级,这三种层级所能访问的资源范围逐一递减。所谓的用户层级是指只要是认证用户就可能访问服务内的各种资源。而用户+角色层级在用户层级的基础上,还要求用户属于某一个或多个特定角色。最后的用户+角色+请求方法层级要求最高,能够对某些HTTP操作进行访问限制。接下来我们分别对这三种层级展开讨论。

用户层级的权限访问控制

在上一课时中,我们已经熟悉了通过扩展各种 ConfigurerAdapter 类来实现自定义配置信息的方法。对于资源服务器而言,也存在一个 ResourceServerConfigurerAdapter 类。在 SpringHealth 案例系统中,为了实现用户层级的控制,我们的做法同样是在 user-service 中创建一个继承了该类的 SpringHealthResourceServerConfiguration 类并覆写它的 configure 方法,如下所示:

@Configuration
public class SpringHealthResourceServerConfiguration extends ResourceServerConfigurerAdapter {
 
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception{
        httpSecurity.authorizeRequests()
             .anyRequest()
             .authenticated();
    }
}

注意到这个方法的入参是一个 HttpSecurity 对象,而上述配置中的 anyRequest().authenticated() 方法指定了访问该服务的任何请求都需要进行验证。因此,当我们使用普通的 HTTP 请求来访问 user-service 中的任何 URL 时,将会得到一个“unauthorized”的 401 错误信息。解决办法就是在 HTTP 请求中设置“Authorization”请求头并传入一个有效的 Token 信息,你可以模仿前面的示例做一些练习。

用户+角色层级的权限访问控制

对于某些安全性要求比较高的资源,我们不应该开放资源访问入口给所有的认证用户,而是需要限定访问资源的角色。就 SpringHealth 案例系统而言,显然,我们认为 intervention-service 服务涉及健康干预这一核心业务流程,会对用户的健康管理产生直接的影响,所以不应该开放给普通用户,而是应该限定只有角色为“ADMIN”的管理员才能访问该服务。要想达到这种效果,实现方式也比较简单,就是在 HttpSecurity 中通过 antMatchers() 和 hasRole() 方法指定想要限制的资源和角色。我们在 intervention-service 中创建一个新的 SpringHealthResourceServerConfiguration 类并覆写它的 configure 方法,如下所示:

@Configuration
public class SpringHealthResourceServerConfiguration extends 
	ResourceServerConfigurerAdapter{
 
    @Override
	public void configure(HttpSecurity httpSecurity) throws Exception {
	 
        httpSecurity.authorizeRequests()
                .antMatchers("/interventions/**")
                .hasRole("ADMIN")
                .anyRequest()
                .authenticated();
    }
}

现在,如果我们使用角色为“User”的 Token 访问 invervention-service,就会得到一个“access_denied”的错误信息。然后,我们使用在上一课时中初始化的一个具有“ADMIN”角色的用户“springhealth_admin”来创建新的 Token,并再次访问 intervention-service 服务就能得到正常的返回结果。

用户+角色+操作层级的权限访问控制

更进一步,我们还可以针对某个端点的某个具体 HTTP 方法进行控制。假设在 SpringHealth 案例系统中,我们认为对 device-service 中的"/devices/"端点下的资源进行更新的风险很高,那么就可以在 HttpSecurity 的 antMatchers() 中添加 HttpMethod.PUT 限定。

@Configuration
public class SpringHealthResourceServerConfiguration extends ResourceServerConfigurerAdapter {
 
    @Override
    public void configure(HttpSecurity httpSecurity) throws Exception{
        httpSecurity.authorizeRequests()
                .antMatchers(HttpMethod.PUT, "/devices/**")
                .hasRole("ADMIN")
                .anyRequest()
                .authenticated();
    }
}

现在,我们使用普通“USER”角色生成的 Token,并调用 device-service 中"/devices/"端点中的 Update 操作,同样会得到“access_denied”错误信息。而尝试使用“ADMIN”角色生成的 Token 进行访问,就可以得到正常响应。

在微服务中传播 Token

让我们再次回到 SpringHealth 案例系统,以添加健康干预这一业务场景为例,就涉及 intervention-service 同时调用 user-service 和 device-service 的实现过程,我们来回顾一下这一场景下的代码结构,如下所示:

public Intervention generateIntervention(String userName, String deviceCode) {
        Intervention intervention = new Intervention();
 
        //获取远程 User 信息
        UserMapper user = getUser(userName);
        …
        
        //获取远程 Device 信息
        DeviceMapper device = getDevice(deviceCode);
        …
 
        //创建并保存 Intervention 信息      
 
        interventionRepository.save(intervention);
 
        return intervention;
}

这样在控制单个微服务访问授权的基础上,就需要确保 Token 在这三个微服务之间进行有效的传播,如下图所示:

Drawing 2.png

微服务中 Token 传播示意图

持有 Token 的客户端访问 intervention-service 提供的 HTTP 端点进行下单操作,该服务会验证所传入 Token 的有效性。intervention-service 会再通过网关访问 user-service 和 device-service,这两个服务同样分别对所传入 Token 进行验证并返回相应的结果。

如何实现上图中的 Token 传播效果?Spring Security 基于 RestTemplate 进行了封装,专门提供了一个用于在 HTTP 请求中传播 Token 的 OAuth2RestTemplate 工具类。想要在业务代码中构建一个 OAuth2RestTemplate 对象,可以使用如下所示的示例代码:

@Bean
public OAuth2RestTemplate oauth2RestTemplate(
	OAuth2ClientContext oauth2ClientContext,
        OAuth2ProtectedResourceDetails details) {
 
        return new OAuth2RestTemplate(details, oauth2ClientContext);
}

可以看到,通过传入 OAuth2ClientContext 和 OAuth2ProtectedResourceDetails,我们就可以创建一下 OAuth2RestTemplate 类。OAuth2RestTemplate 会把从 HTTP 请求头中获取的 Token 保存到一个 OAuth2ClientContext 上下文对象中,而 OAuth2ClientContext 会把每个用户的请求信息控制在会话范围内,以确保不同用户的状态分离。另一方面,OAuth2RestTemplate 还依赖于 OAuth2ProtectedResourceDetails 类,该类封装了上一课时中介绍过的 clientId、客户端安全码 clientSecret、访问范围 scope 等属性。

一旦 OAuth2RestTemplate 创建成功,我们就可以使用它来对 SpringHealth 原有的服务交互流程进行重构。我们来到 intervention-service 中的 UserServiceClient 类中,重构之后的代码如下所示:

@Component
public class UserServiceClient {
 
     @Autowired
	OAuth2RestTemplate restTemplate;
 
    public UserMapper getUserByUserName(String userName){
     
        ResponseEntity<UserMapper> restExchange =
                restTemplate.exchange(
                        "http://userservice/users/{userName}",
                        HttpMethod.GET,
                        null, UserMapper.class, userName);
                
        UserMapper user = restExchange.getBody();
        
        return user;
    }
}

显然,对于原有的实现方式而言,我们唯一要做的就是使用 OAuth2RestTemplate 来替换原有的 RestTemplate,所有关于 Token 传播的细节已经被完整得封装在每次请求中。对于 DeviceServiceClient 类而言,重构方式完全一样。

最后,我们通过 Postman 来验证以上流程的正确性。通过访问 Zuul 中配置的 intervention-service 端点,并传入角色为“ADMIN”的用户对应的 Token 信息,可以看到健康干预记录已经被成功创建。你可以尝试通过生成不同的 Token 来执行这一流程,并验证授权效果。

小结与预告

本课时关注于对服务访问进行授权。通过今天课程的学习,我们明确了基于 Token 在微服务中嵌入访问授权控制的三种粒度,并基于 SpringHealth 案例给出了这三种粒度之下的控制实现方式。同时,在微服务系统中,因为涉及多个服务之间进行交互,所以也需要将 Token 在这些服务之间进行有效的传播。借助于 Spring Cloud Security 为我们提供的工具类,我们可以很轻松地实现这些需求。

这里给你留一道思考题:你能描述对服务访问进行授权的三种层级,以及每个层级对应的实现方法吗?

介绍完授权机制之后,接下来要讨论的是认证问题。在下一课时中,我们将详细介绍 JWT 机制的实现过程以及它提供的扩展性。


28 服务认证:如何使用 JWT 实现定制化 Token?

在上一课时中,我们详细介绍了在微服务架构中,如何使用 Token 对服务的访问过程进行权限控制,这里的 Token 是类似“b7c2c7e0-0223-40e2-911d-eff82d125b80”的一种字符串结构。可能你会问,这个字符串中能包含哪些内容呢?是不是所有的 Token 都是这样的结构吗?事实上,在 OAuth2 协议中,并没有对 Token 具体的组成结构有明确的规定。为了解决 Token 的标准化问题,就诞生了今天我们要介绍的 JWT。

什么是 JWT?

JWT 的全称是 JSON Web Token,所以它本质上就是一种基于 JSON 表示的 Token。JWT 的设计目标就是为 OAuth2 中所使用的 Token 提供一种标准结构,所以它经常与 OAuth2 协议集成在一起使用。

从结构上讲,JWT 本身是由三段信息构成的,第一段为头部(Header),第二段为有效负载(Payload),第三段为签名(Signature),如下所示:

header. payload. signature

以上三个部分的内容从数据格式上讲都是一个 JSON 对象。在JWT中,每一段 JSON 对象都被 Base64 进行编码,然后编码后的内容用“.”号链接一起。所以本质上 JWT 就是一个字符串,如下所示的就是一个 JWT 字符串的示例:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NwcmluZ2hlYWx0aC5leGFtcGxlLmNvbSIsInN1YiI6Im1haWx0bzpzcHJpbmdoZWFsdGhAZXhhbXBsZS5jb20iLCJuYmYiOjE1OTkwNTY4NjIsImV4cCI6MTU5OTA2MDQ2MiwiaWF0IjoxNTk5MDU2ODYyLCJqdGkiOiJpZDEyMzQ1NiIsInR5cCI6Imh0dHBzOi8vc3ByaW5naGVhbHRoLmV4YW1wbGUuY29tL3JlZ2lzdGVyIn0.rlg2i8mWwV-gFjHUSCutX-UBMYrqxL0th1xtyGq7UdE

显然,我们无法从这个经过 Base64 编码的字符串中获取任何有用的信息。业界也存在一些在线生成和解析 JWT 的工具,这里以jsjws上所提供的在线工具为例来演示 JWT的内部结构和签名方式。在这个在线工具中,我们首先需要设置一系列的声明(Claim),然后指定签名的算法和键值,如下图所示:

Drawing 0.png

生成 JWT 的步骤

一旦我们指定了这些内容之后,就可以获取前面所给出的 JWT 字符串。反之,我们可以使用http://jwt.calebb.net/所提供的反向转换原始数据的功能。针对前面的 JWT 字符串,我们可以看到其中所包含的原始 JSON 数据,如下所示:

{
 alg: "HS256",
 typ: "JWT"
}.
{
 iss: "https://springhealth.example.com",
 sub: "mailto:[email protected]",
 nbf: 1599056862,
 exp: 1599060462,
 iat: 1599056862,
 jti: "id123456",
 typ: "https://springhealth.example.com/register"
}.
[signature]

现在,我们可以清晰地看到一个 JWT 中所包含的 Header 部分和 Payload 部分的数据,而出于安全考虑,Signature 部分数据并没有进行展示。

Spring Cloud Security 为 JWT 的生成和验证提供了开箱即用的支持。当然,要发送和消费 JWT,OAuth2 授权服务和各个受保护的微服务必须以不同的方式进行配置。整个开发流程与《服务授权:如何基于 Spring Cloud Security 集成 OAuth2 协议?》中介绍的普通 Token 是一致的,不同之处在于配置的内容和方式。接下来,我们先来看如何在 OAuth2 授权服务器中配置 JWT。

如何集成 OAuth2 与 JWT?

对于所有需要用到 JWT 的独立服务,我们都首先需要在 Maven 的 pom 文件中添加对应的依赖包,如下所示:

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

可以想象,接下来的一步就是提供一个配置类用于完成 JWT 的生成和转换。事实上,在 OAuth2 协议中专门提供了一个接口用于管理 Token 的存储,这个接口就是 TokenStore,该接口的实现类如下所示:

Lark20201224-184609.png

TokenStore 接口的实现类

注意到这里有一个 JwtTokenStore 专门用来存储 JWT Token。对应的,我们也将创建一个用于配置 JwtTokenStore 的配置类。让我们回到 SpringHealth 案例系统中的 auth-server 服务,添加一个 SpringHealthJWTTokenStoreConfig 配置类,如下所示:

@Configuration
public class SpringHealthJWTTokenStoreConfig {
 
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }
 
    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey("123456");
        return converter;
	}
 
    @Bean
    public DefaultTokenServices tokenServices() {
        DefaultTokenServices defaultTokenServices = new DefaultTokenServices();
        defaultTokenServices.setTokenStore(tokenStore());
        defaultTokenServices.setSupportRefreshToken(true);
        return defaultTokenServices;
    }
}

可以看到,这里构建了 JwtTokenStore 对象,而在它的构造函数中传入了一个 JwtAccessTokenConverter。JwtAccessTokenConverters 是一个用来转换 JWT 的转换器,而转换的过程需要签名键。在创建完了 JwtTokenStore 之后,我们通过 tokenServices 方法返回了已经设置 JwtTokenStore 对象的 DefaultTokenServices。

SpringHealthJWTTokenStoreConfig 的作用就是创建了一系列对象供 Spring 容器进行使用,那么我们在什么时候会用到这些对象呢?答案就是在将 JWT 集成到 OAuth2 授权服务的过程中,而这个过程似曾相识。基于《服务授权:如何基于 Spring Cloud Security 集成 OAuth2 协议?》课时中的讨论,我们可以构建一个 SpringHealthAuthorizationServerConfigurer 类来覆写 AuthorizationServerConfigurerAdapter 中的 configure 方法。回想原先的这个 configure 方法实现如下:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
 
        endpoints.authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
}

而集成了 JWT 之后,该方法的实现过程如下所示:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtTokenEnhancer, jwtAccessTokenConverter));
        endpoints.tokenStore(tokenStore).accessTokenConverter(jwtAccessTokenConverter) 
                .tokenEnhancer(tokenEnhancerChain) 
                .authenticationManager(authenticationManager)
                .userDetailsService(userDetailsService);
}

可以看到,这里构建了一个对 Token 的增强链 TokenEnhancerChain,并用到了在 SpringHealthJWTTokenStoreConfig 中创建的 tokenStore、jwtAccessTokenConverter 对象。至此,在 OAuth2 协议中集成 JWT 的过程介绍完成,也就是说现在我们访问 OAuth2 授权服务器时获取的 Token 应该就是 JWT Token。让我们来尝试一下,通过 Postman,我们发起了如下所示的请求并得到了相应的 Token:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXN0ZW0iOiJTcHJpbmdIZWFsdGgiLCJ1c2VyX25hbWUiOiJzcHJpbmdoZWFsdGhfYWRtaW4iLCJzY29wZSI6WyJ3ZWJjbGllbnQiXSwiZXhwIjoxNjA2MzYyMTM3LCJhdXRob3JpdGllcyI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIl0sImp0aSI6IjU3YjhjYjM5LThkMGYtNGE4Ny1hZDU2LTQyZGExZTIxNmRjYyIsImNsaWVudF9pZCI6InNwcmluZ2hlYWx0aCJ9.kEjhuZtSYrj7HJlQhowfBQDzH9qJiqCQm8p7gHUhhcU",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXN0ZW0iOiJTcHJpbmdIZWFsdGgiLCJ1c2VyX25hbWUiOiJzcHJpbmdoZWFsdGhfYWRtaW4iLCJzY29wZSI6WyJ3ZWJjbGllbnQiXSwiYXRpIjoiNTdiOGNiMzktOGQwZi00YTg3LWFkNTYtNDJkYTFlMjE2ZGNjIiwiZXhwIjoxNjA4OTEwOTM3LCJhdXRob3JpdGllcyI6WyJST0xFX0FETUlOIiwiUk9MRV9VU0VSIl0sImp0aSI6IjVmOGZkNDFjLTdlMTEtNDk1OC05ZDVmLWFlY2MzNGRmYThiNSIsImNsaWVudF9pZCI6InNwcmluZ2hlYWx0aCJ9.Rq8pLRHSvZgda_0DgSFQ8eI5dctAGE4Jqlb_qabRDvE",
    "expires_in": 43199,
    "scope": "webclient",
    "system": "SpringHealth",
    "jti": "57b8cb39-8d0f-4a87-ad56-42da1e216dcc"
}

显然,这里的 access_token 和 refresh_token 都已经是经过 Base64 编码的字符串。同样,我们可以通过前面介绍的在线工具来获取其 JSON 数据格式的内容,如下所示的就是 access_token 的原始内容:

{
 alg: "HS256",
 typ: "JWT"
}.
{
 system: "SpringHealth",
 user_name: "springhealth_admin",
 scope: [
  "webclient"
 ],
 exp: 1606362137,
 authorities: [
  "ROLE_ADMIN",
  "ROLE_USER"
 ],
 jti: "57b8cb39-8d0f-4a87-ad56-42da1e216dcc",
 client_id: "springhealth"
}.
[signature]

如何在微服务中使用 JWT?

在各个微服务中使用 JWT 的第一步也是配置工作。我们需要在 SpringHealth 案例系统中的三个业务微服务中分别添加一个 SpringHealthJWTTokenStoreConfig 配置类,这个配置类的内容就是创建一个 JwtTokenStore 并构建 tokenServices,具体代码在前面已经做了介绍,这里不再展开。

配置工作完成之后,剩下的问题就是在服务调用链中传播 JWT。在上一课时中,我们给出了 OAuth2RestTemplate 这个工具类,该类可以传播普通的 Token。但可惜的是,它并不能传播基于 JWT 的 Token。从实现原理上,OAuth2RestTemplate 也是在 RestTemplate 的基础上做了一层封装,所以我们的思路也是尝试在 RestTemplate 请求中添加对 JWT 的支持。

我们知道 HTTP 请求是通过在 Header 部分中添加一个“Authorization”消息头来完成对 Token 的传递,所以第一步需要能够从 HTTP 请求中获取这个 JWT Token。然后,我们需要将这个 Token 存储在一个线程安全的地方以便在后续的服务链中进行使用,这是第二步。第三步,也是最关键的一步,就是在通过 RestTemplate 发起请求时,能够把这个 Token 自动嵌入到所发起的每一个 HTTP 请求中。整个实现思路如下图所示:

Lark20201224-184612.png

在服务调用链中传播 JWT Token 的三个实现步骤

实现这一思路需要你对 HTTP 请求的过程和原理有一定的理解,在代码实现上也需要有一些技巧,我来一一进行展开。

首先,在 HTTP 请求过程中,我们可以通过过滤器 Filter 对所有请求进行过滤。Filter 是 servlet 中的一个核心组件,其基本原理就是构建一个过滤器链并对经过该过滤器链的请求和响应添加定制化的处理机制。Filter 接口的定义如下所示:

public interface Filter {
    public void init(FilterConfig filterConfig) throws ServletException;
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;
    public void destroy();
}

通常,我们会实现 Filter 接口中的 doFilter 方法。例如,在 SpringHealth 案例系统中,我们可从将 ServletRequest 转化为一个 HttpServletRequest 对象,并从该对象中获取“Authorization”消息头,示例代码如下所示:

@Component
public class AuthorizationHeaderFilter implements Filter {

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
            throws IOException, ServletException
{
 
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
 
        AuthorizationHeaderHolder.getAuthorizationHeader().setAuthorizationHeader(httpServletRequest.getHeader(AuthorizationHeader.AUTHORIZATION_HEADER));

        filterChain.doFilter(httpServletRequest, servletResponse);
    }
 
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {}
 
    @Override
    public void destroy() {}
}

注意到,这里我们把从 HTTP 请求中获取的“Authorization”消息头保存到了一个 AuthorizationHeaderHolder 对象中。从命名上看,AuthorizationHeader 对象代表的就是 HTTP 中“Authorization” 消息头,而 AuthorizationHeaderHolder 则是该消息头对象的持有者。这种命名方式在 Spring 等主流开源框架中非常常见。一般而言,以 -Holder 结尾的多是一种封装类,用于对原有对象添加线程安全等附加特性。这里的 AuthorizationHeaderHolder 就是这样一个封装类,如下所示:

public class AuthorizationHeaderHolder {
    private static final ThreadLocal<AuthorizationHeader> authorizationHeaderContext = new ThreadLocal<AuthorizationHeader>();
 
    public static final AuthorizationHeader getAuthorizationHeader(){
        AuthorizationHeader header = authorizationHeaderContext.get();
 
        if (header == null) {
         header = new AuthorizationHeader();
            authorizationHeaderContext.set(header);
 
        }
        return authorizationHeaderContext.get();
    }
 
    public static final void setAuthorizationHeader(AuthorizationHeader header) {
        authorizationHeaderContext.set(header);
    }
}

可以看到,这里使用了 ThreadLocal 来确保对 AuthorizationHeader 对象访问的线程安全性,AuthorizationHeader 定义如下,用于保存来自 HTTP 请求头的 JWT Token:

@Component
public class AuthorizationHeader {
    public static final String AUTHORIZATION_HEADER = "Authorization";

    private String authorizationHeader = new String();
 
    public String getAuthorizationHeader() {
        return authorizationHeader;
    }
 
    public void setAuthorizationHeader(String authorizationHeader) {
        this.authorizationHeader = authorizationHeader;
    }
}

现在,对于每一个 HTTP 请求,我们都能获取其中的 Token 并将其保存在上下文对象中。剩下的唯一问题就是如何通过 RestTemplate 将这个 Token 继续传递到下一个服务中,以便下一个服务也能从 HTTP 请求中获取 Token 并继续向后传递,从而确保 Token 在整个调用链中持续传播。要想实现这一目标,我们需要对 RestTemplate 进行一些设置,如下所示:

@Bean
public RestTemplate getCustomRestTemplate() {
        RestTemplate template = new RestTemplate();
        List<ClientHttpRequestInterceptor> interceptors = template.getInterceptors();
        if (interceptors == null) {
            template.setInterceptors(Collections.singletonList(new AuthorizationHeaderInterceptor()));
        } else {
            interceptors.add(new AuthorizationHeaderInterceptor());
            template.setInterceptors(interceptors);
        }
 
        return template;
}

RestTemplate 允许开发人员添加自定义的拦截器 Interceptor,拦截器本质上与过滤器的功能类似,用于对传入的 HTTP 请求进行定制化处理。例如,上述代码中的 AuthorizationHeaderInterceptor 的作用就是在 HTTP 请求的消息头中嵌入保存在 AuthorizationHeaderHolder 中的 JWT Token,如下所示:

public class AuthorizationHeaderInterceptor implements ClientHttpRequestInterceptor {
 
    @Override
    public ClientHttpResponse intercept(
            HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
            throws IOException {
 
        HttpHeaders headers = request.getHeaders();
        headers.add(AuthorizationHeader.AUTHORIZATION_HEADER, AuthorizationHeaderHolder.getAuthorizationHeader().getAuthorizationHeader());
 
        return execution.execute(request, body);
    }
}

至此,我们已经完成了在微服务中嵌入 JWT 的完整过程。现在,让我们回到 intervention-service 中的 UserServiceClient 类,会发现它重新使用了前面所构建的 RestTemplate 对象来发起远程调用,代码如下所示:

@Component
public class UserServiceClient {
	 
 
     @Autowired
	RestTemplate restTemplate;
 
    public UserMapper getUserByUserName(String userName){

        ResponseEntity<UserMapper> restExchange =
                restTemplate.exchange(
                         “http://zuulservice:5555/springhealth/user/users/username/{userName}”,
                       HttpMethod.GET,
                        null, UserMapper.class, userName);

        UserMapper user = restExchange.getBody();

        return user;
    }
}

如何扩展 JWT?

在本课时的最后,我们来讨论如何扩展 JWT。JWT具有良好的可扩展性,开发人员可以根据需要在 JWT Token 中添加自己想要添加的各种附加信息。

针对 JWT 的扩展性场景,Spring Security 专门提供了一个 TokenEnhancer 接口来对 Token 进行增强(Enhance),该接口定义如下:

public interface TokenEnhancer {
    OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication);
}

可以看到这里处理的是一个 OAuth2AccessToken 接口,而该接口有一个默认的实现类 DefaultOAuth2AccessToken。我们可以通过该实现类的 setAdditionalInformation 方法以键值对的方式将附加信息添加到 OAuth2AccessToken 中,示例代码如下所示:

public class SpringHealthJWTTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> systemInfo= new HashMap<>();
 
        systemInfo.put(“system”, “springhealth”);
 
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(systemInfo);
        return accessToken;
    }
}

这里我们以硬编码的方式添加了一个“system”属性,你也可以根据需要进行相应的调整。

要想使得上述 SpringHealthJWTTokenEnhancer 类能够生效,我们需要对 SpringHealthAuthorizationServerConfigurer 类中的 configure 方法进行重新配置,并将 SpringHealthJWTTokenEnhancer 嵌入到 TokenEnhancerChain 中。事实上,我们在前面的代码中已经演示了这部分内容。

现在,我们已经扩展了 JWT Token。那么,如何从这个 JWT Token 中获取所扩展的属性呢?方法也比较简单和固定,如下所示:

//获取 JWTToken
RequestContext ctx = RequestContext.getCurrentContext();
String authorizationHeader = ctx.getRequest().getHeader(AUTHORIZATION_HEADER);
String jwtToken = authorizationHeader.replace("Bearer ","");

//解析 JWTToken
String[] split_string = jwtToken.split(“\.”);
String base64EncodedBody = split_string[1];
Base64 base64Url = new Base64(true);
String body = new String(base64Url.decode(base64EncodedBody));
JSONObject jsonObj = new JSONObject(body);

//获取定制化属性值
String systemName = jsonObj.getString(“system”);

我们可以把这段代码嵌入到需要使用到自定义“system”属性的任何场景中。

小结与预告

这是微服务安全性的最后一个课时,关注的是认证问题而不是授权问题,为此我们引入了 JWT 机制。JWT 本质上也是一种 Token,只不过提供了标准化的规范定义,可以与 OAuth2 协议进行集成。而我们使用 JWT 时,也可以将各种信息添加到这种 Token 中并在微服务访问链路中进行传播。同时,作为一种具有高扩展性的 Token 解决方案,我们也可以轻松为 JWT 提交各种定制化的认证信息。

这里给你留一道思考题:如果想要对 JWT 中的数据进行扩展,你有什么办法?

介绍完安全性问题之后,下一课时我们将进入到新的一个主题,即服务监控。我们将首先介绍服务监控基本原理以及引入 Spring Cloud Sleuth 框架。


猜你喜欢

转载自blog.csdn.net/fegus/article/details/124941228