> 文档中心 > 彻底搞懂 Spring Cloud 前世今生系列之第⼀代 Spring Cloud 核⼼组件 — 微服务监控和统一授权

彻底搞懂 Spring Cloud 前世今生系列之第⼀代 Spring Cloud 核⼼组件 — 微服务监控和统一授权


常⻅问题及解决⽅案

本部分主要讲解 Eureka 服务发现慢的原因,Spring Cloud 超时设置问题。
如果你刚刚接触Eureka,对Eureka的设计和实现都不是很了解,可能就会遇到⼀些⽆法快速解决的问题,这些问题包括:新服务上线后,服务消费者不能访问到刚上线的新服务,需要过⼀段时间后才能访问?或是将服务下线后,服务还是会被调⽤到,⼀段时候后才彻底停⽌服务,访问前期会导致频繁报错?这些问题还会让你对 Spring Cloud 产⽣严重的怀疑,这难道不是⼀个 Bug?

问题场景

  • 上线⼀个新的服务实例,但是服务消费者⽆感知,过了⼀段时间才知道
  • 某⼀个服务实例下线了,服务消费者⽆感知,仍然向这个服务实例在发起请求

这其实就是服务发现的⼀个问题,当我们需要调⽤服务实例时,信息是从注册中⼼Eureka获取的,然后通过Ribbon选择⼀个服务实例发起调⽤,如果出现调⽤不到或者下线后还可以调⽤的问题,原因肯定是服务实例的信息更新不及时导致的。

Eureka 服务发现慢的原因

Eureka 服务发现慢的原因主要有两个,

  1. ⼀部分是因为服务缓存导致的,
  2. 另⼀部分是因为客户端缓存导致的。

在这里插入图片描述

  1. 服务端缓存
    服务注册到注册中⼼后,服务实例信息是存储在注册表中的,也就是内存中。但Eureka为了提⾼响应速度,在内部做了优化,加⼊了两层的缓存结构,将Client需要的实例信息,直接缓存起来,获取的时候直接从缓存中拿数据然后响应给 Client。

    1. 第⼀层缓存是readOnlyCacheMap,readOnlyCacheMap是采⽤ConcurrentHashMap来存储数据的,主要负责定时与readWriteCacheMap进⾏数据同步,默认同步时间为 30 秒⼀次。
    2. 第⼆层缓存是readWriteCacheMap,readWriteCacheMap采⽤Guava来实现缓存。缓存过期时间默认为180秒,当服务下线、过期、注册、状态变更等操作都会清除此缓存中的数据。

    Client获取服务实例数据时,会先从⼀级缓存中获取,如果⼀级缓存中不存在,再从⼆级缓存中获取,如果⼆级缓存也不存在,会触发缓存的加载,从存储层拉取数据到缓存中,然后再返回给 Client。
    Eureka 之所以设计⼆级缓存机制,也是为了提⾼ Eureka Server 的响应速度,缺点是缓存会导致 Client获取不到最新的服务实例信息,然后导致⽆法快速发现新的服务和已下线的服务。

    了解了服务端的实现后,想要解决这个问题就变得很简单了,我们可以缩短只读缓存的更新时间(eureka.server.response-cache-update-interval-ms)让服务发现变得更加及时,或者直接将只读缓存关闭(eureka.server.use-read-only-response-cache=false),多级缓存也导致C层⾯(数据⼀致性)很薄弱。

    Eureka Server 中会有定时任务去检测失效的服务,将服务实例信息从注册表中移除,也可以将这个失效检测的时间缩短,这样服务下线后就能够及时从注册表中清除。

  2. 客户端缓存 客户端缓存主要分为两块内容,⼀块是 Eureka Client 缓存,⼀块是 Ribbon 缓存。

    1. Eureka Client 缓存
      EurekaClient负责跟EurekaServer进⾏交互,在EurekaClient中的com.netflix.discovery.DiscoveryClient.initScheduledTasks() ⽅法中,初始化了⼀个
      CacheRefreshThread 定时任务专⻔⽤来拉取 Eureka Server 的实例信息到本地。

      所以我们需要缩短这个定时拉取服务信息的时间间隔(eureka.client.registryFetchIntervalSeconds)来快速发现新的服务。

    2. Ribbon 缓存
      Ribbon会从EurekaClient中获取服务信息,ServerListUpdater是Ribbon中负责服务实例更新的组件,默认的实现是PollingServerListUpdater,通过线程定时去更新实例信息。定时刷新的时间间隔默认是30秒,当服务停⽌或者上线后,这边最快也需要30秒才能将实例信息更新成最新的。我们可以将这个时间调短⼀点,⽐如 3 秒。

      刷新间隔的参数是通过 getRefreshIntervalMs ⽅法来获取的,⽅法中的逻辑也是从 Ribbon 的配置中进⾏取值的。

将这些服务端缓存和客户端缓存的时间全部缩短后,跟默认的配置时间相⽐,快了很多。我们通过调整参数的⽅式来尽量加快服务发现的速度,但是还是不能完全解决报错的问题,间隔时间设置为3秒,也还是会有间隔。所以我们⼀般都会开启重试功能,当路由的服务出现问题时,可以重试到另⼀个服务来保证这次请求的成功。

Spring Cloud 各组件超时

在SpringCloud中,应⽤的组件较多,只要涉及通信,就有可能会发⽣请求超时。那么如何设置超时时间? 在 Spring Cloud 中,超时时间只需要重点关注 Ribbon 和 Hystrix 即可。

  1. Ribbon
    如果采⽤的是服务发现⽅式,就可以通过服务名去进⾏转发,需要配置Ribbon的超时。
    Rbbon的超时可以配置全局的ribbon.ReadTimeout和ribbon.ConnectTimeout。也可以在前⾯指定服务名,为每个服务单独配置,⽐如 user-service.ribbon.ReadTimeout。

  2. Hystrix
    其次是Hystrix的超时配置,Hystrix的超时时间要⼤于Ribbon的超时时间,因为Hystrix将请求包装了起来,特别需要注意的是,如果Ribbon开启了重试机制,⽐如重试3 次,Ribbon 的超时为 1 秒,那么Hystrix 的超时时间应该⼤于 3 秒,否则就会出现 Ribbon 还在重试中,⽽ Hystrix 已经超时的现象。

    Hystrix全局超时配置就可以⽤default来代替具体的command名称。

    hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=3000 如果想对具体的command 进⾏配置,那么就需要知道 command 名称的⽣成规则,才能准确的配置。
    如果我们使⽤ @HystrixCommand 的话,可以⾃定义 commandKey。

    如果使⽤FeignClient的话,可以为FeignClient来指定超时时间:
    hystrix.command.UserRemoteClient.execution.isolation.thread.timeoutInMilliseconds = 3000
    如果想对FeignClient中的某个接⼝设置单独的超时,可以在FeignClient名称后加上具体的⽅法:hystrix.command.UserRemoteClient#getUser(Long).execution.isolation.thread.timeoutInMilliseconds = 3000

  3. Feign
    Feign本身也有超时时间的设置,如果此时设置了Ribbon的时间就以Ribbon的时间为准,如果没设置Ribbon的时间但配置了Feign的时间,就以Feign的时间为准。Feign的时间同样也配置了连接超时时间(feign.client.config.服务名称.connectTimeout)和读取超时时间(feign.client.config.服务名称.readTimeout)。

建议,我们配置Ribbon超时时间和Hystrix超时时间即可。

微服务监控

微服务监控之 Turbine 聚合监控

这部分参考文章 《彻底搞懂 Spring Cloud 前世今生 系列之 第⼀代 Spring Cloud 核⼼组件 — Hystrix熔断器》

微服务监控之分布式链路追踪技术 Sleuth + Zipkin

分布式链路追踪技术适⽤场景(问题场景)

  • 场景描述
    为了⽀撑⽇益增⻓的庞⼤业务量,我们会使⽤微服务架构设计我们的系统,使得我们的系统不仅能够通过集群部署抵挡流量的冲击,⼜能根据业务进⾏灵活的扩展。

    那么,在微服务架构下,⼀次请求少则经过三四次服务调⽤完成,多则跨越⼏⼗个甚⾄是上百个服务节点。那么问题接踵⽽来:

    1. 如何动态展示服务的调⽤链路?(⽐如A服务调⽤了哪些其他的服务—依赖关系)
    2. 如何分析服务调⽤链路中的瓶颈节点并对其进⾏调优?(⽐如A—>B—>C,C服务处理时间特别⻓)
    3. 如何快速进⾏服务链路的故障发现?

这就是分布式链路追踪技术存在的⽬的和意义

  • 分布式链路追踪技术
    如果我们在⼀个请求的调⽤处理过程中,在各个链路节点都能够记录下⽇志,并最终将⽇志进⾏集中可视化展示,那么我们想监控调⽤链路中的⼀些指标就有希望了~~~⽐如,请求到达哪个服务实例?请求被处理的状态怎样?处理耗时怎样?这些都能够分析出来了…

    分布式环境下基于这种想法实现的监控技术就是就是分布式链路追踪(全链路追踪)。

  • 市场上的分布式链路追踪⽅案
    分布式链路追踪技术已然成熟,产品也不少,国内外都有,⽐如

    1. Spring Cloud Sleuth + Twitter Zipkin
    2. 阿⾥巴巴的“鹰眼”
    3. ⼤众点评的“CAT”
    4. 美团的“Mtrace”
    5. 京东的“Hydra”
    6. 新浪的“Watchman”
    7. Apache Skywalking。

分布式链路追踪技术核⼼思想

本质:记录⽇志,作为⼀个完整的技术,分布式链路追踪也有⾃⼰的理论和概念

微服务架构中,针对请求处理的调⽤链可以展现为⼀棵树,示意如下
在这里插入图片描述
上图描述了⼀个常⻅的调⽤场景,⼀个请求通过⽹关服务路由到下游的微服务-1,然后微服务-1调⽤微服务-2,拿到结果后再调⽤微服务-3,最后组合微服务-2和微服务-3的结果,通过⽹关返回给⽤户

为了追踪整个调⽤链路,肯定需要记录⽇志,⽇志记录是基础,在此之上肯定有⼀些理论概念,当下主流的的分布式链路追踪技术/系统所基于的理念都来⾃于Google的⼀篇论文《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》,这⾥⾯涉及到的核⼼理念是什么,我们来看下
在这里插入图片描述
上图标识⼀个请求链路,⼀条链路通过TraceId唯⼀标识,span标识发起的请求信息,各span通过parrentId关联起来

  • Trace:
    服务追踪的追踪单元是从客户发起请求(request)抵达被追踪系统的边界开始,到被追踪系统
    向客户返回响应(response)为⽌的过程

  • Trace ID:
    为了实现请求跟踪,当请求发送到分布式系统的⼊⼝端点时,只需要服务跟踪框架为该请求
    创建⼀个唯⼀的跟踪标识Trace ID,同时在分布式系统内部流转的时候,框架失踪保持该唯⼀标识,直到返回给请求⽅

    ⼀个Trace由⼀个或者多个Span组成,每⼀个Span都有⼀个SpanId,Span中会记录TraceId,同时还有⼀个叫做ParentId,指向了另外⼀个Span的SpanId,表明⽗⼦关系,其实本质表达了依赖关系

  • Span ID:
    为了统计各处理单元的时间延迟,当请求到达各个服务组件时,也是通过⼀个唯⼀标识Span
    ID来标记它的开始,具体过程以及结束。对每⼀个Span来说,它必须有开始和结束两个节点,通过记录开始Span和结束Span的时间戳,就能统计出该Span的时间延迟,除了时间戳记录之外,它还可以包含⼀些其他元数据,⽐如时间名称、请求信息等。

    每⼀个Span都会有⼀个唯⼀跟踪标识 Span ID,若⼲个有序的 span 就组成了⼀个 trace。
    Span可以认为是⼀个⽇志数据结构,在⼀些特殊的时机点会记录了⼀些⽇志信息,⽐如有时间戳、spanId、TraceId,parentIde等,Span中也抽象出了另外⼀个概念,叫做事件,核⼼事件如下

    • CS :client send/start 客户端/消费者发出⼀个请求,描述的是⼀个span开始
    • SR: server received/start 服务端/⽣产者接收请求 SR-CS属于请求发送的⽹络延迟
    • SS: server send/finish 服务端/⽣产者发送应答 SS-SR属于服务端消耗时间
    • CR:client received/finished 客户端/消费者接收应答 CR-SS表示回复需要的时间(响应的⽹络延迟)

Spring Cloud Sleuth (追踪服务框架)可以追踪服务之间的调⽤,Sleuth可以记录⼀个服务请求经过哪些服务、服务处理时⻓等,根据这些,我们能够理清各微服务间的调⽤关系及进⾏问题追踪分析。

  • 耗时分析:通过 Sleuth 了解采样请求的耗时,分析服务性能问题(哪些服务调⽤⽐较耗时)

  • 链路优化:发现频繁调⽤的服务,针对性优化等

    Sleuth就是通过记录⽇志的⽅式来记录踪迹数据的

注意:我们往往把Spring Cloud Sleuth 和 Zipkin ⼀起使⽤,把 Sleuth 的数据信息发送给 Zipkin 进⾏聚合,利⽤ Zipkin 存储并展示数据。

在这里插入图片描述
在这里插入图片描述

Sleuth + Zipkin 实战

  1. 每⼀个需要被追踪踪迹的微服务⼯程都引⼊依赖坐标

    <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-sleuth</artifactId></dependency>
  2. 每⼀个微服务都修改application.yml配置⽂件,添加⽇志级别

    #分布式链路追踪logging:  level:    org.springframework.web.servlet.DispatcherServlet: debug    org.springframework.cloud.sleuth: debug

    请求到来时,我们在控制台可以观察到 Sleuth 输出的⽇志(全局 TraceId、SpanId等)。
    彻底搞懂 Spring Cloud 前世今生系列之第⼀代 Spring Cloud 核⼼组件 — 微服务监控和统一授权
    这样的⽇志⾸先不容易阅读观察,另外⽇志分散在各个微服务服务器上,接下来我们使⽤zipkin统⼀聚合轨迹⽇志并进⾏存储展示。

  3. 结合 Zipkin 展示追踪数据
    Zipkin 包括Zipkin Server和 Zipkin Client两部分,Zipkin Server是⼀个单独的服务,Zipkin Client就是具体的微服务

    1. Zipkin Server 构建
      1. pom.xml

        <dependency> <groupId>io.zipkin.java</groupId> <artifactId>zipkin-server</artifactId> <version>2.12.3</version> <exclusions><exclusion> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-log4j2</artifactId></exclusion></exclusions></dependency><dependency> <groupId>io.zipkin.java</groupId> <artifactId>zipkin-autoconfigure-ui</artifactId> <version>2.12.3</version></dependency>
      2. ⼊⼝启动类

        @SpringBootApplication@EnableZipkinServer // 开启Zipkin Server功能public class ZipkinServerApplication9411 {public static void main(String[] args) {SpringApplication.run(ZipkinServerApplication9411.class,args);}}
      3. application.yml

        server:  port: 9411management:  metrics:    web:      server:     auto-time-requests: false # 关闭⾃动检测请求
    2. Zipkin Client 构建(在具体微服务中修改)
      1. pom中添加 zipkin 依赖

        <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-zipkin</artifactId></dependency>
      2. application.yml 中添加对zipkin server的引⽤

        spring:  application:    name: learn-service-autodeliver  zipkin:    base-url: http://127.0.0.1:9411 # zipkin server的请求地址    sender:      # web 客户端将踪迹⽇志数据通过⽹络请求的⽅式传送到服务端,另外还有配置      # kafka/rabbit 客户端将踪迹⽇志数据传递到mq进⾏中转      type: web  sleuth:    sampler:  # 采样率 1 代表100%全部采集 ,默认0.1 代表10% 的请求踪迹数据会被采集  # ⽣产环境下,请求量⾮常⼤,没有必要所有请求的踪迹数据都采集分析,对于⽹络包括server端压⼒都是⽐较⼤的,可以配置采样率采集⼀定⽐例的请求的踪迹数据进⾏分析即可   probability: 1

    另外,对于log⽇志,依然保持开启debug状态

    1. Zipkin server ⻚⾯⽅便我们查看服务调⽤依赖关系及⼀些性能指标和异常信息
    2. 追踪数据Zipkin持久化到mysql
      1. mysql中创建名称为zipkin的数据库,并执⾏如下sql语句(官⽅提供)

        ---- Copyright 2015-2019 The OpenZipkin Authors---- Licensed under the Apache License, Version 2.0 (the "License"); youmay not use this file except-- in compliance with the License. You may obtain a copy of theLicense at---- http://www.apache.org/licenses/LICENSE-2.0---- Unless required by applicable law or agreed to in writing, softwaredistributed under the License-- is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES ORCONDITIONS OF ANY KIND, either express-- or implied. See the License for the specific language governingpermissions and limitations under-- the License.--CREATE TABLE IF NOT EXISTS zipkin_spans ( `trace_id_high` BIGINT NOT NULL DEFAULT 0 COMMENT 'If non zero, thismeans the trace uses 128 bit traceIds instead of 64 bit', `trace_id` BIGINT NOT NULL, `id` BIGINT NOT NULL, `name` VARCHAR(255) NOT NULL, `remote_service_name` VARCHAR(255), `parent_id` BIGINT, `debug` BIT(1), `start_ts` BIGINT COMMENT 'Span.timestamp(): epoch micros used forendTs query and to implement TTL', `duration` BIGINT COMMENT 'Span.duration(): micros used forminDuration and maxDuration query', PRIMARY KEY (`trace_id_high`, `trace_id`, `id`)) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8 COLLATEutf8_general_ci;ALTER TABLE zipkin_spans ADD INDEX(`trace_id_high`, `trace_id`)COMMENT 'for getTracesByIds';ALTER TABLE zipkin_spans ADD INDEX(`name`) COMMENT 'for getTraces andgetSpanNames';ALTER TABLE zipkin_spans ADD INDEX(`remote_service_name`) COMMENT 'forgetTraces and getRemoteServiceNames';ALTER TABLE zipkin_spans ADD INDEX(`start_ts`) COMMENT 'for getTracesordering and range';CREATE TABLE IF NOT EXISTS zipkin_annotations ( `trace_id_high` BIGINT NOT NULL DEFAULT 0 COMMENT 'If non zero, thismeans the trace uses 128 bit traceIds instead of 64 bit', `trace_id` BIGINT NOT NULL COMMENT 'coincides withzipkin_spans.trace_id', `span_id` BIGINT NOT NULL COMMENT 'coincides with zipkin_spans.id', `a_key` VARCHAR(255) NOT NULL COMMENT 'BinaryAnnotation.key orAnnotation.value if type == -1', `a_value` BLOB COMMENT 'BinaryAnnotation.value(), which must besmaller than 64KB', `a_type` INT NOT NULL COMMENT 'BinaryAnnotation.type() or -1 ifAnnotation', `a_timestamp` BIGINT COMMENT 'Used to implement TTL;Annotation.timestamp or zipkin_spans.timestamp', `endpoint_ipv4` INT COMMENT 'Null when Binary/Annotation.endpoint isnull', `endpoint_ipv6` BINARY(16) COMMENT 'Null whenBinary/Annotation.endpoint is null, or no IPv6 address', `endpoint_port` SMALLINT COMMENT 'Null whenBinary/Annotation.endpoint is null', `endpoint_service_name` VARCHAR(255) COMMENT 'Null whenBinary/Annotation.endpoint is null') ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8 COLLATEutf8_general_ci;ALTER TABLE zipkin_annotations ADD UNIQUE KEY(`trace_id_high`,`trace_id`, `span_id`, `a_key`, `a_timestamp`) COMMENT 'Ignore inserton duplicate';ALTER TABLE zipkin_annotations ADD INDEX(`trace_id_high`, `trace_id`,`span_id`) COMMENT 'for joining with zipkin_spans';ALTER TABLE zipkin_annotations ADD INDEX(`trace_id_high`, `trace_id`)COMMENT 'for getTraces/ByIds';ALTER TABLE zipkin_annotations ADD INDEX(`endpoint_service_name`)COMMENT 'for getTraces and getServiceNames';ALTER TABLE zipkin_annotations ADD INDEX(`a_type`) COMMENT 'forgetTraces and autocomplete values';ALTER TABLE zipkin_annotations ADD INDEX(`a_key`) COMMENT 'forgetTraces and autocomplete values';ALTER TABLE zipkin_annotations ADD INDEX(`trace_id`, `span_id`,`a_key`) COMMENT 'for dependencies job';CREATE TABLE IF NOT EXISTS zipkin_dependencies ( `day` DATE NOT NULL, `parent` VARCHAR(255) NOT NULL, `child` VARCHAR(255) NOT NULL, `call_count` BIGINT, `error_count` BIGINT, PRIMARY KEY (`day`, `parent`, `child`)) ENGINE=InnoDB ROW_FORMAT=COMPRESSED CHARACTER SET=utf8 COLLATEutf8_general_ci;
      2. pom⽂件引⼊相关依赖

        <dependency> <groupId>io.zipkin.java</groupId> <artifactId>zipkin-autoconfigure-storagemysql</artifactId> <version>2.12.3</version></dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId></dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.1.10</version></dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId></dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId></dependency>
      3. 修改配置⽂件,添加如下内容

        spring:  datasource:    driver-class-name: com.mysql.jdbc.Driver    url: jdbc:mysql://localhost:3306/zipkin?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true    username: root    password: 123456    druid:   initialSize: 10   minIdle: 10  maxActive: 30   maxWait: 50000# 指定zipkin持久化介质为mysqlzipkin:  storage:    type: mysql
      4. 启动类中注⼊事务管理器

        @Beanpublic PlatformTransactionManager txManager(DataSource dataSource) {return new DataSourceTransactionManager(dataSource);}

微服务统⼀认证⽅案 Spring Cloud OAuth2 + JWT

认证:验证⽤户的合法身份,⽐如输⼊⽤户名和密码,系统会在后台验证⽤户名和密码是否合法,合法的前提下,才能够进⾏后续的操作,访问受保护的资源

微服务架构下统⼀认证场景

分布式系统的每个服务都会有认证需求,如果每个服务都实现⼀套认证逻辑会⾮常冗余,考虑分布式系统共享性的特点,需要由独⽴的认证服务处理系统认证的请求。
在这里插入图片描述

微服务统⼀认证⽅案 Spring Cloud OAuth2 + JWT

微服务架构下统⼀认证思路

  • 基于Session的认证⽅式
    在分布式的环境下,基于session的认证会出现⼀个问题,每个应⽤服务都需要在session中存储⽤户身份信息,通过负载均衡将本地的请求分配到另⼀个应⽤服务需要将session信息带过去,否则会重新认证。我们可以使⽤Session共享、Session黏贴等⽅案。

    Session⽅案也有缺点,⽐如基于cookie,移动端不能有效使⽤等

  • 基于token的认证⽅式
    基于token的认证⽅式,服务端不⽤存储认证数据,易维护扩展性强, 客户端可以把token 存在任意地⽅,并且可以实现web和app统⼀认证机制。

    其缺点也很明显,token由于⾃包含信息,因此⼀般数据量较⼤,⽽且每次请求 都需要传递,因此⽐较占带宽。另外,token的签名验签操作也会给cpu带来额外的处理负担。

OAuth2开放授权协议/标准

OAuth2介绍

OAuth(开放授权)是⼀个开放协议/标准,允许⽤户授权第三⽅应⽤访问他们存储在另外的服务提供者上的信息,⽽不需要将⽤户名和密码提供给第三⽅应⽤或分享他们数据的所有内容。

允许⽤户授权第三⽅应⽤访问他们存储在另外的服务提供者上的信息,⽽不需要将⽤户名和密码提供给第三⽅应⽤或分享他们数据的所有内容

结合“使⽤QQ登录CSDN”这个场景拆分理解上述那句话

  • ⽤户:我们⾃⼰
  • 第三⽅应⽤:CSDN
  • 另外的服务提供者:QQ

OAuth2是OAuth协议的延续版本,但不向后兼容OAuth1即完全废⽌了OAuth1

OAuth2协议⻆⾊和流程

CSDN要开发使⽤QQ登录这个功能的话,那么CSDN是需要提前到QQ平台进⾏登记的(否则QQ凭什么陪着CSDN玩授权登录这件事)

  1. CSDN——登记——>QQ平台
  2. QQ 平台会颁发⼀些参数给CSDN,后续上线进⾏授权登录的时候(刚才打开授权⻚⾯)需要携带这些参数
    • client_id :客户端id(QQ最终相当于⼀个认证授权服务器,CSDN就相当于⼀个客户端了,所以会给⼀个客户端id),相当于账号
    • secret:相当于密码

在这里插入图片描述

  • 资源所有者(Resource Owner):可以理解为⽤户⾃⼰
  • 客户端(Client):我们想登陆的⽹站或应⽤,⽐如CSDN
  • 认证服务器(Authorization Server):可以理解为微信或者QQ
  • 资源服务器(Resource Server):可以理解为微信或者QQ
什么情况下需要使⽤OAuth2?

第三⽅授权登录的场景:⽐如,我们经常登录⼀些⽹站或者应⽤的时候,可以选择使⽤第三⽅授权登录的⽅式,⽐如:微信授权登录、QQ授权登录、微博授权登录等,这是典型的 OAuth2 使⽤场景。

单点登录的场景:如果项⽬中有很多微服务或者公司内部有很多服务,可以专⻔做⼀个认证中⼼(充当认证平台⻆⾊),所有的服务都要到这个认证中⼼做认证,只做⼀次登录,就可以在多个授权范围内的服务中⾃由串⾏。

OAuth2的颁发Token授权⽅式
  1. 授权码(authorization-code)
  2. 密码式(password)提供⽤户名+密码换取token令牌
  3. 隐藏式(implicit)
  4. 客户端凭证(client credentials)

授权码模式使⽤到了回调地址,是最复杂的授权⽅式,微博、微信、QQ等第三⽅登录就是这种模式。我们重点讲解接⼝对接中常使⽤的password密码模式(提供⽤户名+密码换取token)。

Spring Cloud OAuth2 + JWT 实现

Spring Cloud OAuth2介绍

Spring Cloud OAuth2 是 Spring Cloud 体系对OAuth2协议的实现,可以⽤来做多个微服务的统⼀认证(验证身份合法性)授权(验证权限)。通过向OAuth2服务(统⼀认证授权服务)发送某个类型的grant_type进⾏集中认证和授权,从⽽获得access_token(访问令牌),⽽这个token是受其他微服务信任的。

注意:使⽤OAuth2解决问题的本质是,引⼊了⼀个认证授权层,认证授权层连接了资源的拥有者,在授权层⾥⾯,资源的拥有者可以给第三⽅应⽤授权去访问我们的某些受保护资源。

Spring Cloud OAuth2构建微服务统⼀认证服务思路

在这里插入图片描述
注意:在我们统⼀认证的场景中,Resource Server其实就是我们的各种受保护的微服务,微服务中的各种API访问接⼝就是资源,发起http请求的浏览器就是Client客户端(对应为第三⽅应⽤)

搭建认证服务器(Authorization Server)

