Spring Boot2.0 Oauth2 服務器和客戶端配置及原理

1、應用場景

爲了理解OAuth的適用場合,讓我舉一個假設的例子。java

有一個"雲沖印"的網站,能夠將用戶儲存在Google的照片,沖印出來。用戶爲了使用該服務,必須讓"雲沖印"讀取本身儲存在Google上的照片。web

問題是隻有獲得用戶的受權,Google纔會贊成"雲沖印"讀取這些照片。那麼,"雲沖印"怎樣得到用戶的受權呢?redis

傳統方法是,用戶將本身的Google用戶名和密碼,告訴"雲沖印",後者就能夠讀取用戶的照片了。這樣的作法有如下幾個嚴重的缺點。spring

  1. "雲沖印"爲了後續的服務,會保存用戶的密碼,這樣很不安全。
  2. Google不得不部署密碼登陸,而咱們知道,單純的密碼登陸並不安全。
  3. "雲沖印"擁有了獲取用戶儲存在Google全部資料的權力,用戶無法限制"雲沖印"得到受權的範圍和有效期。
  4. 用戶只有修改密碼,才能收回賦予"雲沖印"的權力。可是這樣作,會使得其餘全部得到用戶受權的第三方應用程序所有失效。
  5. 只要有一個第三方應用程序被破解,就會致使用戶密碼泄漏,以及全部被密碼保護的數據泄漏。

 OAuth就是爲了解決上面這些問題而誕生的。數據庫

2、名詞定義

在詳細講解OAuth 2.0以前,須要瞭解幾個專用名詞。它們對讀懂後面的講解,尤爲是幾張圖,相當重要。express

  • Third-party application:第三方應用程序,本文中又稱"客戶端"(client),即上一節例子中的"雲沖印"。
  • HTTP service:HTTP服務提供商,本文中簡稱"服務提供商",即上一節例子中的Google。
  • Resource Owner:資源全部者,本文中又稱"用戶"(user)。
  • User Agent:用戶代理,本文中就是指瀏覽器。
  • Authorization server:認證服務器,即服務提供商專門用來處理認證的服務器。
  • Resource server:資源服務器,即服務提供商存放用戶生成的資源的服務器。它與認證服務器,能夠是同一臺服務器,也能夠是不一樣的服務器。  

知道了上面這些名詞,就不難理解,OAuth的做用就是讓"客戶端"安全可控地獲取"用戶"的受權,與"服務商提供商"進行互動。api

3、OAuth的思路

OAuth在"客戶端"與"服務提供商"之間,設置了一個受權層(authorization layer)。"客戶端"不能直接登陸"服務提供商",只能登陸受權層,以此將用戶與客戶端區分開來。"客戶端"登陸受權層所用的令牌(token),與用戶的密碼不一樣。用戶能夠在登陸的時候,指定受權層令牌的權限範圍和有效期。瀏覽器

"客戶端"登陸受權層之後,"服務提供商"根據令牌的權限範圍和有效期,向"客戶端"開放用戶儲存的資料。緩存

4、客戶端的受權模式

客戶端必須獲得用戶的受權(authorization grant),才能得到令牌(access token)。OAuth 2.0定義了四種受權方式。安全

  • 受權碼模式(authorization code)
  • 簡化模式(implicit)
  • 密碼模式(resource owner password credentials)
  • 客戶端模式(client credentials)

5、受權碼模式

受權碼模式(authorization code)是功能最完整、流程最嚴密的受權模式。它的特色就是經過客戶端的後臺服務器,與"服務提供商"的認證服務器進行互動。

它的步驟以下:

(A)用戶訪問客戶端,後者將前者導向認證服務器。
(B)用戶選擇是否給予客戶端受權。
(C)假設用戶給予受權,認證服務器將用戶導向客戶端事先指定的"重定向URI"(redirection URI),同時附上一個受權碼。
(D)客戶端收到受權碼,附上早先的"重定向URI",向認證服務器申請令牌。這一步是在客戶端的後臺的服務器上完成的,對用戶不可見。
(E)認證服務器覈對了受權碼和重定向URI,確認無誤後,向客戶端發送訪問令牌(access token)和更新令牌(refresh token)。

6、簡化模式

簡化模式(implicit grant type)不經過第三方應用程序的服務器,直接在瀏覽器中向認證服務器申請令牌,跳過了"受權碼"這個步驟,所以得名。全部步驟在瀏覽器中完成,令牌對訪問者是可見的,且客戶端不須要認證。

它的步驟以下:

(A)客戶端將用戶導向認證服務器。

(B)用戶決定是否給於客戶端受權。

(C)假設用戶給予受權,認證服務器將用戶導向客戶端指定的"重定向URI",並在URI的Hash部分包含了訪問令牌。

