一文读懂Spring Cloud Oauth2.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 中的角色:

  • Third-party application:第三方应用程序,这里就是万能App,万能网站。
  • Resource Owner:这个有点不好理解,比如你是淘宝的一个用户,你能拥有淘宝的那些资源呢?也就是个人信息、收货地址、订单信息这些吧,说白了你就是要把个人的一些隐私信息暴露给淘宝以为的第三方应用,但是你暴露给第三方应用,第三方应用就能访问了吗,也不一定,这个我后面会解释。
  • User Agent:就是通过什么工具来完成这个授权过程,比如浏览器呢,Postman呢,还是HttpClient呢。
  • Authorization server:授权服务器会验证两方面的信息:首先就是第三方应用的信息,先看看跟淘宝报过山头没,比如一看是京东,那二说不说,直接拒绝啦。其次就是验证用户的信息,看看淘宝库里有没有这个用户。这些都没问题了,会为第三方应用颁发access token。有了这个令牌就能调用淘宝的相关接口了。
  • Resource server: 什么是资源服务呢?对于淘宝来说,就是商品服务、订单服务、物流信息等等,这些信息只有在淘宝上开通了相关权限才能调用。而对于第三方应用来说它后台的服务其实也是资源服务。有一些资源是无需用户授权的,比如查询淘宝的商品类别这些资源,不涉及什么个人隐私,这些信息直接用client_id和client_secret就可以获取到。

OAuth 2.0两种常用授权模式:

  • 授权码模式(Authorization Code)
    这个是Oauth最安全最常用的一种模式,比如用户在万能App上通过淘宝账号登录,万能App会引导用户先取淘宝上登录,用户也同意把隐私信息暴露给万能App,这是淘宝会返回一个code,万能App用这个code在加上在淘宝上申请的client_id和client_secret去获取一个access token。


    image.png
  • 简化模式(implicit grant type)不通过第三方应用程序的服务器,直接在浏览器中向认证服务器申请令牌,跳过了"授权码"这个步骤,因此得名。所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。

  • 密码凭证模式(Resource Owner Password Credentials)
    这种模式下会把用户名和密码泄露给第三方应用,所以淘宝、微信不会傻到用这种模式授权,那么它存在的价值是什么呢,为了做统一认证。当用淘宝账号登录第三方应用时,只有用户同意授权后,第三方应用才能访问用户信息,那么当用户登录淘宝时,这时还需要用户授权吗,当然不需要啦,当然凭证也不存在泄露给第三方啦。这种模式下其实就相当于普通的用户名密码认证,没什么授权一说。

  • 客户端模式(Client Credentials Grant) 这种模式跟授权没有半毛钱关系,其实就是一种认证方式,比如在某一个开放平台开通了一个短信服务,你直接用服务商给你的应用ID和密钥调用短信服务了,这个过程是不需要授权的,其实只有你要访问别人的隐私才需要授权

应用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

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

  • SecurityContextPersistenceFilter 这个Filter是整个拦截过程的入口和出口(也就是第一个和最后一个拦截器),会在请求开始时从配置好的 SecurityContextRepository 中获取 SecurityContext,然后把它设置给SecurityContextHolder。在请求完成后将 SecurityContextHolder 持有的 SecurityContext 再保存到配置好的 SecurityContextRepository,同时清除 securityContextHolder 所持有的 SecurityContext;
  • UsernamePasswordAuthenticationFilter 用于处理来自表单提交的认证。该表单必须提供对应的用户名和密码,其内部还有登录成功或失败后进行处理的 AuthenticationSuccessHandler 和AuthenticationFailureHandler,这些都可以根据需求做相关改变;
  • FilterSecurityInterceptor 是用于保护web资源的,使用AccessDecisionManager对当前用户进行授权访问;
  • ExceptionTranslationFilter 能够捕获来自 FilterChain 所有的异常,并进行处理。但是它只会处理两类异常:AuthenticationException 和 AccessDeniedException,其它的异常它会继续抛出。


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

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

  • 用表单登录时,会调用UsernamePasswordAuthenticationFilter过滤器,然后会调用DaoAuthenticationProvider.authenticate()方法对用户名和密码进行验证,验证成功后返回UsernamePasswordAuthenticationToken
  • 用户信息如何获取呢?通过UserDetailsService的实现类加载,从内存中加载使用InMemoryUserDetailsManager,从DB加载使用JdbcUserDetailsManager
  • 如何自定义用户信息呢?通过继承UserDetails类,在UserDetailsService.loadUserByUsername()实现方法中返回自定义用户对象
  • 如何定义加密方式呢,通过PasswordEncoder的实现类
    认证成功后会把用户身份信息放入SecurityContextHolder(类似Shiro中的SecurityUtils)

授权过程
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是可以不存储的,那么现在让我们介绍两个东西:

  • 增强器(Enhancer ):什么是增强器呢,将权限等信息增加到一个普通token(比较短)中,这样直接拿这个token就能进行验证了,无需在请求再从其他存储中获取权限信息啦。
  • 转换器(Converter):转换器就从当对JwtToken编码和解码的工作
    JwtTokenStore的构造方法注入了一个JwtAccessTokenConverter 转换器
    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

参考

Oauth 2.0
Taobao Oauth
Spring Security Oauth
Spring Security JWT

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容