认证服务器(Authorization Server),负责颁发token

  1. 新建项⽬learn-cloud-oauth-server-9999

  2. pom.xml

    <project xmlns="http://maven.apache.org/POM/4.0.0"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0http://maven.apache.org/xsd/maven-4.0.0.xsd"> <parent> <artifactId>learn-parent</artifactId> <groupId>com.learn.edu</groupId> <version>1.0-SNAPSHOT</version></parent> <modelVersion>4.0.0</modelVersion> <artifactId>learn-cloud-oauth2-server-9999</artifactId> <dependencies><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eurekaclient</artifactId></dependency><dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-oauth2</artifactId> <exclusions> <exclusion> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2- autoconfigure</artifactId></exclusion></exclusions></dependency> <dependency> <groupId>org.springframework.security.oauth.boot</groupId> <artifactId>spring-security-oauth2-autoconfigure</artifactId> <version>2.1.11.RELEASE</version></dependency> <dependency><groupId>org.springframework.security.oauth</groupId> <artifactId>spring-security-oauth2</artifactId> <version>2.3.4.RELEASE</version></dependency></dependencies></project>
  3. application.yml(构建认证服务器,配置⽂件⽆特别之处)

    server:  port: 9999Spring:  application:    name: learn-cloud-oauth-servereureka:  client:    serviceUrl: # eureka server的路径    defaultZone: http://learncloudeurekaservera:8761/eureka/,http://learncloudeurekaserverb:8762/eureka/ #把 eureka 集群中的所有 url 都填写了进来,也可以只写⼀台,因为各个eureka server 可以同步注册表  instance: #使⽤ip注册,否则会使⽤主机名注册了(此处考虑到对⽼版本的兼容,新版本经过实验都是ip)    prefer-ip-address: true#⾃定义实例显示格式,加上版本号,便于多版本管理,注意是ip-address,早期版本是ipAddress instance-id: ${spring.cloud.client.ipaddress}:${spring.application.name}:${server.port}:@project.version@
  4. ⼊⼝类⽆特殊之处

  5. 认证服务器配置类

    /*** 当前类为Oauth2 server的配置类(需要继承特定的⽗类AuthorizationServerConfigurerAdapter)*/@Configuration@EnableAuthorizationServer // 开启认证服务器功能public class OauthServerConfiger extends AuthorizationServerConfigurerAdapter {@Autowiredprivate AuthenticationManager authenticationManager;/*** 认证服务器最终是以api接⼝的⽅式对外提供服务(校验合法性并⽣成令牌、校验令牌等)* 那么,以api接⼝⽅式对外的话,就涉及到接⼝的访问权限,我们需要在这⾥进⾏必要的配置* @param security* @throws Exception*/@Overridepublic void configure(AuthorizationServerSecurityConfigurer security) throws Exception {super.configure(security);// 相当于打开endpoints 访问接⼝的开关,这样的话后期我们能够访问该接⼝ security// 允许客户端表单认证 .allowFormAuthenticationForClients()// 开启端⼝/oauth/token_key的访问权限(允许) .tokenKeyAccess("permitAll()")// 开启端⼝/oauth/check_token的访问权限(允许) .checkTokenAccess("permitAll()"); }/*** 客户端详情配置,* ⽐如client_id,secret* 当前这个服务就如同QQ平台,拉勾⽹作为客户端需要qq平台进⾏登录授权认证等,提前需要到QQ平台注册,QQ平台会给拉勾⽹* 颁发client_id等必要参数,表明客户端是谁* @param clients* @throws Exception*/@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {super.configure(clients);// 客户端信息存储在什么地⽅,可以在内存中,可以在数据库⾥clients.inMemory()// 添加⼀个client配置,指定其client_id .withClient("client_learn")  .secret("abcxyz") // 指定客户端的密码/安全码 .resourceIds("autodeliver") // 指定客户端所能访问资源id清单,此处的资源id是需要在具体的资源服务器上也配置⼀样// 认证类型/令牌颁发模式,可以配置多个在这⾥,但是不⼀定都⽤,具体使⽤哪种⽅式颁发token,需要客户端调⽤的时候传递参数指定 .authorizedGrantTypes("password","refresh_token")// 客户端的权限范围,此处配置为all全部即可 .scopes("all");} /*** 认证服务器是玩转token的,那么这⾥配置token令牌管理相关(token此时就是⼀个字符串,当下的token需要在服务器端存储,* 那么存储在哪⾥呢?都是在这⾥配置)* @param endpoints* @throws Exception*/@Overridepublic void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {super.configure(endpoints);endpoints .tokenStore(tokenStore()) // 指定token的存储⽅法 .tokenServices(authorizationServerTokenServices()) //token服务的⼀个描述,可以认为是token⽣成细节的描述,⽐如有效时间多少等 .authenticationManager(authenticationManager) // 指定认证管理器,随后注⼊⼀个到当前类使⽤即可   .allowedTokenEndpointRequestMethods(HttpMethod.GET,HttpMethod.POST);} /*该⽅法⽤于创建tokenStore对象(令牌存储对象)token以什么形式存储*/public TokenStore tokenStore(){return new InMemoryTokenStore();}/*** 该⽅法⽤户获取⼀个token服务对象(该对象描述了token有效期等信息)*/public AuthorizationServerTokenServices authorizationServerTokenServices() {// 使⽤默认实现DefaultTokenServices defaultTokenServices = new DefaultTokenServices();defaultTokenServices.setSupportRefreshToken(true); // 是否开启令牌刷新defaultTokenServices.setTokenStore(tokenStore());// 设置令牌有效时间(⼀般设置为2个⼩时)defaultTokenServices.setAccessTokenValiditySeconds(20); // access_token就是我们请求资源需要携带的令牌// 设置刷新令牌的有效时间defaultTokenServices.setRefreshTokenValiditySeconds(259200); // 3天return defaultTokenServices;}}
    • 关于三个configure⽅法

      • configure(ClientDetailsServiceConfigurer clients)
        ⽤来配置客户端详情服务(ClientDetailsService),客户端详情信息在 这⾥进⾏初始化,你能够把客户端详情信息写死在这⾥或者是通过数据库来存储调取详情信息
      • configure(AuthorizationServerEndpointsConfigurer endpoints)
        ⽤来配置令牌(token)的访问端点和令牌服务(token services)
      • configure(AuthorizationServerSecurityConfigurer oauthServer)
        ⽤来配置令牌端点的安全约束.
    • 关于 TokenStore

      • InMemoryTokenStore
        默认采⽤,它可以完美的⼯作在单服务器上(即访问并发量 压⼒不⼤的情况下,并且它在失败的时候不会进⾏备份),⼤多数的项⽬都可以使⽤这个版本的实现来进⾏ 尝试,你可以在开发的时候使⽤它来进⾏管理,因为不会被保存到磁盘中,所以更易于调试。
      • JdbcTokenStore
        这是⼀个基于JDBC的实现版本,令牌会被保存进关系型数据库。使⽤这个版本的实现时, 你可以在不同的服务器之间共享令牌信息,使⽤这个版本的时候请注意把"springjdbc"这个依赖加⼊到你的 classpath当中。
      • JwtTokenStore 这个版本的全称是 JSON Web Token(JWT),它可以把令牌相关的数据进⾏编码(因此对于后端服务来说,它不需要进⾏存储,这将是⼀个重⼤优势),缺点就是这个令牌占⽤的空间会⽐较⼤,如果你加⼊了⽐较多⽤户凭证信息,JwtTokenStore 不会保存任何数据。
  6. 认证服务器安全配置类

    /*** 该配置类,主要处理⽤户名和密码的校验等事宜*/@Configurationpublic class SecurityConfiger extends WebSecurityConfigurerAdapter {/*** 注册⼀个认证管理器对象到容器*/@Bean@Overridepublic AuthenticationManager authenticationManagerBean() throws Exception {return super.authenticationManagerBean();}/*** 密码编码对象(密码不进⾏加密处理)* @return*/@Beanpublic PasswordEncoder passwordEncoder() {return NoOpPasswordEncoder.getInstance();}@Autowiredprivate PasswordEncoder passwordEncoder;/*** 处理⽤户名和密码验证事宜* 1)客户端传递username和password参数到认证服务器* 2)⼀般来说,username和password会存储在数据库中的⽤户表中* 3)根据⽤户表中数据,验证当前传递过来的⽤户信息的合法性*/@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// 在这个⽅法中就可以去关联数据库了,当前我们先把⽤户信息配置在内存中// 实例化⼀个⽤户对象(相当于数据表中的⼀条⽤户记录)UserDetails user = new User("admin","123456",new ArrayList<>());auth.inMemoryAuthentication() .withUser(user).passwordEncoder(passwordEncoder); }}
  7. 测试
    获取token:http://localhost:9999/oauth/token?client_secret=abcxyz&grant_type=password&
    username=admin&password=123456&client_id=client_learn

    • endpoint:/oauth/token
    • 获取token携带的参数
      • client_id:客户端id
      • client_secret:客户单密码
      • grant_type:指定使⽤哪种颁发类型,password
      • username:⽤户名
      • password:密码

    在这里插入图片描述
    校验token:http://localhost:9999/oauth/check_token?token=a9979518-838c-49ff-b14a-ebdb7fde7d08
    在这里插入图片描述
    刷新token:http://localhost:9999/oauth/token?grant_type=refresh_token&client_id=client_learn&client_secret=abcxyz&refresh_token=8b640340-30a3-4307-93d4-ed60cc54fbc8
    在这里插入图片描述

  8. 资源服务器(希望访问被认证的微服务)Resource Server配置

    • 资源服务配置类
    @Configuration@EnableResourceServer // 开启资源服务器功能@EnableWebSecurity // 开启web访问安全public class ResourceServerConfiger extends ResourceServerConfigurerAdapter {private String sign_key = "learn123"; // jwt签名密钥/*** 该⽅法⽤于定义资源服务器向远程认证服务器发起请求,进⾏token校验等事宜* @param resources* @throws Exception*/@Overridepublic void configure(ResourceServerSecurityConfigurer resources) throws Exception {// 设置当前资源服务的资源idresources.resourceId("autodeliver");// 定义token服务对象(token校验就应该靠token服务对象)RemoteTokenServices remoteTokenServices = newRemoteTokenServices();// 校验端点/接⼝设置remoteTokenServices.setCheckTokenEndpointUrl("http://localhost:9999/oauth/check_token");// 携带客户端id和客户端安全码remoteTokenServices.setClientId("client_learn");remoteTokenServices.setClientSecret("abcxyz");// 别忘了这⼀步resources.tokenServices(remoteTokenServices);}/*** 场景:⼀个服务中可能有很多资源(API接⼝)* 某⼀些API接⼝,需要先认证,才能访问* 某⼀些API接⼝,压根就不需要认证,本来就是对外开放的接⼝* 我们就需要对不同特点的接⼝区分对待(在当前configure⽅法中完成),设置是否需要经过认证** @param http* @throws Exception*/@Overridepublic void configure(HttpSecurity http) throws Exception { // 设置session的创建策略(根据需要创建即可)http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED).and().authorizeRequests().antMatchers("/autodeliver/**").authenticated() // autodeliver为前缀的请求需要认证.antMatchers("/demo/**").authenticated() // demo为前缀 的请求需要认证.anyRequest().permitAll(); // 其他请求不认证 }}

    思考:当我们第⼀次登陆之后,认证服务器颁发token并将其存储在认证服务器中,后期我们访问资源服务器时会携带token,资源服务器会请求认证服务器验证token有效性,如果资源服务器有很多,那么认证服务器压⼒会很⼤…

    另外,资源服务器向认证服务器check_token,获取的也是⽤户信息UserInfo,能否把⽤户信息存储到令牌中,让客户端⼀直持有这个令牌,令牌的验证也在资源服务器进⾏,这样避免和认证服务器频繁的交互…

    我们可以考虑使⽤ JWT 进⾏改造,使⽤JWT机制之后资源服务器不需要访问认证服务器…

JWT改造统⼀认证授权中⼼的令牌存储机制

JWT令牌介绍

通过上边的测试我们发现,当资源服务和授权服务不在⼀起时资源服务使⽤RemoteTokenServices 远程请求授权 服务验证token,如果访问量较⼤将会影响系统的性能。

解决上边问题: 令牌采⽤JWT格式即可解决上边的问题,⽤户认证通过会得到⼀个JWT令牌,JWT令牌中已经包括了⽤户相关的信 息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法⾃⾏完成令牌校验,⽆需每次都请求认证 服务完成授权。

  1. 什么是JWT?
    JSON Web Token(JWT)是⼀个开放的⾏业标准(RFC 7519),它定义了⼀种简洁的、⾃包含的协议格式,⽤于 在通信双⽅传递json对象,传递的信息经过数字签名可以被验证和信任。JWT可以使⽤HMAC算法或使⽤RSA的公 钥/私钥对来签名,防⽌被篡改。
  2. JWT令牌结构
    JWT令牌由三部分组成,每部分中间使⽤点(.)分隔,⽐如:xxxxx.yyyyy.zzzzz
    1. Header
      头部包括令牌的类型(即JWT)及使⽤的哈希算法(如HMAC SHA256或RSA),例如

      {"alg": "HS256","typ": "JWT"}

      将上边的内容使⽤Base64Url编码,得到⼀个字符串就是JWT令牌的第⼀部分。

    2. Payload
      第⼆部分是负载,内容也是⼀个json对象,它是存放有效信息的地⽅,它可以存放jwt提供的现成字段,⽐ 如:iss(签发者),exp(过期时间戳), sub(⾯向的⽤户)等,也可⾃定义字段。 此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。 最后将第⼆部分负载使⽤Base64Url编码,得到⼀个字符串就是JWT令牌的第⼆部分。
      ⼀个例⼦:

      {"sub": "1234567890","name": "John Doe","iat": 1516239022}
    3. Signature
      第三部分是签名,此部分⽤于防⽌jwt内容被篡改。 这个部分使⽤base64url将前两部分进⾏编码,编码后使⽤点(.)连接组成字符串,最后使⽤header中声明 签名算法进⾏签名。

      HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)

      base64UrlEncode(header):jwt令牌的第⼀部分。
      base64UrlEncode(payload):jwt令牌的第⼆部分。
      secret:签名所使⽤的密钥。