(D)瀏覽器向資源服務器發出請求,其中不包括上一步收到的Hash值。

(E)資源服務器返回一個網頁,其中包含的代碼能夠獲取Hash值中的令牌。

(F)瀏覽器執行上一步得到的腳本,提取出令牌。

(G)瀏覽器將令牌發給客戶端。

7、密碼模式

密碼模式(Resource Owner Password Credentials Grant)中,用戶向客戶端提供本身的用戶名和密碼。客戶端使用這些信息,向"服務商提供商"索要受權。

在這種模式中,用戶必須把本身的密碼給客戶端,可是客戶端不得儲存密碼。這一般用在用戶對客戶端高度信任的狀況下,好比客戶端是操做系統的一部分,或者由一個著名公司出品。而認證服務器只有在其餘受權模式沒法執行的狀況下,才能考慮使用這種模式。

它的步驟以下:

(A)用戶向客戶端提供用戶名和密碼。

(B)客戶端將用戶名和密碼發給認證服務器,向後者請求令牌。

(C)認證服務器確認無誤後,向客戶端提供訪問令牌。

8、客戶端模式

客戶端模式(Client Credentials Grant)指客戶端以本身的名義,而不是以用戶的名義,向"服務提供商"進行認證。嚴格地說,客戶端模式並不屬於OAuth框架所要解決的問題。在這種模式中,用戶直接向客戶端註冊,客戶端以本身的名義要求"服務提供商"提供服務,其實不存在受權問題。

它的步驟以下:

(A)客戶端向認證服務器進行身份認證,並要求一個訪問令牌。

(B)認證服務器確認無誤後,向客戶端提供訪問令牌。

9、更新令牌

若是用戶訪問的時候,客戶端的"訪問令牌"已通過期,則須要使用"更新令牌"申請一個新的訪問令牌。

客戶端發出更新令牌的HTTP請求,包含如下參數:

  • granttype:表示使用的受權模式,此處的值固定爲"refreshtoken",必選項。
  • refresh_token:表示早前收到的更新令牌,必選項。
  • scope:表示申請的受權範圍,不能夠超出上一次申請的範圍,若是省略該參數,則表示與上一次一致。

10、client_credentials代碼示範

首先引入主要jar包:

<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
     <groupId>org.springframework.security.oauth</groupId>
     <artifactId>spring-security-oauth2</artifactId>
     <version>2.3.3.RELEASE</version>
</dependency>
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
     <groupId>org.springframework.data</groupId>
     <artifactId>spring-data-redis</artifactId>
</dependency>
<dependency>
     <groupId>redis.clients</groupId>
     <artifactId>jedis</artifactId>
     <version>2.9.0</version>
</dependency>

下面配置獲取token的配置文件:

package cn.chinotan.config.oauth;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.token.store.redis.RedisTokenStore;

/**
 * @program: test
 * @description: OAuth2服務配置
 * @author: xingcheng
 * @create: 2018-12-01 16:27
 **/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    @Qualifier("authenticationManagerBean")
    private AuthenticationManager authenticationManager ;

    @Autowired
    private RedisConnectionFactory connectionFactory;

    @Bean
    public RedisTokenStore tokenStore() {
        // redis 存儲token,方便集羣部署
        return new RedisTokenStore(connectionFactory);
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints
                .authenticationManager(authenticationManager) // 配置認證管理器
                .tokenStore(tokenStore()); // 使用redis進行token存儲
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security
                .tokenKeyAccess("permitAll()") 
                .checkTokenAccess("isAuthenticated()")
                .allowFormAuthenticationForClients(); // 容許表單認證
    }

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.inMemory()
                .withClient("start_test_two") // 獲取token的客戶端id
                .secret("start_test_two") // 獲取token密鑰
                .scopes("start_test_two") // 資源範圍
                .authorizedGrantTypes("client_credentials", "password", "refresh_token") // 受權類型
                .resourceIds("oauth2-resource") // 資源id
                .accessTokenValiditySeconds(120); // token 有效時間
    }
}

其中,RedisTokenStore這個是基於Redis的實現,令牌(Access Token)會保存到Redis中,須要配置Redis的鏈接服務

# Redis數據庫索引(默認爲0)
spring.redis.database: 0
# Redis服務器地址
spring.redis.host: 127.0.0.1
# Redis服務器鏈接端口
spring.redis.port: 6379
# Redis服務器鏈接密碼(默認爲空)
spring.redis.password: 
# 鏈接池最大鏈接數(使用負值表示沒有限制)
spring.redis.pool.max-active: 8
# 鏈接池最大阻塞等待時間(使用負值表示沒有限制)
spring.redis.pool.max-wait: -1
# 鏈接池中的最大空閒鏈接
spring.redis.pool.max-idle: 8
# 鏈接池中的最小空閒鏈接
spring.redis.pool.min-idle: 0
# 鏈接超時時間(毫秒)
spring.redis.timeout: 100
package cn.chinotan.config.redis;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @program: test
 * @description: redis
 * @author: xingcheng
 * @create: 2018-12-01 17:09
 **/
