1、應用場景
爲了理解OAuth的適用場合,讓我舉一個假設的例子。java
有一個"雲沖印"的網站,能夠將用戶儲存在Google的照片,沖印出來。用戶爲了使用該服務,必須讓"雲沖印"讀取本身儲存在Google上的照片。web
問題是隻有獲得用戶的受權,Google纔會贊成"雲沖印"讀取這些照片。那麼,"雲沖印"怎樣得到用戶的受權呢?redis
傳統方法是,用戶將本身的Google用戶名和密碼,告訴"雲沖印",後者就能夠讀取用戶的照片了。這樣的作法有如下幾個嚴重的缺點。spring
- "雲沖印"爲了後續的服務,會保存用戶的密碼,這樣很不安全。
- Google不得不部署密碼登陸,而咱們知道,單純的密碼登陸並不安全。
- "雲沖印"擁有了獲取用戶儲存在Google全部資料的權力,用戶無法限制"雲沖印"得到受權的範圍和有效期。
- 用戶只有修改密碼,才能收回賦予"雲沖印"的權力。可是這樣作,會使得其餘全部得到用戶受權的第三方應用程序所有失效。
- 只要有一個第三方應用程序被破解,就會致使用戶密碼泄漏,以及全部被密碼保護的數據泄漏。
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過程: