springboot

一文读懂Spring Cloud Oauth2.0认证授权

2020-03-23  本文已影响0人  程序员王旺

其实微服务分布式认证授权框架并不复杂,网上的一些文章也是过于注重实践,却对这其中的原理解释不多,希望我的这篇文章能帮助你彻底搞明白这之间的逻辑。

为了更形象表述,我们虚构一个例子,假设成立了一家电商公司并开发了一款App,起名为万能App。本公司和淘宝深度合作,通过本公司的App不仅购买本公司的商品,还能购买淘宝上的商品。

万能App在淘宝开放平台申请权限

client_id client_secret grant_type redirect_uri resources
京东App jd authorization_code www.jd.com 拒绝
万能App wn authorization_code www.wn.com 商品、订单

OAuth 2.0 相关知识

在学习Spring Cloud认证授权之前,先来简单了解几个概念,这对搞明白复杂的逻辑至关重要。

OAuth 2.0 中的角色:

OAuth 2.0两种常用授权模式:

应用ID 应用密钥 授权类型 跳转URL 资源
万能App wn password 所有
万能物流 abc123 authorization_code wuliu.wn.com 商品、订单

Spring Security

Spring Security是一款类似Shiro的权限框架,主要是用来保护资源的,只有已经认证并拥有一定的权限才能访问系统资源。一般权限框架都包含两个大模块 :认证和授权。下面简单介绍下Spring Security框架的大体实现:首先在初始化Spring Security时,会创建一个类型为FilterChainProxy,名为 SpringSecurityFilterChain 的Servlet过滤器,这个过滤器只是一个代理,真正干活的是类型为SecurityFilterChain过滤器链,其中负责认证的过滤器会调用认证接口AuthenticationManager;负责授权的过滤器会调用授权接口AccessDecisionManager。


image.png

下面介绍过滤器链中主要的几个过滤器及其作用:

认证过程
Spring Security为我们提供了多种认证方式,通过认证管理器ProviderManager(实现了AuthenticationManager接口)将各种认证方式集成到List<AuthenticationProvider> 列表 ,每种认证方式都会实现 AuthenticationProvider接口,其中authenticate()方法定义了认证的实现过程,supports()方法定义支持那种认证类型,认证成功后会返回AuthenticationToken

public interface AuthenticationProvider {
    Authentication authenticate(Authentication authentication) throws AuthenticationException;
    boolean supports(Class<?> var1);
}

下面我们以表单登录为例,看看如何自定义认证方式呢?

授权过程
FilterSecurityInterceptor会调用AccessDecisionManager进行授权决策,若决策通过,则允许访问资源,否则将禁止访问AccessDecisionManager采用投票的方式来确定是否能够访问受保护资源。

public interface AccessDecisionManager {
    //decide接口就是用来鉴定当前用户是否有访问对应受保护资源的权限。
    void decide(Authentication authentication , Object object, ...) ;    
}

权限信息保存在SecurityMetadataSource的子类中

antMatchers("/xx/").hasAuthority("X") antMatchers("/yy/").hasAuthority("Y")

登录相关权限控制

    http
                .authorizeRequests()
                .antMatchers("/help","/hello").permitAll() //这些请求无需验证

                .and()
                .authorizeRequests()
                .antMatchers( "/admin/**").hasRole("ADMIN" ) //访问/admin请求需要拥有ADMIN权限
                .antMatchers( "/db/**").access("hasRole('ADMIN') and hasRole('DBA')")
                .anyRequest().authenticated() //其余的所有请求都必须验证

                .and()
                .csrf().disable()//默认开启,这里先显式关闭
                .formLogin()  //内部注册 UsernamePasswordAuthenticationFilter
                .loginPage("/loginPage") //表单登录页面地址
                .loginProcessingUrl("/loginAction")//form表单POST请求url提交地址,默认为/login
                .passwordParameter("password")//form表单用户名参数名
                .usernameParameter("username") //form表单密码参数名
                .successForwardUrl("/success")  //登录成功跳转地址
                .failureForwardUrl("/error") //登录失败跳转地址
                //.defaultSuccessUrl()//如果用户没有访问受保护的页面,默认跳转到页面
                //.failureUrl()
                //.failureHandler(AuthenticationFailureHandler)
                //.successHandler(AuthenticationSuccessHandler)
                //.failureUrl("/login?error")
                .permitAll();//允许所有用户都有权限访问登录相关页面

这些lamda方法会添加由一系列过滤器和配置类,例如:authorizeRequests(),formLogin()、httpBasic()这三个方法返回的分别对应 ExpressionUrlAuthorizationConfigurer、FormLoginConfigurer、HttpBasicConfigurer配置类, 他们都是SecurityConfigurer接口的实现类,分别代表的是不同类型的安全配置器。

在实际配置过程中一定要按范围从小到大顺序配置,下面的配置会导致/order/,/db/都失效,因为.anyRequest().authenticated()的范围太大了,把后面的请求都改覆盖了,所以我觉得这里最好配置登录相关,权限配到方法上,避免给自己找麻烦
.anyRequest().authenticated()
.authorizeRequests()
.antMatchers("/order/").hasAuthority("order:all")
.antMatchers( "/db/
").access("hasRole('ADMIN') and hasRole('DBA')")

方法授权
Spring security 提供了 @PreAuthorize,@PostAuthorize, @Secured三类注解定义权限,通过@EnableGlobalMethodSecurity来启用注解

@Secured("IS_AUTHENTICATED_ANONYMOUSLY")
public User read(Long id);

@PreAuthorize("isAnonymous()")
public  User readUser(Long id);
 
@PreAuthorize("hasAuthority('user:add') and hasAuthority('user:read')")
public User post(User user);
}

常用授权方法

authenticated() 保护URL,需要用户登录
permitAll() 指定URL无需保护,一般应用与静态资源文件
hasRole(String role) 限制单个角色访问,角色将被增加 “ROLE_” .所以”ADMIN” 将和 “ROLE_ADMIN”进行比较.
hasAuthority(String authority) 限制单个权限访问
hasAnyRole(String… roles)允许多个角色访问.
hasAnyAuthority(String… authorities) 允许多个权限访问.
access(String attribute) 该方法使用 SpEL表达式,可以通过@service.xxx()方式实现更复杂的逻辑
hasIpAddress(String ipaddressExpression) 限制IP地址或子网

定义Spring Security的配置类

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
   @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    //定义认证管理器,默认实现为ProviderManager
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    //配置请求权限
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf()
                .disable()
                .authorizeRequests()
                .antMatchers("/oauth/**", "/login/**", "/logout/**")
                .permitAll()
                .anyRequest()
                .authenticated()
                .and()
                .formLogin()//.httpBasic();
                .permitAll();
    }

    //自定义用户数据源,从内存中读取,还是从数据库中读取
    @Override
    protected void configure(AuthenticationManagerBuilder builder) throws Exception {
        builder.inMemoryAuthentication()
                .withUser("admin")
                .password(passwordEncoder().encode("admin"))
                .authorities(Collections.emptyList());
        
    //定义user服务和验证器
        //builder.userDetailsService(userDetailsService);
        //builder.authenticationProvider(authenticationProvider());
    }
}

Spring Security OAuth2.0

Spring OAuth 2.0 是基于Oauth2.0协议的一个实现,它包含认证服务 (Authorization Service) 和资源服务 (Resource Service)两大模块,当然这两大服务离不开Spring Security框架的保驾护航,这三者构成了Spring Security OAuth2.0框架中的三板斧,后面开发都是围绕这三板斧的


image.png
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private UserService userService;

    /**
     此配置方法有以下几个用处:
     不同的授权类型(Grant Types)需要设置不同的类:
     authenticationManager:当授权类型为密码模式(password)时,需要设置此类
     AuthorizationCodeServices: 授权码模式(authorization_code) 下需要设置此类,用于实现授权码逻辑
     implicitGrantService:隐式授权模式设置此类。
     tokenGranter:自定义授权模式逻辑

     通过pathMapping<默认链接,自定义链接> 方法修改默认的端点URL
     /oauth/authorize:授权端点。
     /oauth/token:令牌端点。
     /oauth/confirm_access:用户确认授权提交端点。
     /oauth/error:授权服务错误信息端点。
     /oauth/check_token:用于资源服务访问的令牌解析端点。
     /oauth/token_key:提供公有密匙的端点,如果你使用JWT令牌的话。


     通过tokenStore来定义Token的存储方式和生成方式:
     InMemoryTokenStore
     JdbcTokenStore
     JwtTokenStore
     RedisTokenStore
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        endpoints.authenticationManager(authenticationManager)
                .tokenStore(redisTokenStore)
                .userDetailsService(userService);//这里的userDetailsService仅用于刷新令牌时检验用户有没有登录,通过令牌可以知道用户登录信息,如果已经登录
    }

    /**
     *  此方法主要是用来配置Oauth2中第三方应用的,什么是第三方应用呢,就是请求用微信、微博账号登录的程序
     *  ▶ 对于授权码 authorization_code模式,一般使用and().配置多个应用
     *  ▶ 可以使用JDBC从数据库读取
     */
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("admin")//配置client_id
                .secret(passwordEncoder.encode("admin123456"))//配置client_secret
                .accessTokenValiditySeconds(3600)//配置访问token的有效期
                .refreshTokenValiditySeconds(864000)//配置刷新token的有效期
                .redirectUris("http://www.baidu.com")//配置redirect_uri,用于授权成功后跳转
                .scopes("all")//配置申请的权限范围
                .authorizedGrantTypes("authorization_code", "password");//配置grant_type,表示授权类型
    }


    /**
     *  对端点的访问控制
     *  ▶ 对oauth/check_token,oauth/token_key访问控制,可以设置isAuthenticated()、permitAll()等权限
     *  ▶ 这块的权限控制是针对应用的,而非用户,比如当设置了isAuthenticated(),必须在请求头中添加应用的id和密钥才能访问
     */
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients()
                .passwordEncoder(passwordEncoder)
                .checkTokenAccess("isAuthenticated()")
                .tokenKeyAccess("permitAll()") ; //允许所有客户端发送器请求而不会被Spring-security拦截


    }
}
@Configuration
@EnableResourceServer //此注解会添加OAuth2AuthenticationProcessingFilter 过滤器链
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {

    /**
     * HttpSecurity配置这个与Spring Security类似:
     * 请求匹配器,用来设置需要进行保护的资源路径,默认的情况下是保护资源服务的全部路径。
     */
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest()
                .authenticated() //配置任何请求都需要认证
                 //指定不同请求方式访问资源所需要的权限,一般查询是read,其余是write。
                .antMatchers(HttpMethod.GET, "/**").access("#oauth2.hasScope('read')")
                .antMatchers(HttpMethod.POST, "/**").access("#oauth2.hasScope('write')")
                .and()
                .headers().addHeaderWriter((request, response) -> {
            response.addHeader("Access-Control-Allow-Origin", "*");//允许跨域
            if (request.getMethod().equals("OPTIONS")) {//如果是跨域的预检请求,则原封不动向下传达请求头信息
                response.setHeader("Access-Control-Allow-Methods", request.getHeader("Access-Control-Request-Method"));
                response.setHeader("Access-Control-Allow-Headers", request.getHeader("Access-Control-Request-Headers"));
            }
        });
    }

    /**
     * ResourceServerSecurityConfigurer主要配置以下几方面:
     * tokenServices:ResourceServerTokenServices 类的实例,用来实现令牌访问服务,如果资源服务和授权服务不在一块,就需要通过RemoteTokenServices来访问令牌
     * tokenStore:TokenStore类的实例,定义令牌的访问方式
     * resourceId:这个资源服务的ID
     * 其他的拓展属性例如 tokenExtractor 令牌提取器用来提取请求中的令牌。
     */
    @Override
    public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
        resources.resourceId(RESOURCE_ID)
                  .tokenServices(tokenService()) ;
    }
}

举几个栗子

经过前面的铺垫,我想大家应该对Spring 安全框架的理论知识应该有一定的了解了,下面我们看几个具体的例子

一. 简单授权

引入Spring OAuth2.0相关包

注意:一旦工程中引入了spring-cloud-starter-security包,意味着所有资源都被spring security框架接管啦,所有访问都会被限制

<parent>
   <groupId>org.springframework.boot</groupId>
   <artifactId>spring-boot-starter-parent</artifactId>
   <version>2.1.3.RELEASE</version>
</parent>
<dependency>
   <groupId>org.springframework.cloud</groupId>
   <artifactId>spring-cloud-starter-security</artifactId>
</dependency>

相关代码在spring-oauth2-simple工程下,代码比较简单,这里就不贴啦,主要看看授权码模式的请求流程:
1. 获取授权码,初次访问时要求登录

http://localhost:8080/oauth/authorize?response_type=code&client_id=wnApp&redirect_uri=http://www.baidu.com

image.png

这里我们采用的是手动授权,可以设置自动授权.autoApprove=true,就不会显示这个页面了


image.png

点击授权后会返回:

https://www.baidu.com/?code=S0LPSB

2. 授权码到手后就可以用它来获取Token啦

image.png image.png
  1. 这里必须使用POST请求,否则会报Missing grant type错误

  2. redirect_uri必须和申请code时的一致

  3. 申请令牌时scope传递的参数必须在client的scope范围内,否则会报以下错误

    {
    "error": "insufficient_scope",
    "error_description": "Insufficient scope for this resource",
    "scope": "ROLE_API"
    }

也可以使用curl请求获取

curl -X POST http://localhost:8080/oauth/token
      -H 'Authorization: Basic d25BcHA6MTIzNDU2
image.png

3. 检查令牌,检查令牌时会调用授权服务,根据令牌拿到相关的授权信息

如果在授权服务的check_token配置为isAuthenticated,那么需要验证应用密钥(client_id和client_secret),这里一定注意是应用的密钥,而非验证登录权限,这里容易搞混。

image.png

这里可以使用postman工具生成一个Authorization的Header头,或者用Base64工具生成也可以


image.png

如果系统安装了curl,使用curl请求更方便:

 curl -X POST http://localhost:8080/oauth/check_token
      -H 'Authorization: Basic d25BcHA6MTIzNDU2

4. 刷新令牌
刷新Token也算一种授权模式:grant_type=refresh_token,所以也是请求/ oauth/token

curl -i -X POST  -u 'wnApp:123456' -d 'grant_type=refresh_token&refresh_token=95844d87-f06e-4a4e-b76c-f16c5329e287' http://localhost:8080/oauth/token
image.png

刷新令牌有点特别,必须要配置UserDetailService,否则会报错,这个其实也不难理解,因为刷新令牌时需要检验用户有没有登录凭证,检查登录凭证时就需要UserDetailService


image.png

5. 访问资源

先用检查下令牌都有哪些权限,可以看到有list、info2权限,但没有info、info3权


image.png

大家来想几个问题, 通过令牌怎么能获取到用户权限呢?这不用问肯定请求授权服务了,授权服务在用户登录时,已经将权限加载到内存中了,所以直接从Principal中就能拿到权限,但对于微服务来说,认证中心和资源是远程通信的,以后每请求方法都要远程检查令牌是否有访问权限,这个代价是很大的,所以通常采用RedisTokenStore或JwtTokenStore,这两种方案各有优缺点,后面会重点介绍。

分别定义三个请求info、info2、info3,从上面检查令牌可知,令牌只有info2的权限


image.png

分别用令牌访问三个请求发现,虽然令牌没有info3的权限但依然能访问,这是怎么回事呢?这是因为资源服务的权限控制只检查带@PreAuthorize现在的方法


image.png

访问资源时检查是否经过用户授权

