Spring Cloud Alibaba 实战(四)Oauth2篇

6,417 阅读18分钟

白菜Java自习室 涵盖核心知识

Spring Cloud Alibaba 实战(一)准备篇
Spring Cloud Alibaba 实战(二)Nacos篇
Spring Cloud Alibaba 实战(三)Sentinel篇
Spring Cloud Alibaba 实战(四)Oauth2篇
Spring Cloud Alibaba 实战(五)Zuul篇
Spring Cloud Alibaba 实战(六)RocketMQ篇
Spring Cloud Alibaba 实战(七)Seata篇
Spring Cloud Alibaba 实战(八)SkyWalking篇

项目 GitHub 地址:github.com/D2C-Cai/her…

1. Oauth2 简介

OAuth2 其实是一个关于授权的网络标准,它制定了设计思路和运行流程,利用这个标准我们其实是可以自己实现 OAuth2 的认证过程的。spring-cloud-starter-oauth2 ,其实是 Spring Cloud 按照 OAuth2 的标准并结合 spring-security 封装好的一个具体实现。

首先大家最熟悉的就是几乎每个人都用过的,比如用微信登录、用 QQ 登录、用微博登录、用 Google 账号登录、用 github 授权登录等等,这些都是典型的 OAuth2 使用场景。

OAuth 2.0 的运行流程:

  1. (A)用户打开客户端以后,客户端要求用户给予授权。
  2. (B)用户同意给予客户端授权。
  3. (C)客户端使用上一步获得的授权,向认证服务器申请令牌。
  4. (D)认证服务器对客户端进行认证以后,确认无误,同意发放令牌。
  5. (E)客户端使用令牌,向资源服务器申请获取资源。
  6. (F)资源服务器确认令牌无误,同意向客户端开放资源。

OAuth 2 有四种授权模式,分别是授权码模式(authorization code)、简化模式(implicit)、密码模式(resource owner password credentials)、客户端模式(client credentials)。

假设我们做了一个自己的服务平台,如果不使用 OAuth2 登录方式,那么我们需要用户先完成注册,然后用注册号的账号密码或者用手机验证码登录。而使用了 OAuth2 之后,相信很多人使用过、甚至开发过公众号网页服务、小程序,当我们进入网页、小程序界面,第一次使用就无需注册,直接使用微信授权登录即可,大大提高了使用效率。因为每个人都有微信号,有了微信就可以马上使用第三方服务,这体验不要太好了。而对于我们的服务来说,我们也不需要存储用户的密码,只要存储认证平台返回的唯一ID 和用户信息即可。

以上是使用了 OAuth2 的授权码模式,利用第三方的权威平台实现用户身份的认证。当然了,如果你的公司内部有很多个服务,可以专门提取出一个认证中心,这个认证中心就充当上面所说的权威认证平台的角色,所有的服务都要到这个认证中心做认证。

这样一说,发现没,这其实就是个单点登录的功能。这就是另外一种使用场景,对于多服务的平台,可以使用 OAuth2 实现服务的单点登录,只做一次登录,就可以在多个服务中自由穿行,当然仅限于授权范围内的服务和接口。

2. Oauth2 实现

本系统主要介绍 密码模式 实现的单点登录。

密码模式(Resource Owner Password Credentials Grant)中,用户向客户端提供自己的用户名和密码。客户端使用这些信息,向"服务商提供商"索要授权。

在这种模式中,用户必须把自己的密码给客户端,但是客户端不得储存密码。这通常用在用户对客户端高度信任的情况下,比如客户端是操作系统的一部分,或者由一个著名公司出品。而认证服务器只有在其他授权模式无法执行的情况下,才能考虑使用这种模式。

密码模式的运行流程:

  1. (A)用户向客户端提供用户名和密码。
  2. (B)客户端将用户名和密码发给认证服务器,向后者请求令牌。
  3. (C)认证服务器确认无误后,向客户端提供访问令牌。

系统架构说明(当然这是无网关情形下的初步架构,后续会介绍此架构的不合理之处):

  • 客户端:例如 app 端、web 端 等终端。
  • 认证中心:herring-oauth2,OAuth2 主要实现端,Token 的生成、刷新、验证都在认证中心完成。
  • 会员服务:herring-member-service,微服务之一,接收到请求后会到认证中心验证。
  • 订单服务:herring-orders-service,微服务之二,接收到请求后会到认证中心验证。
  • 商品服务:herring-product-service,微服务之三,接收到请求后会到认证中心验证。

上图描述了使用了 OAuth2 的客户端与微服务间的请求过程。大致的过程就是客户端用用户名和密码到认证服务端换取 token,返回给客户端,客户端拿着 token 去各个微服务请求数据接口,一般这个 token 是放到 header 中的。当微服务接到请求后,先要拿着 token 去认证服务端检查 token 的合法性,如果合法,再根据用户所属的角色及具有的权限动态的返回数据。

3. 搭建 Oauth2 认证中心(服务端)

  1. 添加 pom 文件依赖

spring-cloud-starter-oauth2 包含了 spring-cloud-starter-security,所以不用再单独引入了。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>
  1. WebSecurity 基础配置: WebSecurityConfig extends WebSecurityConfigurerAdapter

使用 @EnableWebSecurity 注解修饰,并继承自 WebSecurityConfigurerAdapter 类。

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * 允许匿名访问所有接口 主要是 oauth2 接口
     *
     * @param http
     * @throws Exception
     */
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/**").permitAll();
    }

}

这个类的重点就是声明 PasswordEncoder 和 AuthenticationManager 两个 Bean。

  • BCryptPasswordEncoder 是一个密码加密工具类,它可以实现不可逆的加密,
  • AuthenticationManager 是为了实现 OAuth2 的 password 模式必须要指定的授权管理 Bean。
  1. 实现 UserDetailsService: HerringUserDetailsService implements UserDetailsService

UserDetailsService 的核心就是 loadUserByUsername 方法,它要接收一个字符串参数,也就是传过来的用户名,返回一个 UserDetails 对象。

@Slf4j
@Component(value = "herringUserDetailsService")
public class HerringUserDetailsService implements UserDetailsService {

    @Resource
    private PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("username is:" + username);
        // 查询数据库操作
        if (!username.equals("admin")) {
            throw new UsernameNotFoundException("the user is not found");
        } else {
            // 用户角色也应在数据库中获取
            String role = "ROLE_ADMIN";
            List<SimpleGrantedAuthority> authorities = new ArrayList<>();
            authorities.add(new SimpleGrantedAuthority(role));
            // 线上环境应该通过用户名查询数据库获取加密后的密码
            String password = passwordEncoder.encode("123456");
            return new org.springframework.security.core.userdetails.User(username, password, authorities);
        }
    }

}

这里为了做演示,把用户名、密码和所属角色都写在代码里了,正式环境中,这里应该是从数据库或者其他地方根据用户名将加密后的密码及所属角色查出来的。账号 admin ,密码 123456,稍后在换取 token 的时候会用到。并且给这个用户设置 "ROLE_ADMIN" 角色。

  1. OAuth2 配置文件: JwtOAuth2Config extends AuthorizationServerConfigurerAdapter

创建一个配置文件继承自 AuthorizationServerConfigurerAdapter, 并开启注解 @EnableAuthorizationServer。JwtOAuth2Config 这个类实现之前,我们还需要一系列准备工作。

@Configuration
@EnableAuthorizationServer
public class JwtOAuth2Config extends AuthorizationServerConfigurerAdapter {

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
	// ...
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
	// ...
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
	// ...
    }

}

准备工作 一:
configure(final AuthorizationServerEndpointsConfigurer endpoints) 方法的重写

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        /**
         * jwt 增强模式
         */
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancerList = new ArrayList<>();
        enhancerList.add(jwtTokenEnhancer);
        enhancerList.add(jwtAccessTokenConverter);
        enhancerChain.setTokenEnhancers(enhancerList);
        endpoints.tokenStore(jwtTokenStore)
                .userDetailsService(herringUserDetailsService)
                /**
                 * 支持 password 模式
                 */
                .authenticationManager(authenticationManager)
                .tokenEnhancer(enhancerChain)
                .accessTokenConverter(jwtAccessTokenConverter);
    }
  • authenticationManage() 调用此方法才能支持 password 模式。
  • userDetailsService() 设置用户验证服务。
  • tokenStore() 指定 token 的存储方式。

一般系统会有两种选择:RedisToken 和 JwtToken。这两种 token 方式分别有什么优点和劣势呢?

RedisToken(性能较差,安全性高,要持久化,服务端校验):

  1. RedisToken 的设计,是一种登陆后分配随机 token,然后记录 token 与用户信息对应关系的设计。
  2. RedisToken 实际上是需要服务器存储,每次验权需要查询 Redis 服务器存储库。
  3. RedisToken 必须配合持久化进行存储和查询,因此性能较低,但却能做到及时的授权关闭,已经登陆授权可见可查,每一次 token 都会有对应的记录。
  4. RedisToken 模式适合较高安全度和用户登陆等信息分析的系统,如政府系统、支付系统等不可能允许高权限的 token 被偷窃却不能及时关闭授权。

JwtToken(性能较好,安全性低,不持久化,客户端校验)

  1. JwtToken 属于无状态设计,用户登陆的信息关键存放在 jwt 加密数据里,这种设计下服务器不需要存储 jwt 密文,只需要解密就能拿到授权信息等用户信息。这种设计是一种利用计算力减少 token 设计下数据库及缓存的压力和设计复杂度,因此它的本质就是不存储登陆授权,而通过密文本身保存授权信息。
  2. JwtToken 不需要服务器存储,信息本身就存储于 jwt 本身,这种模式无需使用数据库。
  3. JwtToken 这种流行的模式有一个设计上的缺陷,他通过密文传输用户信息,那么服务器在这种基础结构下是无法做到关闭用户登陆授权的操作,如果用户的 jwt 密文被偷窃,那么黑客就能以用户身份登陆,并且即使知道密文丢失,也无法关闭被偷窃的 jwt 密文。
  4. JwtToken 为了应对这一问题,可以使用 jwt 内部验证有效期和 jwt 黑名单模式,但是有效期始终无法做到及时停止 jwt 授权,这是一个治标不治本的方法。而 jwt 黑名单模式,则需要数据库或内存存储黑名单,那么,这实际上违背了 jwt 的免数据库设计原则。
  5. JwtToken 更适合低安全级别的服务器设计,如普通的博客、阅读器等等,这种服务允许不严格的登陆授权,即使密文丢失也不会造成用户的严重损失,却能获得较高的服务性能。

这里我们选择 JwtToken 方式。用 jwt 的方式就不用把 token 再存储到服务端了,jwt 有自己特殊的加密方式,可以有效的防止数据被篡改,只要不把用户密码等关键信息放到 jwt 里就可以保证安全性。

4.1. 添加 JwtConfig 配置类

JwtAccessTokenConverter 是为了做 jwt 数据转换,这样做是因为 jwt 有自身独特的数据格式。

@Configuration
public class JwtTokenConfig {

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setSigningKey("sign-8888");
        return accessTokenConverter;
    }

}

4.2. 添加一个 jwt 增强器

如果我想在 jwt 中加入额外的字段(比方说用户的其他信息)怎么办呢,当然可以。spring security oauth2 提供了 TokenEnhancer 增强器。

@Component
public class JwtTokenEnhancer implements TokenEnhancer {

    @Override
    public OAuth2AccessToken enhance(OAuth2AccessToken oAuth2AccessToken, OAuth2Authentication oAuth2Authentication) {
        Map<String, Object> info = new HashMap<>();
        info.put("jwt-ext", "JWT 扩展信息");
        ((DefaultOAuth2AccessToken) oAuth2AccessToken).setAdditionalInformation(info);
        return oAuth2AccessToken;
    }

}

准备工作 二:
configure(ClientDetailsServiceConfigurer clients) 方法的重写

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        JdbcClientDetailsServiceBuilder jcsb = clients.jdbc(dataSource);
        jcsb.passwordEncoder(passwordEncoder);
    }

4.3. 添加 pom 文件Mysql依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.38</version>
        </dependency>

4.4. 在配置文件 application.yml 中添加关于数据库的配置

Spring Boot 2.0 之后默认使用 hikari 作为数据库连接池。如果使用其他连接池需要引入相关包,然后对应的增加配置。

server:
  port: 10800

spring:
  application:
    name: oauth2-service

  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://(安装Mysql的机器IP):3306/herring_oauth2?characterEncoding=UTF-8&useSSL=false
    username: root
    password: (你的root密码)
    hikari:
      connection-timeout: 30000
      idle-timeout: 600000
      max-lifetime: 1800000
      maximum-pool-size: 9

4.5. 在数据库中增加表 oauth_client_details,并插入数据

注意: client_secret 字段不能直接是 secret 的原始值,需要经过加密。因为是用的 BCryptPasswordEncoder,所以最终插入的值应该是经过 BCryptPasswordEncoder.encode() 之后的值。

DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details`  (
  `client_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
  `resource_ids` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `client_secret` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `scope` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `authorized_grant_types` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `web_server_redirect_uri` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `authorities` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `access_token_validity` int(11) NULL DEFAULT NULL,
  `refresh_token_validity` int(11) NULL DEFAULT NULL,
  `additional_information` varchar(4096) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  `autoapprove` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
  PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Compact;

INSERT INTO `oauth_client_details` VALUES ('app-client', 'member-service,orders-service,product-service', '$2a$10$DVdqTrpMWBjed3C3F43v0ewmCpkg9V0scgGAS9dYoYrjLm6bHce5S', 'all', 'authorization_code,refresh_token,password', NULL, NULL, 7200, 72000, NULL, '1');
INSERT INTO `oauth_client_details` VALUES ('web-client', 'member-service,orders-service,product-service', '$2a$10$DVdqTrpMWBjed3C3F43v0ewmCpkg9V0scgGAS9dYoYrjLm6bHce5S', 'all', 'authorization_code,refresh_token,password', NULL, NULL, 7200, 72000, NULL, '1');

oauth_client_details 表中一些参数的解释如下:

  • cleint-id:对应请求端定义的参数 cleint-id;
  • client-secret:对应请求端定义的参数 client-secret;
  • resource_ids:描述资源服务器和客户端的关系,比如我定义了cleint-id=app-client,resource_ids=member-service,orders-service,product-service,就是 app-client 端的请求允许访问这些资源服务器;
  • access_token_validity:access_token 的有效期(秒);
  • refresh_token_validity:refresh_token 的有效期(秒);
  • scopesauthorities:用来限制 cleint 访问的权限(注意不是用户的)。两个字段作用类似,scope 是 oauth2 协议中的字段,而 authorities 是 spring-security 框架中的字段。在换取的 token 的时候会带上 scope 参数,只有在 scopes 定义内的,才可以正常换取 token。
  • authorized_grant_types: 授权类型可以包括如下几种设置中的一种或多种:
  1. authorization_code:授权码类型。
  2. implicit:隐式授权类型。
  3. password:资源所有者(即用户)密码类型。
  4. client_credentials:客户端凭据(客户端ID以及Key)类型。
  5. refresh_token:通过以上授权获得的刷新令牌来获取新的令牌。

准备工作 三:
configure(AuthorizationServerSecurityConfigurer security) 方法的重写

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
        security.checkTokenAccess("isAuthenticated()");
        security.tokenKeyAccess("isAuthenticated()");
    }

这个方法限制客户端访问认证接口的权限。

  • allowFormAuthenticationForClients() 是允许客户端访问 OAuth2 授权接口,否则请求 token 会返回 401。
  • checkTokenAccess() 和tokenKeyAccess() 分别是允许已授权用户访问 checkToken 接口和获取 token 接口。

4.6. JwtConfig 配置类 最终内容

@Configuration
@EnableAuthorizationServer
public class JwtOAuth2Config extends AuthorizationServerConfigurerAdapter {

    @Resource
    private DataSource dataSource;
    @Resource
    private TokenStore jwtTokenStore;
    @Resource
    private TokenEnhancer jwtTokenEnhancer;
    @Resource
    private PasswordEncoder passwordEncoder;
    @Resource
    private AuthenticationManager authenticationManager;
    @Resource
    private UserDetailsService herringUserDetailsService;
    @Resource
    private JwtAccessTokenConverter jwtAccessTokenConverter;

    @Override
    public void configure(final AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        /**
         * jwt 增强模式
         */
        TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
        List<TokenEnhancer> enhancerList = new ArrayList<>();
        enhancerList.add(jwtTokenEnhancer);
        enhancerList.add(jwtAccessTokenConverter);
        enhancerChain.setTokenEnhancers(enhancerList);
        endpoints.tokenStore(jwtTokenStore)
                .userDetailsService(herringUserDetailsService)
                /**
                 * 支持 password 模式
                 */
                .authenticationManager(authenticationManager)
                .tokenEnhancer(enhancerChain)
                .accessTokenConverter(jwtAccessTokenConverter);
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        JdbcClientDetailsServiceBuilder jcsb = clients.jdbc(dataSource);
        jcsb.passwordEncoder(passwordEncoder);
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
        security.checkTokenAccess("isAuthenticated()");
        security.tokenKeyAccess("isAuthenticated()");
    }

}
  1. 启动项目,查看相关接口

启动项目,如果你用的是 IDEA 会在下方的 Mapping 窗口中看到 oauth2 相关的 RESTful 接口。

主要有如下几个:

POST /oauth/authorize  授权码模式认证授权接口
GET/POST /oauth/token  获取 token 的接口
POST  /oauth/check_token  检查 token 合法性接口

4. 搭建 Oauth2 资源服务(客户端)

搭建下边几个服务任选一个:

  • 会员服务:herring-member-service,微服务之一,接收到请求后会到认证中心验证。
  • 订单服务:herring-orders-service,微服务之二,接收到请求后会到认证中心验证。
  • 商品服务:herring-product-service,微服务之三,接收到请求后会到认证中心验证。
  1. 添加 pom 文件依赖
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
        </dependency>

如果在 jwt 中加入了额外信息,而在接收到 jwt 格式的 token 之后,用户客户端要把 jwt 解析出来。

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.9.1</version>
        </dependency>
  1. application.yml 配置文件:

client-id、client-secret 要和认证服务中的配置一致, access-token-uri 是密码模式需要用到的获取 token 的接口, user-authorization-uri 是授权码认证方式需要的,可以不设置。

server:
  port: 10801
  servlet:
    context-path: /api/member

spring:
  application:
    name: member-service
    
security:
  oauth2:
    client:
      client-id: app-client
      client-secret: client-secret-8888
      user-authorization-uri: http://localhost:10800/oauth/authorize
      access-token-uri: http://localhost:10800/oauth/token
    resource:
      jwt:
        key-uri: http://localhost:10800/oauth/token_key
        key-value: sign-8888
  1. ResourceServerConfig 类的配置:

资源服务的注解 @EnableResourceServer,注意 JwtAccessTokenConverter 设置的 signingKey 要和配置文件中的 key-value 相同,不然会导致无法正常解码 jwt ,导致验证不通过。

@Configuration
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class JwtResourceServerConfig extends ResourceServerConfigurerAdapter {

    @Resource
    private TokenStore jwtTokenStore;

    @Bean
    public TokenStore jwtTokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    @Bean
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter accessTokenConverter = new JwtAccessTokenConverter();
        accessTokenConverter.setSigningKey("sign-8888");
        accessTokenConverter.setVerifierKey("sign-8888");
        return accessTokenConverter;
    }

    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.tokenStore(jwtTokenStore);
        resources.resourceId("member-service");
    }

}
  1. HelloController 创建几个测试用的接口
@RestController
@RequestMapping
public class HelloController {

    @Resource
    private MemberService memberService;

    @RequestMapping("/service")
    public String service() {
        return memberService.sayHello();
    }

    @GetMapping(value = "/info/jwt")
    @PreAuthorize("hasAnyRole('ROLE_ADMIN')")
    public Object jwtParser(Authentication authentication) {
        authentication.getCredentials();
        OAuth2AuthenticationDetails details = (OAuth2AuthenticationDetails) authentication.getDetails();
        String jwtToken = details.getTokenValue();
        Claims claims = Jwts.parser()
                .setSigningKey("sign-8888".getBytes(StandardCharsets.UTF_8))
                .parseClaimsJws(jwtToken)
                .getBody();
        return claims;
    }

}
@Service
public class MemberService {

    public String sayHello() {
        return "Hello, Member! ";
    }

}
  1. 启动资源服务,测试 Oauth2 的密码模式流程

一、向 Oauth2 认证中心(服务端)请求 token:

#### 向 Oauth2 认证中心(服务端)请求 token

POST http://localhost:10800/oauth/token?grant_type=password&username=admin&password=123456&client_id=app-client&client_secret=client-secret-8888&scope=all
Accept: */*
Cache-Control: no-cache

得到请求结果:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1ODQxNywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJiMGQ5ZTI1Yy1jZGE3LTQ4MDctOWJmZS02ZjcyYjM4NGVhNTMiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.w4M9zCahAVISQ_wfKdkT6n9Aaw6kFtoh5HmCJ_uy-vU",
  "token_type": "bearer",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImIwZDllMjVjLWNkYTctNDgwNy05YmZlLTZmNzJiMzg0ZWE1MyIsImV4cCI6MTYxMjkyMzIxNywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiIzZmQ2MWM4ZS1kNTcyLTQ0YjYtYjViNC0zMzc3ODQ5NjY4YmQiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.WxisDVLUlfP45pepc4sQM1M7UCvzsET0O8JvF11tKAI",
  "expires_in": 7199,
  "scope": "all",
  "jwt-ext": "JWT 扩展信息",
  "jti": "b0d9e25c-cda7-4807-9bfe-6f72b384ea53"
}
  • access_token : 就是之后请求需要带上的 token,也是本次请求的主要目的
  • token_type:为 bearer,这是 access token 最常用的一种形式
  • refresh_token:之后可以用这个值来换取新的 token,而不用输入账号密码
  • expires_in:token 的过期时间(秒)

二、向 Oauth2 资源服务(客户端)请求数据:

请求不带 token:

#### 向 Oauth2 资源服务(客户端)请求数据

GET http://localhost:10801/api/member/service
Accept: */*
Cache-Control: no-cache

得到请求结果:

{
  "error": "unauthorized",
  "error_description": "Full authentication is required to access this resource"
}

请求带错误的 token:

#### 向 Oauth2 资源服务(客户端)请求数据

GET http://localhost:10801/api/member/service
Accept: */*
Cache-Control: no-cache
Authorization: bearer 123456

得到请求结果:

{
  "error": "invalid_token",
  "error_description": "Cannot convert access token to JSON"
}

就算能解析成 JSON,token 错误也会报其他错误,是得不到正确的请求数据的。

请求带正确的 token,需要请求头 Authorization,格式为 bearer + 空格 + token

#### 向 Oauth2 资源服务(客户端)请求数据

GET http://localhost:10801/api/member/service
Accept: */*
Cache-Control: no-cache
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1ODQxNywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJiMGQ5ZTI1Yy1jZGE3LTQ4MDctOWJmZS02ZjcyYjM4NGVhNTMiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.w4M9zCahAVISQ_wfKdkT6n9Aaw6kFtoh5HmCJ_uy-vU

得到请求结果:

Hello, Member! 

三、向 Oauth2 认证中心(服务端)刷新 token:

#### 向 Oauth2 认证中心(服务端)刷新 token

POST http://localhost:10800/oauth/token?grant_type=refresh_token&refresh_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImIwZDllMjVjLWNkYTctNDgwNy05YmZlLTZmNzJiMzg0ZWE1MyIsImV4cCI6MTYxMjkyMzIxNywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiIzZmQ2MWM4ZS1kNTcyLTQ0YjYtYjViNC0zMzc3ODQ5NjY4YmQiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.WxisDVLUlfP45pepc4sQM1M7UCvzsET0O8JvF11tKAI&client_id=app-client&client_secret=client-secret-8888
Accept: */*
Cache-Control: no-cache

得到请求结果:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1OTQxMSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJkYWVlNjlkZi02NTFhLTQ1MmItYjA0Yi05N2FhYTc2MjkzYTgiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.F30LZplYodM7zH0N6gwBA29uCBObZISgOPXf-PKB3aI",
  "token_type": "bearer",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImF0aSI6ImRhZWU2OWRmLTY1MWEtNDUyYi1iMDRiLTk3YWFhNzYyOTNhOCIsImV4cCI6MTYxMjkyMzIxNywiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiIzZmQ2MWM4ZS1kNTcyLTQ0YjYtYjViNC0zMzc3ODQ5NjY4YmQiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.8Ix-x4VWTsDKGeqGqjTzlVJk-P1OnD-ISn-zsQPQUG8",
  "expires_in": 7199,
  "scope": "all",
  "jwt-ext": "JWT 扩展信息",
  "jti": "daee69df-651a-452b-b04b-97aaa76293a8"
}

请求带新的 token,向 Oauth2 资源服务(客户端)请求数据

#### 向 Oauth2 资源服务(客户端)请求数据

GET http://localhost:10801/api/member/service
Accept: */*
Cache-Control: no-cache
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1OTQxMSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJkYWVlNjlkZi02NTFhLTQ1MmItYjA0Yi05N2FhYTc2MjkzYTgiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.F30LZplYodM7zH0N6gwBA29uCBObZISgOPXf-PKB3aI

