一文讀懂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/con?rm_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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容