scope一般表示想从用户那获取到某一类信息,通常可设置接口名,比如scope=getUserInfo,表示想获取用户的个人信息,如果用户刚好也开通了这个接口的权限,那么应用就能调用getUserInfo方法拿到用户信息啦。那么资源服务是怎么知道某个令牌里包含具体某个用户的授权呢?通过上面check_token返回的内容可知,里面包含具体授权的用户名,拿这个用户名请求getUserInfo接口时,我们只要控制只能请求authentication中包含是具体用户名就可以了

为了控制访问在方法上添加hasAnyScope判断是否当前请求的应用scope是否包含该接口,u == authentication.name判断请求的用户是否和授权用户匹配

    @GetMapping(value = "/getUserInfo/{userName}")
    @PreAuthorize("#oauth2.hasAnyScope('getUserInfo') and #u == authentication.name")
    public User getUserInfo(@Param("u") String userName){
        ....
    }

二. 持久化例子

我们前面无论是用户信息、Client信息、Token信息都是保存在内存中,下面看个如何从数据库获取这些信息。

用户表

CREATE TABLE `sys_user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(32) NOT NULL COMMENT '用户名称',
  `password` varchar(120) NOT NULL COMMENT '密码',
  `status` int(1) DEFAULT '1' COMMENT '1开启0关闭',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

第三方应用信息表

CREATE TABLE `oauth_client_details` (
  `client_id` varchar(128) NOT NULL COMMENT '客户端id',
  `resource_ids` varchar(256) DEFAULT NULL COMMENT '客户端所能访问的资源id集合',
  `client_secret` varchar(256) DEFAULT NULL COMMENT '客户端访问密匙',
  `scope` varchar(256) DEFAULT NULL COMMENT '客户端申请的权限范围',
  `authorized_grant_types` varchar(256) DEFAULT NULL COMMENT '授权类型',
  `web_server_redirect_uri` varchar(256) DEFAULT NULL COMMENT '客户端重定向URI',
  `authorities` varchar(256) DEFAULT NULL COMMENT '客户端权限',
  `access_token_validity` int(11) DEFAULT NULL COMMENT 'access_token的有效时间(单位:秒)',
  `refresh_token_validity` int(11) DEFAULT NULL COMMENT 'refresh_token的有效时间(单位:秒)',
  `additional_information` varchar(4096) DEFAULT NULL COMMENT '预留字段,JSON格式',
  `autoapprove` varchar(256) DEFAULT NULL COMMENT '否自动Approval操作',
  PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户端详情';

修改application.yml配置

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/oauth2?serverTimezone=UTC&useUnicode=true&characterEncoding=utf-8&useSSL=true
    username: root
    password:

只需把授权服务类中内存相关缓存jdbc即可,同时自定义一个UserDetailsService的子类,用于定义查询用户逻辑

@Configuration
@EnableAuthorizationServer
public class OauthServerConfig extends AuthorizationServerConfigurerAdapter {

    //数据库连接池对象
    @Autowired
    private DataSource dataSource;

    //从数据库读取用户信息
    @Autowired
    private UserDetailsService userService;

    //此对象是将security认证对象注入到oauth2框架中
    @Autowired
    private AuthenticationManager authenticationManager;

    //客户端(第三方应用)信息来源
    @Bean
    public JdbcClientDetailsService jdbcClientDetailsService(){
        return new JdbcClientDetailsService(dataSource);
    }

    //token保存策略
    @Bean
    public TokenStore tokenStore(){
        return new JdbcTokenStore(dataSource);
    }

    //授权信息保存策略
    @Bean
    public ApprovalStore approvalStore(){
        return new JdbcApprovalStore(dataSource);
    }

    //授权码模式数据来源
    @Bean
    public AuthorizationCodeServices authorizationCodeServices(){
        return new JdbcAuthorizationCodeServices(dataSource);
    }

    //指定客户端信息的数据库来源
    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(jdbcClientDetailsService());
    }

    //检查token的策略
    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
        security.checkTokenAccess("isAuthenticated()");
    }

    //OAuth2的主配置信息,这个方法相当于把前面的所有配置到装配到endpoints中让其生效
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .approvalStore(approvalStore())
                .authenticationManager(authenticationManager)
                .authorizationCodeServices(authorizationCodeServices())
                .tokenStore(tokenStore());
    }

}

相关代码在spring-oauth2-jdbc工程中

三. RedisToken和JwtToken

前面我们演示了Token存储在内存中和数据库的例子,这个例子我们看看怎么将Token保存到Redis中和客户端中。

1. 将token保存到redis中

相关测试代码:

spring-oauth2-redis +
 - auth
 - common
 - order

# 请求授权服务器获取token
curl --location --request POST 'http://localhost:8085/oauth/token?username=user&password=123456&grant_type=password&scope=local' \
--header 'Authorization: Basic bWU6MTIzNDU2' 

#通过token请求订单服务的 o1接口
curl --location --request GET 'http://localhost:8086/o1' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Bearer 880cc949-69c1-4179-a715-f8d17454bf6b' 

(1) 授权服务类中(认证微服务)
application.yml配置redis连接

spring:
    redis:
      url: redis://localhost:6379

添加redis tokenStore相关配置

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
  
    //redis连接工厂
    @Autowired
    private RedisConnectionFactory connectionFactory;

    //token 管理类,负责token的保存和读取
    @Bean
    public TokenStore tokenStore() {
        RedisTokenStore redis = new RedisTokenStore(connectionFactory);
        return redis;
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore())
                .tokenServices(tokenService())
                ....
    }


}

(2) 资源服务类中(订单微服务)
在微服务中只需要配置资源服务类就可以了,当用户请求订单微服时,它会通过RemoteTokenServices 远程请求授权服务器,拿到token对应的权限上下文信息,请求时必须配置客户端账号。如果是微服务还需要配置负载均衡器。

    /**
     * 资源服务令牌解析服务,此例中因为使用的是基于客户端的jwt token所以这个类用不到
     */
    @Bean
    public ResourceServerTokenServices tokenService() {
        //使用远程服务请求授权服务器校验token,必须指定校验token 的url、client_id,client_secret
        RemoteTokenServices service=new RemoteTokenServices();
//通过token请求授权服务类获取权限相关信息    service.setCheckTokenEndpointUrl("http://localhost:8085/oauth/check_token");
        service.setClientId("wnApp");
        service.setClientSecret("123456");
        return service;
    }

可以将授权服务添加配置文件中:

security:
  oauth2:
    client:
      token-info-uri: http://localhost:8085/oauth/check_token
      client-id:  wnApp 
      client-secret: 123456
2. 将token保存到客户端中

将token保存在客户端,意味着授权服务不存储token了,token只保存在客户端,在生成token时用jwt算法将权限等信息编码到token(OAuth2AccessToken)中,生成一个big token;每次客户端访问资源(微服务)时,服务端再用jwt算法解码成权限信息(OAuth2Authentication)。这种token适合在微服务之间传播,我们知道jwt算法默认是对称加密的,这样令牌容易被伪造,为了保证token的安全性,我们一般通过非对称加密,生成token时采用私钥加密,token解码时资源服务器请求授权服务器获取公钥,使用公钥解密,因为公钥只解密不能加密,所以令牌不能为伪造。

image.png

前面介绍将Token放到Redis中使用RedisTokenStore类,那么将Token 存放客户端需要注入JwtTokenStore类

    @Bean
    public TokenStore tokenStore() {
        //JWT令牌存储方案
        return new JwtTokenStore(accessTokenConverter());
    }

我们知道Jwt Token是可以不存储的,那么现在让我们介绍两个东西:

    public JwtTokenStore(JwtAccessTokenConverter jwtTokenEnhancer) {
        this.jwtTokenEnhancer = jwtTokenEnhancer;
    }

JwtAccessTokenConverter 是二合一的转换器,既能增强token,又能转换token

public class JwtAccessTokenConverter implements TokenEnhancer, AccessTokenConverter, InitializingBean {

将tokenStore和 tokenEnhancer注入到TokenService中

DefaultTokenServices service=new DefaultTokenServices();
 //令牌管理器
service.setTokenStore(tokenStore);

//令牌增强
TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
tokenEnhancerChain.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter ));
//我们前面提到过jwtAccessTokenConverter本身是一个二合一的转换器
//所以这里可以直接注入service.setTokenEnhancer(jwtAccessTokenConverter)到TokenService中
service.setTokenEnhancer(tokenEnhancerChain);

我们看到一般都是将增强器先注入到一个TokenEnhancerChain 中,那这个东西又是干嘛的呢?TokenEnhancerChain 类似的装饰器模式(Decorator Pattern) ,它做的事情特别简单,就是将多个Token增强器依次对普通token进行增强,比如用A增强器给token附加了A信息,再用B增强器给token附加了B信息,这样这个token就拥有了A和B的信息,有点像spring中的AOP,增强bean成一个更强大的bean。

public class TokenEnhancerChain implements TokenEnhancer {
    ....
    public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
        OAuth2AccessToken result = accessToken;
        for (TokenEnhancer enhancer : delegates) {
            result = enhancer.enhance(result, authentication);
        }
        return result;
    }

}

完整代码如下:

@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
  
    //jwt token管理器
    @Bean
    public TokenStore tokenStore() {
        //JWT令牌存储方案
        return new JwtTokenStore(accessTokenConverter());
    }

    //token转换器
    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setSigningKey(SIGNING_KEY); //对称秘钥,资源服务器使用该秘钥来验证
        return converter;
    }

    //令牌管理服务
    @Bean
    public AuthorizationServerTokenServices tokenService() {
        DefaultTokenServices service=new DefaultTokenServices();
        service.setClientDetailsService(clientDetailsService);//客户端详情服务
        service.setSupportRefreshToken(true);//支持刷新令牌
        service.setTokenStore(tokenStore);//令牌存储策略

        //令牌增强
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(accessTokenConverter));

        service.setTokenEnhancer(tokenEnhancerChain);
        service.setAccessTokenValiditySeconds(7200); // 令牌默认有效期2小时
        service.setRefreshTokenValiditySeconds(259200); // 刷新令牌默认有效期3天
        return service;
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore)
                .tokenServices(tokenService())
                .authorizationCodeServices(authorizationCodeServices)
                .userDetailsService(userService) //只有刷新令牌才会用到用户服务来验证是否已经登录
        ;

        endpoints.allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE);
    }

}

对token进行非对称加密
可使用 ssh-keygen -t rsa 命令生成一对公私钥

  // 在授权服务器端token转换器中同时配置公钥和私钥
  @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        
        KeyPair keyPair =  new KeyPair(rsaProp.getPublicKey(),rsaProp.getPrivateKey()) ;
        converter.setKeyPair(keyPair);
        return converter;
    }

网上通常做法是远程拉取公钥文件,而我这里是直接把公钥文件放在资源服务器端:

security:
  oauth2:
    resource:
      jwt:
        key-uri: http://localhost:53020/oauth/token_key

在OauthResourceServerAutoConfiguration中配置公钥文件

@Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();

        //对称秘钥,资源服务器使用该秘钥来验证
        //converter.setSigningKey(SIGNING_KEY);

        //非对接加密
        try {
            File pubKeyFile = ResourceUtils.getFile("classpath:rsa/id_key_rsa.pub");
            RsaVerifier rsaVerifier = new RsaVerifier((RSAPublicKey) RsaUtils.getPublicKey(pubKeyFile.getPath()));
            converter.setVerifier(rsaVerifier);
        } catch (Exception e) {
            log.error("加载证书公钥文件出错:",e);
        }
        return converter;
    }

而Oauth2底层是通过JwtAccessTokenConverter中的encode 和 decode方法来加解密token的。

测试的时候要注意client_id是否拥有访问的资源及其scop权限

详细源码在spring-oauth2-token 工程中

四. 微服务分布式授权

重头戏终于来了,微服务授权才是我们今天的重点内容,大家想想的其实微服务授权和单体工程授权区别就在于token怎么传播,其他的像生成token、验证token基本都一样。所以微服务这块我们重点讲讲token是怎么传播的


image.png

我们定义了两个微服务order和product,在order中调用product

   @GetMapping(value = "/o2")
    @PreAuthorize("hasAuthority('p2')")
    public String r2(){
        //获取用户身份信息
        String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
        return username +":订单2:"+productService.getProduct3();//这里订单2没有获取商品3的权限
    }

通过网关携带Authorization头访问order微服务,将Authorization头通过 Feign拦截器放到header中,在product微服务中会自动解析令牌并生成权限对象并注入到权限上下文中,这个自动解析的过程后面会解释。

@Configuration
public class FeignInterceptorAutoConfig implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        //添加token
        requestTemplate.header("Authorization", attributes.getRequest().getHeader("Authorization"));
    }
}

下面让我们看看这个自动解析token的过程,还记得前面讲的security的filter吗,这些过滤器是自上而下执行

Security filter chain: [
WebAsyncManagerIntegrationFilter
SecurityContextPersistenceFilter
HeaderWriterFilter
LogoutFilter
OAuth2AuthenticationProcessingFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
FilterSecurityInterceptor
]

其中OAuth2AuthenticationProcessingFilter过滤器,是专门用来将Authorization=Bearer xxx请求头中的令牌解析成Authentication权限对象,解析过程大概为:


image.png

为了方便注入拦截器,我们定义一个@EnableOauthFeignClients的注解对象,在这个注解对象中实现Feign拦截器的自动装配,如果不需要注入Feign拦截器就换成@EnableFeignClients注解。

@EnableOauthFeignClients //不需要注入Feign拦截器就换成@EnableFeignClients注解
@EnableOauthResourceServer //自定义资源服务注解
@EnableDiscoveryClient
@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

我们前面资源服务权限的控制都是通过继承ResourceServerConfigurerAdapter类控制资源服务的,但是这样每个微服务都需要重新定义一下对资源服务访问的控制,没法实现可插拔式,所以我们一般需要自定义@EnableResourceServer这个注解来定制权限控制

@Documented
@Inherited
@EnableResourceServer
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@EnableWebSecurity(debug = true)//打印security过滤器信息
@Import(OauthResourceServerAutoConfiguration.class)
public @interface EnableOauthResourceServer {

}

好了,下面让我们集成测试一下,看看效果,这次我们使用password授权模式测试,启动微服务时建议使用IDEA 的run dashboard。

  1. 首先在数据库新增一个名称为me的应用


    image.png
  2. 生成令牌

http://localhost:53010/auth/oauth/token?username=user&password=123456&grant_type=password&scope=local

image.png

别忘了先生成一个Authorization头


image.png
  1. 检查令牌

curl -X POST http://localhost:53010/auth/oauth/check_token
-H 'Authorization: Basic d25BcHA6MTIzNDU2

image.png
  1. 令牌具有p1、p2权限,访问“订单1 > 商品1”正常,但没有商品3权限,当访问“订单2 > 商品3”提示没权限访问,测试没问题。


    image.png

    可以自定义AccessDeniedHandler来定制权限信息


    image.png

五. 单点登录(SSO)

spring 提供了专门单点登录的注解,只需要在每个客户端app的安全配置类上添加该注解就能实现单点登录的功能,当然肯定少不了一些配置,这个代码还没实现,后续会实现,大概配置如下:

@Configuration
@EnableOAuth2Sso
public class SecurityConfig extends WebSecurityConfigurerAdapter {
       .....
}

在application.yml中配置

security:
    oauth2:
        client:
            clientId: sso
            clientSecret: 123456
            accessTokenUri: http://localhost:8080/oauth/token
            userAuthorizationUri: http://localhost:8080/oauth/authorize
        resource:
            userInfoUri: http://localhost:8080/user

案例中所有代码

https://gitee.com/little-ant/open_source_project/tree/master/Spring-Cloud-Oauth2

参考

http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html
https://open.taobao.com/doc.htm?docId=102635&docType=1
https://projects.spring.io/spring-security-oauth/docs/oauth2.html
http://www.tianshouzhi.com/api/tutorials/spring_security_4

上一篇 下一篇

猜你喜欢

热点阅读