认证服务器端JWT改造(改造主配置类)
/*该⽅法⽤于创建tokenStore对象(令牌存储对象)token以什么形式存储*/public TokenStore tokenStore(){//return new InMemoryTokenStore();// 使⽤jwt令牌return new JwtTokenStore(jwtAccessTokenConverter());}/*** 返回jwt令牌转换器(帮助我们⽣成jwt令牌的)* 在这⾥,我们可以把签名密钥传递进去给转换器对象* @return*/public JwtAccessTokenConverter jwtAccessTokenConverter() {JwtAccessTokenConverter jwtAccessTokenConverter = newJwtAccessTokenConverter();jwtAccessTokenConverter.setSigningKey(sign_key); // 签名密钥jwtAccessTokenConverter.setVerifier(new MacSigner(sign_key)); // 验证 时使⽤的密钥,和签名密钥保持⼀致return jwtAccessTokenConverter; }

修改 JWT 令牌服务⽅法
在这里插入图片描述

资源服务器校验JWT令牌

不需要和远程认证服务器交互,添加本地tokenStore

@Configuration@EnableResourceServer // 开启资源服务器功能@EnableWebSecurity // 开启web访问安全public class ResourceServerConfiger extends ResourceServerConfigurerAdapter {private String sign_key = "learn123"; // jwt签名密钥/*** 该⽅法⽤于定义资源服务器向远程认证服务器发起请求,进⾏token校验等事宜* @param resources* @throws Exception*/@Overridepublic void configure(ResourceServerSecurityConfigurer resources) throws Exception {/*// 设置当前资源服务的资源idresources.resourceId("autodeliver");// 定义token服务对象(token校验就应该靠token服务对象)RemoteTokenServices remoteTokenServices = new RemoteTokenServices();// 校验端点/接⼝设置remoteTokenServices.setCheckTokenEndpointUrl("http://localhost:9999/oauth/check_token");// 携带客户端id和客户端安全码remoteTokenServices.setClientId("client_learn");remoteTokenServices.setClientSecret("abcxyz");// 别忘了这⼀步resources.tokenServices(remoteTokenServices);*/// jwt令牌改造resources.resourceId("autodeliver").tokenStore(tokenStore()).stateless(true);// ⽆状态设置}/*** 场景:⼀个服务中可能有很多资源(API接⼝)* 某⼀些API接⼝,需要先认证,才能访问* 某⼀些API接⼝,压根就不需要认证,本来就是对外开放的接⼝* 我们就需要对不同特点的接⼝区分对待(在当前configure⽅法中完成),设置是否需要经过认证** @param http* @throws Exception*/@Overridepublic void configure(HttpSecurity http) throws Exception {// 设置session的创建策略(根据需要创建即可)http .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)  .and()   .authorizeRequests()  .antMatchers("/autodeliver/**").authenticated() // autodeliver为前缀的请求需要认证  .antMatchers("/demo/**").authenticated() // demo为前缀的请求需要认证  .anyRequest().permitAll(); // 其他请求不认证 } /*该⽅法⽤于创建tokenStore对象(令牌存储对象)token以什么形式存储*/public TokenStore tokenStore(){//return new InMemoryTokenStore();// 使⽤jwt令牌return new JwtTokenStore(jwtAccessTokenConverter());}/*** 返回jwt令牌转换器(帮助我们⽣成jwt令牌的)* 在这⾥,我们可以把签名密钥传递进去给转换器对象* @return*/public JwtAccessTokenConverter jwtAccessTokenConverter() {JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();jwtAccessTokenConverter.setSigningKey(sign_key); // 签名密钥jwtAccessTokenConverter.setVerifier(new MacSigner(sign_key)); // 验证 时使⽤的密钥,和签名密钥保持⼀致return jwtAccessTokenConverter;}}

从数据库加载Oauth2客户端信息

  1. 创建数据表并初始化数据(表名及字段保持固定)

    SET NAMES utf8mb4;SET FOREIGN_KEY_CHECKS = 0;-- ------------------------------ Table structure for oauth_client_details-- ----------------------------DROP TABLE IF EXISTS `oauth_client_details`;CREATE TABLE `oauth_client_details` ( `client_id` varchar(48) NOT NULL, `resource_ids` varchar(256) DEFAULT NULL, `client_secret` varchar(256) DEFAULT NULL, `scope` varchar(256) DEFAULT NULL, `authorized_grant_types` varchar(256) DEFAULT NULL, `web_server_redirect_uri` varchar(256) DEFAULT NULL, `authorities` varchar(256) DEFAULT NULL, `access_token_validity` int(11) DEFAULT NULL, `refresh_token_validity` int(11) DEFAULT NULL, `additional_information` varchar(4096) DEFAULT NULL, `autoapprove` varchar(256) DEFAULT NULL, PRIMARY KEY (`client_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Records of oauth_client_details-- ----------------------------BEGIN;INSERT INTO `oauth_client_details` VALUES ('client_learn123','autodeliver,resume', 'abcxyz', 'all', 'password,refresh_token', NULL, NULL,7200, 259200, NULL, NULL);COMMIT;SET FOREIGN_KEY_CHECKS = 1;
  2. 配置数据源

    server:  port: 9999Spring:  application:    name: learn-cloud-oauth-server    datasource:    driver-class-name: com.mysql.jdbc.Driver    url: jdbc:mysql://localhost:3306/oauth2?useUnicode=true&characterEncoding=utf-8&useSSL=false&allowMultiQueries=true    username: root password: 123456 druid:   initialSize: 10   minIdle: 10   maxActive: 30   maxWait: 50000eureka:  client:    serviceUrl: # eureka server的路径      defaultZone: http://learncloudeurekaservera:8761/eureka/,http://learncloudeurekaserverb:8762/eureka/ #把 eureka 集群中的所有 url 都填写了进来,也可以只写⼀台,因为各个 eurekaserver 可以同步注册表    instance: #使⽤ip注册,否则会使⽤主机名注册了(此处考虑到对⽼版本的兼容,新版本经过实验都是ip)   prefer-ip-address: true#⾃定义实例显示格式,加上版本号,便于多版本管理,注意是ip-address,早期版本ipAddress   instance-id: ${spring.cloud.client.ipaddress}:${spring.application.name}:${server.port}:@project.version@
  3. 认证服务器主配置类改造

    @Autowiredprivate DataSource dataSource;/*** 客户端详情配置,* ⽐如client_id,secret* 当前这个服务就如同QQ平台,CSDN作为客户端需要qq平台进⾏登录授权认证等,提前需要到QQ平台注册,QQ平台会给CSDN* 颁发client_id等必要参数,表明客户端是谁* @param clients* @throws Exception*/@Overridepublic void configure(ClientDetailsServiceConfigurer clients) throws Exception {super.configure(clients);// 从内存中加载客户端详情改为从数据库中加载客户端详情clients.withClientDetails(createJdbcClientDetailsService()); } @Beanpublic JdbcClientDetailsService createJdbcClientDetailsService() {JdbcClientDetailsService jdbcClientDetailsService = new JdbcClientDetailsService(dataSource);return jdbcClientDetailsService;}

从数据库验证⽤户合法性

  1. 创建数据表users(表名不需固定),初始化数据

    SET NAMES utf8mb4;SET FOREIGN_KEY_CHECKS = 0;-- ------------------------------ Table structure for users-- ----------------------------DROP TABLE IF EXISTS `users`;CREATE TABLE `users` ( `id` int(11) NOT NULL AUTO_INCREMENT, `username` char(10) DEFAULT NULL, `password` char(100) DEFAULT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8;-- ------------------------------ Records of users-- ----------------------------BEGIN;INSERT INTO `users` VALUES (4, 'learn-user', 'iuxyzds');COMMIT;SET FOREIGN_KEY_CHECKS = 1;
  2. 操作数据表的JPA配置以及针对该表的操作的Dao接⼝此处省略…

  3. 开发UserDetailsService接⼝的实现类,根据⽤户名从数据库加载⽤户信息

    @Servicepublic class JdbcUserDetailsService implements UserDetailsService {@Autowiredprivate UsersRepository usersRepository;/*** 根据username查询出该⽤户的所有信息,封装成UserDetails类型的对象返回,⾄于密码,框架会⾃动匹配* @param username* @return* @throws UsernameNotFoundException*/@Overridepublic UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {Users users = usersRepository.findByUsername(username);return new User(users.getUsername(),users.getPassword(),new ArrayList<>()); }}
  4. 使⽤⾃定义的⽤户详情服务对象

@Autowiredprivate JdbcUserDetailsService jdbcUserDetailsService;/*** 处理⽤户名和密码验证事宜* 1)客户端传递username和password参数到认证服务器* 2)⼀般来说,username和password会存储在数据库中的⽤户表中* 3)根据⽤户表中数据,验证当前传递过来的⽤户信息的合法性*/@Overrideprotected void configure(AuthenticationManagerBuilder auth) throws Exception {// 在这个⽅法中就可以去关联数据库了,当前我们先把⽤户信息配置在内存中// 实例化⼀个⽤户对象(相当于数据表中的⼀条⽤户记录)/*UserDetails user = new User("admin","123456",new ArrayList());auth.inMemoryAuthentication().withUser(user).passwordEncoder(passwordEncoder);*/auth.userDetailsService(jdbcUserDetailsService).passwordEncoder(passwordEncoder);}

基于Oauth2的 JWT 令牌信息扩展

OAuth2帮我们⽣成的JWT令牌载荷部分信息有限,关于⽤户信息只有⼀个user_name,有些场景下我们希望放⼊⼀些扩展信息项,⽐如,之前我们经常向session中存⼊userId,或者现在我希望在JWT的载荷部分存⼊当时请求令牌的客户端IP,客户端携带令牌访问资源服务时,可以对⽐当前请求的客户端真实IP和令牌中存放的客户端IP是否匹配,不匹配拒绝请求,以此进⼀步提⾼安全性。那么如何在OAuth2环境下向JWT令牌中存如扩展信息?

  • 认证服务器⽣成JWT令牌时存⼊扩展信息(⽐如clientIp)
    继承DefaultAccessTokenConverter类,重写convertAccessToken⽅法存⼊扩展信息

    @Componentpublic class LearnAccessTokenConvertor extends DefaultAccessTokenConverter{@Overridepublic Map<String, ?> convertAccessToken(OAuth2AccessToken token, OAuth2Authentication authentication) {// 获取到request对象HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.getRequestAttributes())).getRequest();// 获取客户端ip(注意:如果是经过代理之后到达当前服务的话,那么这种⽅式获取的并不是真实的浏览器客户端ip)String remoteAddr = request.getRemoteAddr();Map<String, String> stringMap = (Map<String, String>)super.convertAccessToken(token, authentication);stringMap.put("clientIp",remoteAddr);return stringMap; }}
  • 将⾃定义的转换器对象注⼊

    /*** 返回jwt令牌转换器(帮助我们⽣成jwt令牌的)* 在这⾥,我们可以把签名密钥传递进去给转换器对象* @return*/public JwtAccessTokenConverter jwtAccessTokenConverter() {JwtAccessTokenConverter jwtAccessTokenConverter = newJwtAccessTokenConverter();jwtAccessTokenConverter.setSigningKey(sign_key); // 签名密钥jwtAccessTokenConverter.setVerifier(new MacSigner(sign_key)); // 验证 时使⽤的密钥,和签名密钥保持⼀致jwtAccessTokenConverter.setAccessTokenConvertor(learnAccessTokenConvertor)return jwtAccessTokenConverter; }

资源服务器取出 JWT 令牌扩展信息

资源服务器也需要⾃定义⼀个转换器类,继承DefaultAccessTokenConverter,重写
extractAuthentication提取⽅法,把载荷信息设置到认证对象的details属性中

@Componentpublic class LearnAccessTokenConvertor extends DefaultAccessTokenConverter {@Overridepublic OAuth2Authentication extractAuthentication(Map<String, ?> map) {OAuth2Authentication oAuth2Authentication = super.extractAuthentication(map);oAuth2Authentication.setDetails(map); // 将map放⼊认证对象中,认证对象在controller中可以拿到return oAuth2Authentication;}}

将⾃定义的转换器对象注⼊

/*** 返回jwt令牌转换器(帮助我们⽣成jwt令牌的)* 在这⾥,我们可以把签名密钥传递进去给转换器对象* @return*/public JwtAccessTokenConverter jwtAccessTokenConverter() {JwtAccessTokenConverter jwtAccessTokenConverter = new JwtAccessTokenConverter();jwtAccessTokenConverter.setSigningKey(sign_key); // 签名密钥jwtAccessTokenConverter.setVerifier(new MacSigner(sign_key)); // 验证 时使⽤的密钥,和签名密钥保持⼀致jwtAccessTokenConverter.setAccessTokenConvertor(learnAccessTokenConvertor)return jwtAccessTokenConverter; }

业务类⽐如Controller类中,可以通过SecurityContextHolder.getContext().getAuthentication()获取到认证对象,进⼀步获取到扩展信息

Object details = SecurityContextHolder.getContext().getAuthentication().getDetails();

获取到扩展信息后,就可以做其他的处理了,⽐如根据userId进⼀步处理,或者根据clientIp处理,或者其他都是可以的了

其他

关于JWT令牌我们需要注意

  • JWT令牌就是⼀种可以被验证的数据组织格式,它的玩法很灵活,我们这⾥是基于Spring Cloud Oauth2 创建、校验JWT令牌
  • 我们也可以⾃⼰写⼯具类⽣成、校验JWT令牌
  • JWT令牌中不要存放过于敏感的信息,因为我们知道拿到令牌后,我们可以解码看到载荷部分的信息
  • JWT令牌每次请求都会携带,内容过多,会增加⽹络带宽占⽤