Spring Security 详解与实操第五节 JWT和Oauth2

564 篇文章 138 订阅

令牌扩展:如何使用 JWT 实现定制化 Token?

上一讲我们详细介绍了在微服务架构中如何使用 Token 对微服务的访问过程进行权限控制,这里的 Token 是类似“b7c2c7e0-0223-40e2-911d-eff82d125b80”的一种字符串结构。显然,这种格式的 Token 包含的内容应该是很有限的,那么是否有办法实现更为丰富的 Token 呢?答案是肯定的。

事实上,在 OAuth2 协议中并没有明确规定 Token 具体的组成结构,而在现实应用中,我也不太建议你使用上一讲中我们用到的 Token 格式,而是更倾向于采用 JWT。今天我们就基于 JWT 讨论如何实现定制化 Token 这一话题。

什么是 JWT?

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

JWT 的基本结构

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

header. payload. signature

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

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3NwcmluZy5leGFtcGxlLmNvbSIsInN1YiI6Im1haWx0bzpzcHJpbmdAZXhhbXBsZS5jb20iLCJuYmYiOjE2MTU4MTg2NDYsImV4cCI6MTYxNTgyMjI0NiwiaWF0IjoxNjE1ODE4NjQ2LCJqdGkiOiJpZDEyMzQ1NiIsInR5cCI6Imh0dHBzOi8vc3ByaW5nLmV4YW1wbGUuY29tL3JlZ2lzdGVyIn0.Nweh3OPKl-p0PrSNDUQZ9LkJVWxjAP76uQscYJFQr9w

显然,我们无法从这个经过 Base64 编码的字符串中获取任何有用的信息。业界也存在一些在线生成和解析 JWT 的工具,针对上面这个 JWT 字符串,我们可以通过这些工具获取其包含的原始 JSON 数据,如下所示:

{
 alg: "HS256",
 typ: "JWT"
}.
{
 iss: "https://spring.example.com",
 sub: "mailto:spring@example.com",
 nbf: 1615818646,
 exp: 1615822246,
 iat: 1615818646,
 jti: "id123456",
 typ: "https://spring.example.com/register"
}.
[signature]

我们可以清晰地看到一个 JWT 中包含的 Header 部分和 Payload 部分的数据,出于安全考虑,JWT 解析工具通常都不会展示 Signature 部分数据。

JWT 的优势

JWT 具有很多优秀的功能特性,它的数据表示方式采用语言无关的 JSON 格式,可以与各个异构系统进行集成。同时,JWT 是一种表示数据的标准,所有人都可以遵循这种标准来传递数据。

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

如何集成 OAuth2 与 JWT?

看到这里,可能你已经认识到了JWT 和 OAuth2 面向的是不同的应用场景,本身并没有任何关联。但在很多情况下,我们讨论 OAuth2 的实现时,会把 JWT 作为一种认证机制进行使用。

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

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

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

下一步就是提供一个配置类用于完成 JWT 的生成和转换。事实上,在 OAuth2 协议中专门提供了一个接口用于管理 Token 的存储,这个接口就是 TokenStore,而该接口的实现类 JwtTokenStore 则专门用来存储 JWT Token。对应的,我们也将创建一个用于配置 JwtTokenStore 的配置类 JWTTokenStoreConfig,如下所示:

@Configuration
public class JWTTokenStoreConfig {

@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。

上述 JWTTokenStoreConfig 的作用就是创建了一系列对象以供 Spring 容器使用,我们什么时候会用到这些对象呢?答案就是在将 JWT 集成到 OAuth2 授权服务的过程中,而这个过程似曾相似。基于 13 讲“授权体系:如何构建 OAuth2 授权服务器?”中的讨论,我们可以构建一个配置类来覆写 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,并用到了在 JWTTokenStoreConfig 中创建的 tokenStore、jwtAccessTokenConverter 对象。至此,我们在 OAuth2 协议中集成 JWT 的过程就介绍完了,也就是说现在我们访问 OAuth2 授权服务器时获取的 Token 应该就是 JWT Token。

我们来尝试一下,通过 Postman,我们发起了请求并得到了相应的 Token:

{
    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXN0ZW0iOiJTcHJpbmcgU3lzdGVtIiwidXNlcl9uYW1lIjoic3ByaW5nX3VzZXIiLCJzY29wZSI6WyJ3ZWJjbGllbnQiXSwiZXhwIjoxNjE3NTYwODU0LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiY2UyYTgzZmYtMjMzMC00YmQ1LTk4MzUtOWIyYzE0N2Y2MTcyIiwiY2xpZW50X2lkIjoic3ByaW5nIn0.Cd_x3r-Fi9hudA2W80amLEga0utPiOJCgBxxLI4Lsb8",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXN0ZW0iOiJTcHJpbmcgU3lzdGVtIiwidXNlcl9uYW1lIjoic3ByaW5nX3VzZXIiLCJzY29wZSI6WyJ3ZWJjbGllbnQiXSwiYXRpIjoiY2UyYTgzZmYtMjMzMC00YmQ1LTk4MzUtOWIyYzE0N2Y2MTcyIiwiZXhwIjoxNjIwMTA5NjU0LCJhdXRob3JpdGllcyI6WyJST0xFX1VTRVIiXSwianRpIjoiMDA0NjIxY2MtMmRmZi00ZDJiLWE0YWUtNTU5MzM5YzkyYmFhIiwiY2xpZW50X2lkIjoic3ByaW5nIn0.xDhGwhNTq7Iun9yLENaCvh8mrVHkabu3J8sP0NXENq0",
    "expires_in": 43199,
    "scope": "webclient",
    "system": "Spring System",
    "jti": "ce2a83ff-2330-4bd5-9835-9b2c147f6172"
}

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

{
 alg: "HS256",
 typ: "JWT"
}.
{
 system: "Spring System",
 user_name: "spring_user",
 scope: [
  "webclient"
 ],
 exp: 1617560854,
 authorities: [
  "ROLE_USER"
 ],
 jti: "ce2a83ff-2330-4bd5-9835-9b2c147f6172",
 client_id: "spring"
}.
[signature]

如何在微服务中使用 JWT?

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

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

  • 我们知道, HTTP 请求是通过在 Header 部分中添加一个“Authorization”消息头来完成对 Token 的传递,所以第一步需要能够从 HTTP 请求中获取这个 JWT Token。

  • 然后第二步我们需要将这个 Token 存储在一个线程安全的地方,以便在后续的服务链中进行使用。

  • 第三步,也是最关键的一步,就是在通过 RestTemplate 发起请求时,能够把这个 Token 自动嵌入到所发起的每一个 HTTP 请求中。

整个实现思路如下图所示:

1.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 方法。关于过滤器的详细内容,你可以结合 08 讲“管道过滤:如何基于 Spring Security 过滤器扩展安全性?”做一下回顾。基于过滤器,我们可以将 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 的方法已经介绍完毕。关于 JWT 还有一部分内容我们没有介绍,即如何扩展 JWT 中所持有的数据结构,我们会在接下来的案例系统中结合具体的业务场景对这块内容进行补充。

小结与预告

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

这里给你留一道思考题:如果想要确保 JWT 在各个微服务中进行有效传播,需要怎么做?

介绍完 JWT,下一讲又会介绍一个新的案例。我们将基于 Spring Security 和 Spring Cloud 构建一个 SpringOAuth2 案例系统,并给出微服务架构中实现服务安全访问的详细过程。


案例实战:基于 Spring Security 和 Spring Cloud 构建微服务安全架构

通过前面课程的学习,我们已经知道 Spring Security 可以集成 OAuth2 协议并实现分布式环境下的访问授权。同时,Spring Security 也可以和 Spring Cloud 框架无缝集成,并完成对各个微服务的权限控制。

今天我们将设计一个案例系统,从零构建一个完整的微服务系统,除了演示微服务系统构建过程,还将重点展示 OAuth2 协议以及 JWT 在其中所起到的作用。

案例驱动:SpringAppointment

在本课程中,我们通过构建一个相对精简的完整系统,来展示微服务架构相关的设计理念以及各项技术组件,这个案例系统称为 SpringAppointment。

SpringAppointment 包含的业务场景比较简单,可以用来模拟就医过程中的预约处理流程。一般而言,预约流程势必会涉及三个独立的微服务,即就诊卡(Card)服务、预约(Appointment)服务,以及医生(Doctor)服务。

我们把以上三个服务统称为业务服务。纵观整个 SpringAppointment 系统,除了这三个业务微服务之外,还有一批非业务性的基础设施类服务,具体包括:注册中心服务(Eureka)、配置中心服务(Spring Cloud Config),以及 API 网关服务(Zuul)。关于 Spring Cloud 中基础设施类服务的构建过程不是本专栏的重点,你可以参考拉勾上《Spring Cloud 原理与实战》专栏做详细了解。

虽然案例中的各个服务在物理上都是独立的,但就整个系统而言,需要各服务相互协作构成一个完整的微服务系统。也就是说,服务运行时存在一定的依赖性。我们结合系统架构对 SpringAppointment 的运行方式进行梳理,梳理的基本方法就是按照服务列表构建独立服务,并基于注册中心来管理它们之间的依赖关系,如下图所示:

图片1.png

基于注册中心的服务运行时依赖关系图

构建 OAuth2 授权服务

在上图中,我们注意到还存在着案例系统中的最后一个基础设施类微服务,即 OAuth2 授权服务,在这里充当着授权中心的作用。关于 OAuth2 授权服务的具体构建步骤已经在《13|授权体系:如何在微服务架构中集成OAuth2协议?》做了详细介绍,这里我们直接创建 WebSecurityConfigurerAdapter 的子类 WebSecurityConfigurer

以及 AuthorizationServerConfigurerAdapter 的子类 JWTOAuth2Config,实现代码如下所示:

@Configuration
public class WebSecurityConfigurer 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("user").password("{noop}password1").roles("USER").and()
                 .withUser("admin").password("{noop}password2").roles("USER", "ADMIN");
    }
}
 
@Configuration
public class JWTOAuth2Config 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("appointment_client").secret("{noop}appointment_secret")
                 .authorizedGrantTypes("refresh_token", "password", "client_credentials")
                 .scopes("webclient", "mobileclient");
    }
}

初始化业务服务

在 SpringAppointment 案例系统中,我们需要构建三个业务微服务,即 card-service、appointment-service 和 doctor-service,它们都是独立的 Spring Boot 应用程序。在构建业务服务时,我们首先需要完成它们与基础设施类服务集成。因为 API 网关起到的是服务路由作用,所以对于各个业务服务而言是透明的,而其他的注册中心、配置中心和授权中心都需要每个业务服务完成与它们之间的集成。

集成注册中心

对于注册中心 Eureka 而言,card-service、appointment-service 和 doctor-service 都是它的客户端,所以需要 spring-cloud-starter-netflix-eureka-client 的依赖,如下所示。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

然后,我们以 appointment-service 为例,来看它的 Bootstrap 类,如下所示:

@SpringBootApplication
@EnableEurekaClient
public class AppointmentApplication {
	public static void main(String[] args) {
	 
        SpringApplication.run(AppointmentApplication.class, args);
    }
}

这里引入了一个新的注解 @EnableEurekaClient,该注解用于表明当前服务就是一个 Eureka 客户端,这样该服务就可以自动注册到 Eureka 服务器。当然,我们也可以直接使用统一的 @SpringCloudApplication 注解来实现 @SpringBootApplication 和 @EnableEurekaClient这两个注解整合在一起的效果。

接下来就是最重要的配置工作,appointment-service 中的配置内容如下所示:

spring:
  application:
	name: appointmentservice
server:
  port: 8081
	 
eureka:
  client:
    registerWithEureka: true
    fetchRegistry: true
    serviceUrl:
	  defaultZone: http://localhost:8761/eureka/

显然,这里包含两段配置内容。其中,第一段配置指定了服务的名称和运行时端口。在上面的示例中 appointment-service 的名称通过“spring.application.name=appointmentservice”进行指定,也就是说 appointment-service 在注册中心中的名称为 appointmentservice。在后续的示例中,我们会使用这一名称获取 appointment-service 在 Eureka 中的各种注册信息。