@Configuration
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport {

    @Value("${spring.redis.host}")
    private String host;
    @Value("${spring.redis.port}")
    private int port;
    @Value("${spring.redis.timeout}")
    private int timeout;

    @Autowired
    private JedisConnectionFactory jedisConnectionFactory;

    /**
     * Logger
     */
    private static final Logger lg = LoggerFactory.getLogger(RedisConfig.class);

    @Bean
    @Override
    public KeyGenerator keyGenerator() {
        //  設置自動key的生成規則,配置spring boot的註解,進行方法級別的緩存
        // 使用:進行分割,能夠不少顯示出層級關係
        // 這裏其實就是new了一個KeyGenerator對象
        return (target, method, params) -> {
            StringBuilder sb = new StringBuilder();
            sb.append(target.getClass().getName());
            sb.append(":");
            sb.append(method.getName());
            for (Object obj : params) {
                sb.append(":" + String.valueOf(obj));
            }
            String rsToUse = String.valueOf(sb);
            return rsToUse;
        };
    }

    //緩存管理器
    @Bean
    public CacheManager cacheManager(RedisTemplate redisTemplate) {
        // 初始化緩存管理器,在這裏咱們能夠緩存的總體過時時間什麼的,我這裏默認沒有配置
        RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager
                .RedisCacheManagerBuilder
                .fromConnectionFactory(jedisConnectionFactory);
        return builder.build();
    }
    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory){
        //設置序列化
        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        ObjectMapper om = new ObjectMapper();
        om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        jackson2JsonRedisSerializer.setObjectMapper(om);
        // 配置redisTemplate
        RedisTemplate<Object, Object> redisTemplate = new RedisTemplate<Object, Object>();
        redisTemplate.setConnectionFactory(jedisConnectionFactory);
        RedisSerializer stringSerializer = new StringRedisSerializer();
        redisTemplate.setKeySerializer(stringSerializer); // key序列化
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer); // value序列化
        redisTemplate.setHashKeySerializer(stringSerializer); // Hash key序列化
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer); // Hash value序列化
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

    @Override
    @Bean
    public CacheErrorHandler errorHandler() {
        // 異常處理,當Redis發生異常時,打印日誌,可是程序正常走
        CacheErrorHandler cacheErrorHandler = new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException e, Cache cache, Object key) {
                lg.error("Redis occur handleCacheGetError:key -> [{}]", key, e);
            }

            @Override
            public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) {
                lg.error("Redis occur handleCachePutError:key -> [{}];value -> [{}]", key, value, e);
            }

            @Override
            public void handleCacheEvictError(RuntimeException e, Cache cache, Object key)    {
                lg.error("Redis occur handleCacheEvictError:key -> [{}]", key, e);
            }

            @Override
            public void handleCacheClearError(RuntimeException e, Cache cache) {
                lg.error("Redis occur handleCacheClearError:", e);
            }
        };
        return cacheErrorHandler;
    }
}

以後配置資源服務器:

package cn.chinotan.config.oauth;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;

import javax.servlet.http.HttpServletResponse;

/**
 * @program: test
 * @description: Resource服務配置
 * @author: xingcheng
 * @create: 2018-12-01 16:30
 **/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {


}

以及Web安全配置:

package cn.chinotan.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.access.expression.method.MethodSecurityExpressionHandler;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.oauth2.provider.expression.OAuth2MethodSecurityExpressionHandler;

import javax.servlet.http.HttpServletResponse;

/**
 * @program: test
 * @description: WebSecurityConfig
 * @author: xingcheng
 * @create: 2018-12-01 17:29
 **/
@Configuration
@EnableWebSecurity
@Order(Ordered.HIGHEST_PRECEDENCE)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.csrf().disable()
                .exceptionHandling() // 統一異常處理
                .authenticationEntryPoint((request, response, authException) -> response.sendError(HttpServletResponse.SC_UNAUTHORIZED)) // 自定義異常返回
                .and()
                .authorizeRequests()
                .antMatchers("/api/**") 
                .authenticated() // 攔截全部/api開頭下的資源路徑,包括其/api自己
                .anyRequest()
                .permitAll()// 其餘請求無需認證
                .and()
                .httpBasic(); // 啓用httpBasic認證
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder()).withUser("start_test_two").password(new BCryptPasswordEncoder().encode("start_test_two")).roles("USER"); // 內存中配置httpBasic認證名和密碼,使用BCryptPasswordEncoder加密
    }
}