得到请求结果:

Hello, Member! 

四、向 Oauth2 资源服务(客户端)请求查看具体 jwt 的 token 解码内容:

#### 向 Oauth2 资源服务(客户端)请求查看具体 jwt 的 token 解码内容

GET http://localhost:10801/api/member/info/jwt
Accept: */*
Cache-Control: no-cache
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1OTQxMSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJkYWVlNjlkZi02NTFhLTQ1MmItYjA0Yi05N2FhYTc2MjkzYTgiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.F30LZplYodM7zH0N6gwBA29uCBObZISgOPXf-PKB3aI

得到请求结果:

{
  "aud": [
    "orders-service",
    "member-service",
    "product-service"
  ],
  "user_name": "admin",
  "jwt-ext": "JWT 扩展信息",
  "scope": [
    "all"
  ],
  "exp": 1612859411,
  "authorities": [
    "ROLE_ADMIN"
  ],
  "jti": "daee69df-651a-452b-b04b-97aaa76293a8",
  "client_id": "app-client"
}

5. Feign 微服务间调用认证踩坑

假设我们按照以上步骤,已经搭建完成了几乎相同的三个微服务:

  • 会员服务:herring-member-service,微服务之一,接收到请求后会到认证中心验证。
  • 订单服务:herring-orders-service,微服务之二,接收到请求后会到认证中心验证。
  • 商品服务:herring-product-service,微服务之三,接收到请求后会到认证中心验证。

此时我写一个请求指向 member-service,但是 member-service 需要调用远程服务 orders-service 或者 product-service 才能返回正确的结果,如果不做 Oauth2 认证相信大家都没问题,如果我们做了 Oauth2 认证呢?我们现在就来尝试下。

  1. 添加 pom 文件依赖
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
  1. 添加 @EnableFeignClients 注解
@EnableFeignClients
@SpringBootApplication
public class MemberServiceApplication {

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

}
  1. member-service,orders-service,product-service 都添加以下类似接口
@RestController
@RequestMapping
public class HelloController {

    @Resource
    private MemberService memberService;

    @RequestMapping("/service")
    public String service() {
        return memberService.sayHello();
        // return ordersService.sayHello();
        // return productService.sayHello();
    }

}

就是有个区分,访问 /api/member/xxx,返回对应的 Hello, xxx!

@Service
public class MemberService {

    public String sayHello() {
        return "Hello, Member! ";
        // return "Hello, Orders! ";
        // return "Hello, Product! ";
    }

}
  1. member-service 添加对 orders-service,product-service 远程访问的客户端
@FeignClient(name = "orders-service", path = "/api/orders")
public interface OrdersClient {

    @RequestMapping("/service")
    String service();

}
@FeignClient(name = "product-service", path = "/api/product")
public interface ProductClient {

    @RequestMapping("/service")
    String service();

}
  1. member-service 新增加一个接口 /api/member/hello:
    @Resource
    private MemberService memberService;
    @Resource
    private ProductClient productClient;
    @Resource
    private OrdersClient ordersClient;

    @RequestMapping("/hello")
    public String hello() {
        String product = productClient.service();
        String orders = ordersClient.service();
        return memberService.sayHello() + product + orders;
    }
  1. 带正确的 token,向 Oauth2 资源服务(客户端)请求数据 /api/member/hello
#### 向 Oauth2 资源服务(客户端)请求数据

GET http://localhost:10801/api/member/hello
Accept: */*
Cache-Control: no-cache
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1OTQxMSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJkYWVlNjlkZi02NTFhLTQ1MmItYjA0Yi05N2FhYTc2MjkzYTgiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.F30LZplYodM7zH0N6gwBA29uCBObZISgOPXf-PKB3aI

得到请求结果:

{
  "timestamp": 1612855021645,
  "status": 500,
  "error": "Internal Server Error",
  "message": "[401] during [GET] to [http://product-service/api/product/service] [ProductClient#service()]: [{\"error\":\"unauthorized\",\"error_description\":\"Full authentication is required to access this resource\"}]",
  "path": "/api/member/hello"
}

为什么呢?我明明 http 请求头里带了正确的 token,却报 401 forbidden 的错误信息

原因是 当我请求数据 /api/member/hello 时,虽然 http 请求头里带了正确的 token,但是在远程调用 orders-service,product-service 服务时,feign 新建的请求并不会带上这个 token,这是两个不同的 http 请求,所以就会导致 401 forbidden 的错误信息。

解决方案就是在 member-service,orders-service,product-service 都添加一个 RequestInterceptor:

public class TokenRelayRequestInterceptor implements RequestInterceptor {

    public static final String AUTH_TOKEN = "Authorization";

    @Override
    public void apply(RequestTemplate template) {
        // 获取该次请求得token 将token传递
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String token = request.getHeader(AUTH_TOKEN);
        if (!StringUtils.isEmpty(token)) {
            template.header(AUTH_TOKEN, token);
        }
    }

}

并且在 application.yml 中添加配置:

feign:
  client:
    config:
      default:
        requestInterceptors:
          - com.herring.feign.interceptor.TokenRelayRequestInterceptor
  1. 带正确的 token,再次向 Oauth2 资源服务(客户端)请求数据 /api/member/hello
#### 向 Oauth2 资源服务(客户端)请求数据

GET http://localhost:10801/api/member/hello
Accept: */*
Cache-Control: no-cache
Authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib3JkZXJzLXNlcnZpY2UiLCJnYXRld2F5LXNlcnZpY2UiLCJtZW1iZXItc2VydmljZSIsInByb2R1Y3Qtc2VydmljZSJdLCJ1c2VyX25hbWUiOiJhZG1pbiIsImp3dC1leHQiOiJKV1Qg5omp5bGV5L-h5oGvIiwic2NvcGUiOlsiYWxsIl0sImV4cCI6MTYxMjg1OTQxMSwiYXV0aG9yaXRpZXMiOlsiUk9MRV9BRE1JTiJdLCJqdGkiOiJkYWVlNjlkZi02NTFhLTQ1MmItYjA0Yi05N2FhYTc2MjkzYTgiLCJjbGllbnRfaWQiOiJhcHAtY2xpZW50In0.F30LZplYodM7zH0N6gwBA29uCBObZISgOPXf-PKB3aI

得到请求结果:

Hello, Member! Hello, Product! Hello, Orders! 

Oauth2 系统架构的反思

Oauth2 体系搭建到这里,我们来反思一下系统架构:

刚才前文就提到了,这样的架构存在一点问题。什么问题呢?刚才的认证 401 forbidden 的问题不是已经解决了吗?问题是 认证逻辑和微服务耦合,每个微服务都需要各自建立一遍,而且存在多次重复的认证逻辑(当然从安全性角度也不是不可以)。

我们选择的是 JwtToken 还能忍受,因为是 Oauth2 资源服务(客户端)本地就能完成认证,如果选择的是 RedisToken,当一个请求的微服务调用链路很长时,岂不是每个微服务都要请求 Oauth2 认证服务(服务端)一次,这样形成了请求和认证 1比N 的次数关系,如果请求流量比较大,让 oauth2-service 承受了巨大压力(Redis 再快也经不住这样玩)。

  • 引进 zuul 或 gateway 网关,聚合认证服务和鉴权服务到网关。 不但能将认证逻辑和微服务解耦,而且因为每次请求必经网关,只进行一次认证逻辑判断,在请求流量比较大,微服务调用链路很长时能有效缓解 oauth2-service 的压力。

Spring Cloud Alibaba 实战(一)准备篇
Spring Cloud Alibaba 实战(二)Nacos篇
Spring Cloud Alibaba 实战(三)Sentinel篇
Spring Cloud Alibaba 实战(四)Oauth2篇
Spring Cloud Alibaba 实战(五)Zuul篇
Spring Cloud Alibaba 实战(六)RocketMQ篇
Spring Cloud Alibaba 实战(七)Seata篇
Spring Cloud Alibaba 实战(八)SkyWalking篇

项目 GitHub 地址:github.com/D2C-Cai/her…