集成配置中心

要想获取配置服务器中的配置信息,我们首先需要初始化客户端,也就是在将各个业务微服务与 Spring Cloud Config 服务器端进行集成。初始化客户端的第一步是引入 Spring Cloud Config 的客户端组件 spring-cloud-config-client,如下所示。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-config-client</artifactId>
</dependency>

然后我们需要在配置文件 application.yml 中配置服务器的访问地址,如下所示:

spring: 
  cloud:
    config:
	   enabled: true
	   uri: http://localhost:8888

以上配置信息中,我们指定了配置服务器所在的地址,也就是上面的 uri:http://localhost:8888

一旦我们引入了 Spring Cloud Config 的客户端组件,相当于在各个微服务中自动集成了访问配置服务器中 HTTP 端点的功能。也就是说,访问配置服务器的过程对于各个微服务而言是透明的,即微服务不需要考虑如何从远程服务器获取配置信息,而只需要考虑如何在 Spring Boot 应用程序中使用这些配置信息。而对于常见的关系型数据访问配置而言,Spring 已经帮助我们内置了整合过程,我们要做的就是引入相关的依赖组件而已。

我们以 appointment-service 为例来演示数据库访问功能,案例中使用的是 JPA 和 MySQL,因此需要在服务中引入相关的依赖,如下所示:

<dependency>
	   <groupId>org.springframework.boot</groupId>
	   <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
	 
<dependency>
	   <groupId>mysql</groupId>
	   <artifactId>mysql-connector-java</artifactId>
</dependency>

现在,我们就可以使用 JPA 提供的数据访问功能来访问 MySQL 数据库了。

集成授权中心

在业务服务中集成授权中心的实现方法,我们已经在《14.资源保护:如何使用OAuth2协议实现对微服务访问进行授权?》中做了详细介绍,这里做一些简单的回顾。首先,我们需要在 Spring Boot 的启动类上添加 @EnableResourceServer 注解:

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

然后,我们需要在配置文件中指定授权中心服务的地址:

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

最后,要做的就是在每个业务服务中嵌入访问授权控制。我们可以使用用户层级的权限访问控制、用户+角色层级的权限访问控制,以及用户+角色+操作层级的权限访问控制这三种策略中的任意一种来实现这一目标。

集成和扩展 JWT

让我们再次回到 SpringAppointment 案例系统,以用户下单这一业务场景为例,就涉及 appointment-service 同时调用 doctor-service 和 card-service,这三个服务之间的交互方式如下图所示:
图片2.png

SpringAppointment 案例系统中三个业务微服务的交互方式图

通过这个交互图,实际上我们已经可以梳理出这一场景下的代码结构了,如下所示:

public Appointment generateAppointment(String doctorName, String cardCode) {
 
        Appointment appointment = new Appointment();
 
        //获取远程 Card 信息
        CardMapper card = getCard(cardCode);
        …

        //获取远程 Doctor 信息
        DoctorMapper doctor = getDoctor(doctorName);
       …

        appointmentRepository.save(appointment);
 
        return appointment;
}

其中 appointment-service 从 card-service 获取 Card 对象,以及从 doctor-service 中获取 Doctor 对象,这两个步骤都会涉及远程 Web 服务的访问。因此,我们首先需要分别在 card-service 和 doctor-service 服务中创建对应的 HTTP 端点。这一过程不是课程的重点,如果你感兴趣,可以参考案例源码自己进行学习:https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/SpringAppointment

集成 JWT

在《15 | 令牌扩展:如何使用JWT实现定制化 Token?》中,我们引入了 JWT 并完成了与 OAuth2 协议的集成,从而实现了定制化的 Token。JWT 同样也需要在整个服务调用链路中进行传递。而持有 JWT 的客户端访问 appointment-service 提供的 HTTP 端点进行下单操作,该服务会验证所传入 JWT 的有效性。然后,appointment-service 会再通过网关访问 card-service 和 doctor-service,同样这两个服务也会分别对所传入的 JWT 进行验证,并返回相应的结果。

现在,让我们在 appointment-service 中构建一个 CardRestTemplateClient 类,会发现它使用了在《15 | 令牌扩展:如何使用 JWT 实现定制化 Token?》中所创建的 RestTemplate 对象来发起远程调用,代码如下所示:

@Service
public class CardRestTemplateClient {
 
    @Autowired
    RestTemplate restTemplate;
 
    public CardMapper getCardByCardCode(String cardCode) {
        ResponseEntity<CardMapper> result =
                restTemplate.exchange("http://cardservice/cards/{cardCode}", HttpMethod.GET, null,
                        CardMapper.class, cardCode);
 
        return result.getBody();
    }
}

我们知道在这个 RestTemplate 中,基于 AuthorizationHeaderInterceptor 对请求进行了拦截,从而完成了 JWT 在各个服务中的正确传播。

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

扩展 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 JWTTokenEnhancer implements TokenEnhancer {
 
    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        Map<String, Object> systemInfo = new HashMap<>();
        systemInfo.put("system", "Appointment System");
 
        ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(systemInfo);
        return accessToken;
    }
}

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

要想使得上述 JWTTokenEnhancer 类能够生效,我们需要对 JWTOAuth2Config 类中的 configure 方法进行重新配置,并将 JWTTokenEnhancer 嵌入到 TokenEnhancerChain 中,如下所示:

@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);
}

请注意,我们在这里通过创建一个 TokenEnhancer 的列表将包括 JWTTokenEnhancer 在内的多个 TokenEnhancer 嵌入到 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”属性的任何场景中。

小结与预告

案例分析是掌握一个框架应用方式的最好方法,对于 OAuth2 协议也是一样。本讲中,我们将 Spring Security 结合 Spring Cloud 构建了一个微服务案例系统 SpringAppointment。然后根据 SpringAppointment 案例中的业务场景划分了各个微服务,并重点介绍了各个业务服务的构建过程。我们一方面展示了业务服务与基础设施服务的集成过程,另一方面也演示了如何集成和扩展 JWT 的实现过程。

最后再给你留一道思考题:在业务系统中如何实现对 JWT 进行定制化的扩展呢?欢迎在留言区和我分享你的收获。

介绍完 OAuth2 协议以及与 Spring Cloud 框架的集成之后,下一讲我们将介绍 OAuth2 协议的另一个常见的应用场景,这就是单点登录。


案例实战:基于 Spring Security 和 OAuth2 实现单点登录

单点登录(Single Sign-On,SSO)是我们设计和实现 Web 系统时经常需要面临的一个问题,允许用户使用一组凭据来登录多个相互独立但又需要保持统一登录状态的 Web 应用程序。单点登录的实现需要特定的技术和框架,而 Spring Security 也提供了它的解决方案。本课时将基于 OAuth2 协议来构建 SSO。

什么是单点登录?

与其说 SSO 是一种技术体系,不如说它是一种应用场景。因此,我们有必要先来看看 SSO 与本专栏前面所介绍的各种技术体系之间的关联关系。

单点登录与OAuth2协议

假设存在 A 和 B 两个独立的系统,但它们相互信任,并通过单点登录系统进行了统一的管理和维护。那么无论访问系统 A 还是系统 B,当用户在身份认证服务器上登录一次以后,即可获得访问另一个系统的权限。同时这个过程是完全自动化的,SSO 通过实现集中式登录系统来达到这一目标,该系统处理用户的身份认证并与其他应用程序共享该认证信息。

说到这里,你可能会问为什么我们需要实施 SSO 呢?原因很简单,因为它提供了很多优势。下面我们具体分析一下。

  • 首先,借助 SSO 可以确保系统更加安全,我们只需要一台集中式服务器来管理用户身份,而不需要将用户凭证扩展到各个服务,因此能够减少被攻击的维度。

  • 其次,可以想象持续输入用户名和密码来访问不同的服务,是一件让用户感到很困扰的事情。而 SSO 将不同的服务组合在一起,以便用户可以在服务之间进行无缝导航,从而提高用户体验。

  • 同时,SSO 也能帮助我们更好地了解客户,因为我们拥有对客户信息的单一视图,能够更好地构建用户画像。

那么,如何构建 SSO 呢?各个公司可能有不同的做法,而采用 Spring Security 和 OAuth2 协议是一个不错的选择,因为实现过程非常简单。虽然 OAuth2 一开始是用来允许用户授权第三方应用访问其资源的一种协议,也就是说其目标不是专门用来实现 SSO,但是我们可以利用它的功能特性来变相地实现单点登录,这就需要用到 OAuth2 四种授权模式中的授权码模式。关于 OAuth2 协议和授权码模式,你可以参考《12 | 开放协议:OAuth2 协议解决的是什么问题?》做一些回顾。同时,在使用 OAuth2 协议实现SSO时,我们也会使用 JWT 来生成和管理 Token,关于 JWT,你也可以回顾《15 | 令牌扩展:如何使用 JWT 实现定制化 Token?》课时中的内容。

单点登录的工作流程

在具体介绍实现方案之前,我们先对 SSO 的工作流程做一下展开,从而了解典型 SSO 系统背后的设计思想。下图描述了 SSO 流程,可以看到我们有两个应用程序 App1 和 App2,以及一个集中式 SSO 服务器。

17讲.png

SSO 工作流程图

结合上图,我们先来看针对 App1 的工作流程。

  • 用户第一次访问 App1。由于用户未登录,所以将用户重定向到 SSO 服务器。

  • 用户在 SSO 服务器提供的登录页面上输入用户凭据。SSO 服务器验证凭据并生成 SSO Token,然后 SSO 服务器在 Cookie 中保存这个 Token,以供用户进行后续登录。

  • SSO 服务器将用户重定向到 App1。在重定向 URL 中,就会附上这个 SSO Token 作为查询参数。

  • App1 将 Token 保存在其 Cookie 中,并将当前的交互方式更改为已登录的用户。App1 可以通过查询 SSO 服务器或 Token 来获取与用户相关的信息。我们知道 JWT 是可以自定义扩展的,所以这时候就可以利用 JWT 来传递用户信息。

现在,我们再来看一下同一用户尝试访问 App2 的工作流程。

  • 由于应用程序只能访问相同来源的 Cookie,它不知道用户已登录到 App2。因此,同样会将用户重定向到 SSO 服务器。

  • SSO 服务器发现该用户已经设置了 Cookie,因此它会立即将用户重定向到 App2,并在 URL 中附加 SSO Token 作为查询参数。

  • App2 同样将 Token 存储在 Cookie 中,并将其交互方式更改为已登录用户。

整个流程结束之后,用户浏览器中将设置三个 Cookie,每个 Cookie 分别针对 App1、App2 和 SSO Server 域。

关于上述流程,业界存在各种各样的实现方案和工具,包括 Facebook Connect、Open Id Connect、CAS、Kerbos、SAML 等。我们无意对这些具体的工具做详细展开,而是围绕到目前为止已经掌握的技术来从零构建SSO服务器端和客户端组件。

实现 SSO 服务器端

基于 Spring Security实现 SSO 服务端的核心工作,还是使用一系列我们已经很熟悉的配置体系,来配置基础的认证授权信息,以及与 OAuth2 协议之间的整合过程。

配置基础认证和授权信息

我们同样通过继承 WebSecurityConfigurerAdapter 类来实现自定义的认证和授权信息配置,这个过程比较简单,完整代码如下所示:

@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsServiceBean()).passwordEncoder(passwordEncoder());
    }
 
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/assets/**", "/css/**", "/images/**");
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                .loginPage("/login")
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll()
                .anyRequest()
                .authenticated()
                .and().csrf().disable().cors();
    }
 
    @Bean
    @Override
    public UserDetailsService userDetailsServiceBean() {
        Collection<UserDetails> users = buildUsers();
 
        return new InMemoryUserDetailsManager(users);
    }
 
    private Collection<UserDetails> buildUsers() {
        String password = passwordEncoder().encode("12345");
 
        List<UserDetails> users = new ArrayList<>();
 
        UserDetails user_admin = User.withUsername("admin").password(password).authorities("ADMIN", "USER").build();
 
        users.add(user_admin);
 
        return users;
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
}

在上述代码中,我们综合使用了已经掌握的 Spring Security 中与认证、授权、密码管理、CSRF、CORS 相关的多项功能特性,通过loginPage()方法指定了 SSO 服务器上的登录界面地址,并初始化了一个“admin”用户用来执行登录操作。

配置 OAuth2 授权服务器

然后,我们创建一个 AuthorizationServerConfiguration 类来继承 AuthorizationServerConfigurerAdapter,请注意,在这个类上需要添加 @EnableAuthorizationServer 注解,如下所示:

@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter {

配置 OAuth2 授权服务器的重点工作是指定需要参与 SSO 的客户端。在《13 | 授权体系:如何在微服务架构中集成 OAuth2 协议?》课时中,我们给出了 Spring Security 中描述客户端详情的 ClientDetails 接口,以及用于管理 ClientDetails 的 ClientDetailsService。基于 ClientDetailsService,我们就可以定制化对ClientDetails的创建过程,示例代码如下所示:

@Bean
public ClientDetailsService inMemoryClientDetailsService() throws Exception {
        return new InMemoryClientDetailsServiceBuilder()
                 //创建 app1 客户端
                .withClient("app1")
                .secret(passwordEncoder.encode("app1_secret"))
                .scopes("all")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .redirectUris("http://localhost:8080/app1/login")
                .accessTokenValiditySeconds(7200)
                .autoApprove(true)
 
                .and()
 
                // 创建 app2 客户端
                .withClient("app2")
                .secret(passwordEncoder.encode("app2_secret"))
                .scopes("all")
                .authorizedGrantTypes("authorization_code", "refresh_token")
                .redirectUris("http://localhost:8090/app2/login")
                .accessTokenValiditySeconds(7200)
                .autoApprove(true)
 
                .and()
                .build();
}

这里我们通过 InMemoryClientDetailsServiceBuilder 构建了一个基于内存的 ClientDetailsService,然后通过这个 ClientDetailsService 创建了两个 ClientDetails,分别对应 app1 和 app2。请注意,这里指定的 authorizedGrantTypes为代表授权码模式的 “authorization_code”。

同时,我们还需要在 AuthorizationServerConfiguration 类中添加对 JWT 的相关设置:

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.accessTokenConverter(jwtAccessTokenConverter())
                .tokenStore(jwtTokenStore());
}
 
@Bean
public JwtTokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
}
 
@Bean
public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();
        jwtAccessTokenConverter.setSigningKey("123456");
        return jwtAccessTokenConverter;
}

这里使用的设置方法我们在《15 | 令牌扩展:如何使用 JWT 实现定制化 Token?》课时中都已经介绍过了,这里不再详细展开。

实现 SSO 客户端

介绍完 SSO 服务器端配置,接下来,我们来讨论客户端的实现过程。在客户端中,我们同样创建一个继承了 WebSecurityConfigurerAdapter 的 WebSecurityConfiguration,用来设置认证和授权机制,如下所示:

@EnableOAuth2Sso
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {
 
    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.logout()
                .and()
                .authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .csrf().disable();
    }
}

这里唯一需要强调的就是 @EnableOAuth2Sso 注解,这是单点登录相关自动化配置的入口,定义如下:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@EnableOAuth2Client
@EnableConfigurationProperties(OAuth2SsoProperties.class)
@Import({ OAuth2SsoDefaultConfiguration.class, OAuth2SsoCustomConfiguration.class,
        ResourceServerTokenServicesConfiguration.class })
public @interface EnableOAuth2Sso {
 
}

在 @EnableOAuth2Sso 注解上,我们找到了 @EnableOAuth2Client 注解,代表启用了 OAuth2Client 客户端。同时,OAuth2SsoDefaultConfiguration 和 OAuth2SsoCustomConfiguration 用来配置基于 OAuth2 的 SSO 行为,而在 ResourceServerTokenServicesConfiguration 中则配置了基于 JWT 来处理 Token 的相关操作。

接着,我们在 app1 客户端的 application.yml 配置文件中,添加如下配置项:

server:
  port: 8080
  servlet:
    context-path: /app1

这里用到了 server.servlet.context-path 配置项,用来设置应用的上下文路径,相当于为完整的URL地址添加了一个前缀。这样,原本访问“http://localhost:8080/login”的地址就会变成http://localhost:8080/app1/login,这是使用 SSO 时的一个常见的技巧。

然后,我们再在配置文件中添加如下配置项:

security:
  oauth2:
    client:
      client-id: app1
      client-secret: app1_secret
      access-token-uri: http://localhost:8888/oauth/token
      user-authorization-uri: http://localhost:8888/oauth/authorize
    resource:
      jwt:
        key-uri: http://localhost:8888/oauth/token_key

这些配置项是针对 OAuth2 协议的专用配置项,我们看到了用于设置客户端信息的“client”配置段,除了客户端Id和密码之外,还指定了用于获取 token 的“access-token-uri”地址以及执行授权的“user-authorization-uri”地址,这些都应该指向前面已经创建的 SSO 服务器地址。

另一方面,一旦在配置文件中添加了“security.oauth2.resource.jwt”配置项,对Token的校验就会使用 JwtTokenStore了,这样就能跟SSO服务器端所创建的 JwtTokenStore 进行对应。

到目前为止,我们已经创建了一个 SSO 客户端应用 app1,而创建 app2 的过程是完全一样的,这里不再展开。完整的代码你可以参考https://github.com/lagouEdAnna/SpringSecurity-jianxiang/tree/main/SpringSsoDemo

案例演示

最后,让我们演示一下整个单点登录过程。依次启动 SSO 服务器以及 app1 和 app2,然后在浏览器中访问 app1 地址http://localhost:8080/app1/system/profile,这时候浏览器就会重定向到 SSO 服务器登录页面。

请注意,如果我们在访问上述地址时打开了浏览器的“网络”标签并查看其访问路径,就可以看到确实是先跳转到了app1的登录页面(http://localhost:8080/app1/login),然后又重定向到 SSO 服务器。由于用户处于未登录状态,所以最后又重定向到 SSO 服务器的登录界面(http://localhost:8888/login),整个请求的跳转过程如下图所示:

17-2.png

未登录状态访问 app1 时的网络请求跳转流程图

我们在 SSO 服务器的登录界面输入正确的用户名和密码之后就可以认证成功了,这时候我们再看网络请求的过程,如下所示:

17-3png.png

登录 app1 过程的网络请求跳转流程图

可以看到,在成功登录之后,授权系统重定向到 app1 中配置的回调地址(http://localhost:8080/app1/login)。与此同时,我们在请求地址中还发现了两个新的参数 code 和 state。app1 客户端就会根据这个 code 来访问 SSO 服务器的/oauth/token 接口来申请 token。申请成功后,重定向到 app1 配置的回调地址。

现在,如果你访问 app2,与第一次访问 app1 相同,浏览器先重定向到 app2 的登录页面,然后又重定向到 SSO 服务器的授权链接,最后直接就重新重定向到 app2 的登录页面。不同之处在于,此次访问并不需要再次重定向到 SSO 服务器进行登录,而是成功访问 SSO 服务器的授权接口,并携带着 code 重定向到 app2 的回调路径。然后 app2 根据 code 再次访问 /oauth/token 接口拿到 token,这样就可以正常访问受保护的资源了。

小结与预告

本课时是相对独立的一部分内容,针对日常开发过程中经常碰到的单点登录场景做了案例的设计和实现。我们可以把各个独立的系统看成一个个客户端,然后基于 OAuth2 协议来实现 SSO。此外,本课时还对如何构建 SSO 服务器端和客户端组件,以及两者之间的交互过程进行了详细的介绍。

这里给你留一道思考题:你能结合 OAuth2 协议描述 SSO 的整体工作流程吗?

介绍完 OAuth2 协议以及应用场景之后,我们将引入一个全新的话题,既响应式编程,这是一种技术趋势。下一课时我们将讨论如何为 Spring Security 添加响应式编程特性。


  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值