其中注意WebSecurityConfigurerAdapter和ResourceServerConfigurerAdapter都有對於HttpSecurity的配置:

而在ResourceServerConfigurer中,默認全部接口都須要認證:

且一旦匹配上一個filter後就不會走其餘的filter了,所以須要將WebSecurityConfigurerAdapter的調用順序調到最高級:

@Order(Ordered.HIGHEST_PRECEDENCE)

配置完成後啓動:

能夠看到暴露了/oauth/token接口

Spring-Security-Oauth2的提供的jar包中內置了與token相關的基礎端點。本文認證與受權token與/oauth/token有關,其處理的接口類爲TokenEndpoint。下面咱們來看一下對於認證與受權token流程的具體處理過程。

1 @FrameworkEndpoint
 2 public class TokenEndpoint extends AbstractEndpoint {
 3     ... 
 4     @RequestMapping(value = "/oauth/token", method=RequestMethod.POST)
 5     public ResponseEntity<OAuth2AccessToken> postAccessToken(Principal principal, @RequestParam
 6     Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
 7     //首先對client信息進行校驗
 8         if (!(principal instanceof Authentication)) {
 9             throw new InsufficientAuthenticationException(
10                     "There is no client authentication. Try adding an appropriate authentication filter.");
11         }
12         String clientId = getClientId(principal);
13         //根據請求中的clientId,加載client的具體信息
14         ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);
15         TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);
16         ... 
17         
18         //驗證scope域範圍
19         if (authenticatedClient != null) {
20             oAuth2RequestValidator.validateScope(tokenRequest, authenticatedClient);
21         }
22         //受權方式不能爲空
23         if (!StringUtils.hasText(tokenRequest.getGrantType())) {
24             throw new InvalidRequestException("Missing grant type");
25         }
26         //token endpoint不支持Implicit模式
27         if (tokenRequest.getGrantType().equals("implicit")) {
28             throw new InvalidGrantException("Implicit grant type not supported from token endpoint");
29         }
30         ...
31         
32         //進入CompositeTokenGranter,匹配受權模式,而後進行password模式的身份驗證和token的發放
33         OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);
34         if (token == null) {
35             throw new UnsupportedGrantTypeException("Unsupported grant type: " + tokenRequest.getGrantType());
36         }
37         return getResponse(token);
38     }
39     ...

口處理的主要流程就是對authentication信息進行檢查是否合法,不合法直接拋出異常,而後對請求的GrantType進行處理,根據GrantType,進行password模式的身份驗證和token的發放。下面咱們來看下TokenGranter的類圖。

能夠看出TokenGranter的實現類CompositeTokenGranter中有一個List<TokenGranter>,對應五種GrantType的實際受權實現。這邊涉及到的getTokenGranter(),代碼也列下:

1 public class CompositeTokenGranter implements TokenGranter {
 2     //GrantType的集合,有五種,以前有講
 3     private final List<TokenGranter> tokenGranters;
 4     public CompositeTokenGranter(List<TokenGranter> tokenGranters) {
 5         this.tokenGranters = new ArrayList<TokenGranter>(tokenGranters);
 6     }
 7     
 8     //遍歷list,匹配到相應的grantType就進行處理
 9     public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {
10         for (TokenGranter granter : tokenGranters) {
11             OAuth2AccessToken grant = granter.grant(grantType, tokenRequest);
12             if (grant!=null) {
13                 return grant;
14             }
15         }
16         return null;
17     }
18     ...
19 }

啓動後,訪問下面的接口:

package cn.chinotan.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @program: test
 * @description: oauth2測試類
 * @author: xingcheng
 * @create: 2018-12-01 17:43
 **/
@RestController
public class WordController {

    @RequestMapping("/")
    public String index(){

        return "index" ;
    }

    @RequestMapping("/api")
    public String api(){
        return "api" ;
    }

    @RequestMapping("/login")
    public String login() {
        return "login";
    }


}

能夠看到訪問/api接口的時候被攔截了,可是其餘接口能夠訪問

那麼如何才能訪問/api接口呢,首先得獲取到access_token才行

經過暴露出的/oauth/token?grant_type=client_credentials接口就能夠獲取到access_token,其中expires_in爲有效時間,看下咱們的token是存儲在哪裏:

沒錯,被存在了redis中,相比存在本地內存和數據庫中,redis這樣的數據結構有着自然的時間特性,能夠方便的來作失效處理

以後即可以經過access_token方便的訪問/api接口了

NoSuchMethodError.RedisConnection.set([B[B)V #16錯誤

版本問題,spring-data-redis 2.0版本中set(String,String)被棄用了。而後我按照網頁中的決解方法「spring-date-redis」改成2.3.3.RELEASE版本,下面是源碼中的存儲token過程:

 

相關文章
相關標籤/搜索