Spring Boot2.0 Oauth2 伺服器和客戶端配置及原理
一、應用場景
為了理解OAuth的適用場合,讓我舉一個假設的例子。
有一個"雲沖印"的網站,可以將使用者儲存在Google的照片,沖印出來。使用者為了使用該服務,必須讓"雲沖印"讀取自己儲存在Google上的照片。
問題是隻有得到使用者的授權,Google才會同意"雲沖印"讀取這些照片。那麼,"雲沖印"怎樣獲得使用者的授權呢?
傳統方法是,使用者將自己的Google使用者名稱和密碼,告訴"雲沖印",後者就可以讀取使用者的照片了。這樣的做法有以下幾個嚴重的缺點。
- "雲沖印"為了後續的服務,會儲存使用者的密碼,這樣很不安全。
- Google不得不部署密碼登入,而我們知道,單純的密碼登入並不安全。
- "雲沖印"擁有了獲取使用者儲存在Google所有資料的權力,使用者沒法限制"雲沖印"獲得授權的範圍和有效期。
- 使用者只有修改密碼,才能收回賦予"雲沖印"的權力。但是這樣做,會使得其他所有獲得使用者授權的第三方應用程式全部失效。
- 只要有一個第三方應用程式被破解,就會導致使用者密碼洩漏,以及所有被密碼保護的資料洩漏。
OAuth就是為了解決上面這些問題而誕生的。
二、名詞定義
在詳細講解OAuth 2.0之前,需要了解幾個專用名詞。它們對讀懂後面的講解,尤其是幾張圖,至關重要。
- Third-party application:第三方應用程式,本文中又稱"客戶端"(client),即上一節例子中的"雲沖印"。
- HTTP service:HTTP服務提供商,本文中簡稱"服務提供商",即上一節例子中的Google。
- Resource Owner:資源所有者,本文中又稱"使用者"(user)。
- User Agent:使用者代理,本文中就是指瀏覽器。
- Authorization server:認證伺服器,即服務提供商專門用來處理認證的伺服器。
- Resource server:資源伺服器,即服務提供商存放使用者生成的資源的伺服器。它與認證伺服器,可以是同一臺伺服器,也可以是不同的伺服器。
知道了上面這些名詞,就不難理解,OAuth的作用就是讓"客戶端"安全可控地獲取"使用者"的授權,與"服務商提供商"進行互動。
三、OAuth的思路
OAuth在"客戶端"與"服務提供商"之間,設定了一個授權層(authorization layer)。"客戶端"不能直接登入"服務提供商",只能登入授權層,以此將使用者與客戶端區分開來。"客戶端"登入授權層所用的令牌(token),與使用者的密碼不同。使用者可以在登入的時候,指定授權層令牌的許可權範圍和有效期。
"客戶端"登入授權層以後,"服務提供商"根據令牌的許可權範圍和有效期,向"客戶端"開放使用者儲存的資料。
四、客戶端的授權模式
客戶端必須得到使用者的授權(authorization grant),才能獲得令牌(access token)。OAuth 2.0定義了四種授權方式。
- 授權碼模式(authorization code)
- 簡化模式(implicit)
- 密碼模式(resource owner password credentials)
- 客戶端模式(client credentials)
五、授權碼模式
授權碼模式(authorization code)是功能最完整、流程最嚴密的授權模式。它的特點就是通過客戶端的後臺伺服器,與"服務提供商"的認證伺服器進行互動。
它的步驟如下:
(A)使用者訪問客戶端,後者將前者導向認證伺服器。
(B)使用者選擇是否給予客戶端授權。
(C)假設使用者給予授權,認證伺服器將使用者導向客戶端事先指定的"重定向URI"(redirection URI),同時附上一個授權碼。
(D)客戶端收到授權碼,附上早先的"重定向URI",向認證伺服器申請令牌。這一步是在客戶端的後臺的伺服器上完成的,對使用者不可見。
(E)認證伺服器核對了授權碼和重定向URI,確認無誤後,向客戶端傳送訪問令牌(access token)和更新令牌(refresh token)。
六、簡化模式
簡化模式(implicit grant type)不通過第三方應用程式的伺服器,直接在瀏覽器中向認證伺服器申請令牌,跳過了"授權碼"這個步驟,因此得名。所有步驟在瀏覽器中完成,令牌對訪問者是可見的,且客戶端不需要認證。
它的步驟如下:
(A)客戶端將使用者導向認證伺服器。
(B)使用者決定是否給於客戶端授權。
(C)假設使用者給予授權,認證伺服器將使用者導向客戶端指定的"重定向URI",並在URI的Hash部分包含了訪問令牌。
(D)瀏覽器向資源伺服器發出請求,其中不包括上一步收到的Hash值。
(E)資源伺服器返回一個網頁,其中包含的程式碼可以獲取Hash值中的令牌。
(F)瀏覽器執行上一步獲得的指令碼,提取出令牌。
(G)瀏覽器將令牌發給客戶端。
七、密碼模式
密碼模式(Resource Owner Password Credentials Grant)中,使用者向客戶端提供自己的使用者名稱和密碼。客戶端使用這些資訊,向"服務商提供商"索要授權。
在這種模式中,使用者必須把自己的密碼給客戶端,但是客戶端不得儲存密碼。這通常用在使用者對客戶端高度信任的情況下,比如客戶端是作業系統的一部分,或者由一個著名公司出品。而認證伺服器只有在其他授權模式無法執行的情況下,才能考慮使用這種模式。
它的步驟如下:
(A)使用者向客戶端提供使用者名稱和密碼。
(B)客戶端將使用者名稱和密碼發給認證伺服器,向後者請求令牌。
(C)認證伺服器確認無誤後,向客戶端提供訪問令牌。
八、客戶端模式
客戶端模式(Client Credentials Grant)指客戶端以自己的名義,而不是以使用者的名義,向"服務提供商"進行認證。嚴格地說,客戶端模式並不屬於OAuth框架所要解決的問題。在這種模式中,使用者直接向客戶端註冊,客戶端以自己的名義要求"服務提供商"提供服務,其實不存在授權問題。
它的步驟如下:
(A)客戶端向認證伺服器進行身份認證,並要求一個訪問令牌。
(B)認證伺服器確認無誤後,向客戶端提供訪問令牌。
九、更新令牌
如果使用者訪問的時候,客戶端的"訪問令牌"已經過期,則需要使用"更新令牌"申請一個新的訪問令牌。
客戶端發出更新令牌的HTTP請求,包含以下引數:
- granttype:表示使用的授權模式,此處的值固定為"refreshtoken",必選項。
- refresh_token:表示早前收到的更新令牌,必選項。
- scope:表示申請的授權範圍,不可以超出上一次申請的範圍,如果省略該引數,則表示與上一次一致。
十、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過程: