一、OAuth2.0簡介
關于OAuth2.0的介紹,網上有很多說明的文章了,這里就不做展開詳細講解,只是把必要的示意圖貼上,再簡單說明,方便后面復習。
如下是官方給出的認證過程示意圖:
- Client,指發起認證流程的一方,比如某個APP、Web站點;
- Resource Owner,指在Resource Server上擁有資源的一方,需要訪問Client,并允許Client從Resource Server獲取到自己的信息;
- Authorization Server,為了保護Resource Owner在Resource Server上的資源,對Client進行認證和授權的服務;
- Resource Server,存放Resource Owner的資源,為Client提供獲取Resource Owner的資源的服務;
我們再來舉一個詳細點的例子:
- Client,就是“黑馬程序員”這個網站;
- Resource Owner,就是“用戶”,想要利用自己在微信上的注冊信息在“黑馬程序員”這個網站實現注冊登錄;
- Authorization Server,就是“微信認證”,得到用戶授權的情況下,把合法憑證令牌給到“黑馬程序員”這個網站;
- Resource Server,就是“微信用戶信息”這個服務,用戶在其上擁有一些注冊信息,根據合法的憑證令牌將信息給到“黑馬程序員”這個網站;
二、準備工作
本案例中總共涉及四個角色,其中用戶是自然人,不需要準備;其它三個角色都是程序代碼,需要做一些準備工作。
我們創建一個父工程:security-oauth,主要的依賴有:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2020.0.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
然后,我們依次創建三個子模塊:
- auth-authorize,表示我們的授權服務,8081端口;
依賴信息:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
- auth-resource,表示我們的資源服務,8082端口;
依賴信息:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
- auth-client,表示我們的客戶端,8080端口;
依賴信息:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
本篇文章主要講解授權服務的實現,關于資源服務和客戶端的示例在后面的篇文章中演示。
三、授權碼模式
通過第一節的示意圖我們知道,授權服務的主要作用就是對用戶進行認證(用戶密碼登錄),然后將用戶的合法性(授權碼、訪問令牌)傳遞給客戶端。
所以我們需要一個提供給用戶的登錄功能,還需要保留用戶的賬號密碼,對用戶進行認證,這個可以使用WebSecurityConfigurerAdapter
進行,這在原先講解Spring Security的時候就說到了,如果不熟悉可以翻看原來的文章,此處不贅述。
@EnableWebSecurity
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyUserDetailsService userDetailsService;
/**
* 對請求進行鑒權的配置
* @param http
* @throws Exception
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
// 沒有權限進入內置的登錄頁面
.formLogin()
.and()
// 暫時關閉CSRF校驗,允許get請求登出
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用userDetailsService進行認證
auth.userDetailsService(userDetailsService);
}
/**
* 密碼加密器,供在UserDetailsService中驗證密碼時使用
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
相應的,我們需要一個UserDetailsService
來提供用戶信息。
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private PasswordEncoder passwordEncoder;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 為了演示方便,使用內存定義用戶的真實賬密及其訪問權限
return User
.withUsername("zhangxun")
.password(passwordEncoder.encode("mm123"))
// 設置當前用戶可以擁有的權限信息,授權碼模式下,用戶輸入賬密后就擁有該權限
.authorities("user:query")
.build();
}
}
到此,我們的用戶就可以使用賬密登錄授權服務了,但是此時還沒有實現任何一點授權服務的功能,所以見下面。
我們先定義token令牌的管理策略,可以選擇:
- 內存管理,默認管理策略,即令牌被創建后是保存在單機內存中的,因此適合授權服務是單機且并發量不大的場景下;
- JDBC管理,令牌被托管到數據庫進行管理,適用于授權服務是集群的場景,不同機器之間可以通過數據庫來共享token;
- JWT管理,授權服務不需要存儲任何token,只需要對訪問令牌進行計算即可驗證token的合法性,也比較適合授權服務是集群的場景,而且是現在比較主流的使用方案;
本案例先使用內存管理token,其它方式在后面會介紹到。
@Configuration
public class TokenConfig {
@Bean
public TokenStore tokenStore(){
// 使用內存管理token策略
return new InMemoryTokenStore();
}
}
然后,就是我們的授權服務核心配置類了:
@Configuration
// 標記授權服務
@EnableAuthorizationServer
public class MyAuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
// 授權碼服務
@Autowired
private AuthorizationCodeServices authorizationCodeServices;
// 訪問令牌服務
@Autowired
private AuthorizationServerTokenServices tokenServices;
// 訪問令牌管理服務
@Autowired
private TokenStore tokenStore;
// 客戶端服務,由于我們使用了內存模式,會自動創建一個默認的客戶端服務
@Autowired
private ClientDetailsService clientDetailsService;
/**
* 配置客戶端的詳情,提供客戶端的信息
*
* 客戶端通過訪問如下地址來獲取授權碼
* /oauth/authorize?client_id=iSchool&response_type=code&scope=all&redirect_uri=http://localhost:8080
* 客戶端通過訪問如下地址來獲取訪問token,訪問token僅能使用一次
* /oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=authorization_code&code=授權碼&redirect_uri=http://localhost:8080
*
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
// 基于內存方式存儲客戶信息
.inMemory()
// client_id,分配給客戶端的標識
.withClient("iSchool")
// secret密鑰,加密存儲
.secret(new BCryptPasswordEncoder().encode("mysecret"))
// 當前僅開啟授權碼模式,refresh_token表示開啟刷新令牌
.authorizedGrantTypes("authorization_code","refresh_token")
// 允許授權的范圍,默認為空表示允許訪問全部范圍,這個在資源服務器那里用的到
.scopes("all")
// 資源服務器的ID配置,可以是多個,這個在資源服務器那里用的到
.resourceIds("user")
// 設置該client_id的主體所擁有的權限信息,在客戶端模式下生效,在資源服務器那里用的到
.authorities("user:query")
// 需要用戶手動授權,即會彈出界面需要用戶手動點擊授權
.autoApprove(false)
// 重定向地址,這里是第三方客戶端的地址,用來接收授權服務器返回的授權碼
.redirectUris("http://localhost:8080");
// 可以通過and()再添加其它的客戶端信息,這里省略
}
/**
* 配置令牌的訪問端點和令牌管理服務
* 默認的訪問端點如下:
* /oauth/authorize:授權端點,獲取授權碼
* /oauth/token:令牌端點,獲取訪問令牌
* /oauth/confirm_access:用戶確認授權提交端點
* /oauth/error:授權服務錯誤信息端點
* /oauth/check_token:提供給資源服務訪問的令牌驗證端點
* /oauth/token_key:提供公有密匙的端點,JWT模式使用
*
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 指定授權碼管理策略
.authorizationCodeServices(authorizationCodeServices)
// 指定token管理策略,token會自己生成一個隨機值
.tokenServices(tokenServices)
// 指定訪問token的請求方法,實際應該使用POST方式,這里為了演示方便使用GET
.allowedTokenEndpointRequestMethods(HttpMethod.GET);
}
/**
* 配置令牌訪問端點的安全約束
*
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
// 放開/oauth/check_token這個端點,供資源服務器調用來校驗訪問token的合法性
.checkTokenAccess("permitAll()")
// 開啟表單認證
.allowFormAuthenticationForClients();
}
/**
* 配置授權碼模式下授權碼的存取方式,此時采用內存模式
* @return
*/
@Bean
public AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}
/**
* 配置令牌管理服務
* @return
*/
@Bean
public AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
// 配置客戶端詳情服務,獲取客戶端的信息
services.setClientDetailsService(clientDetailsService);
// 支持刷新令牌
services.setSupportRefreshToken(true);
// 配置令牌的存儲方式,此時采用內存方式存儲
services.setTokenStore(tokenStore);
// 訪問令牌有效時間2小時
services.setAccessTokenValiditySeconds(7200);
// 刷新令牌的有效時間3天
services.setRefreshTokenValiditySeconds(259200);
return services;
}
}
具體的說明在如上代碼中都已經注釋說明了,到此,我們的授權碼模式就算完成了。啟動項目后,我們使用瀏覽器模擬第三方客戶端發起授權請求:
http://localhost:8081/oauth/authorize?client_id=iSchool&response_type=code&scope=all&redirect_uri=http://localhost:8080
這個請求中包含的內容主要有:
- /oauth/authorize,這是訪問端點,授權服務器對外暴露的,用于給第三方客戶端生成授權碼的接口;
- client_id,就是授權服務器分配給第三方客戶端的標識,這里隨便寫一個iSchool,只要授權服務器上有這個客戶信息即可;
- response_type,值code表示需要獲取授權碼;
- scope,值為all表示需要申請all這個域的資源訪問權限,必須和上面配置中的一致;
- redirect_uri,即第三方客戶端的回調地址,用來獲取授權服務器返回的授權碼;
請求發起后,頁面就會進入登錄頁面,要求輸入賬密進行登錄,此處即MyUserDetailsService
中寫死的zhangxun/mm123
,登錄成功后,就會跳轉到授權頁面,
需要注意到,授權頁面有很多信息:
- 授權給誰?這里是iSchool這個client_id;
- 授權的范圍?是all這個域的資源;
登錄頁面和授權頁面都是可以定制的,這里為了簡單演示,不做過度展開。
當我們授權成功后,授權服務器就重定向到第三方客戶端的地址,并帶過來一個授權碼:
http://localhost:8080/?code=y4CwNB
第三方客戶端拿到這個授權碼之后,就將其傳遞給自己的后端服務器,由后端服務器再去調用授權服務器換取訪問token。
這里并不是說一定要由后端服務器去獲取token,而是token是一種需要保護的令牌,我們當然可以通過前端直接去獲取token,但這會導致token被泄露在前端,而且還有第三方客戶端的密鑰,這些都是需要保密的內容。這里為了方便演示,就直接通過瀏覽器,使用前端調用授權服務器獲取token:
http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=authorization_code&code=y4CwNB&redirect_uri=http://localhost:8080
然后會得到返回信息:
{"access_token":"1a6d94be-1f38-4140-bf2e-35b226a7346f","token_type":"bearer","refresh_token":"b41bfe84-717b-4bbe-9e38-2e30073fea29","expires_in":43199,"scope":"all"}
到此,我們就拿到了訪問token。
四、簡化模式
簡化模式就是對授權碼模式進行了簡化,即第三方客戶端訪問授權服務器時不需要先獲取授權碼再獲取訪問token了,而是直接一步到位獲取訪問token。
首先,我們需要在授權服務器端的授權配置中開啟簡化模式:
// 支持的授權模式,refresh_token表示開啟刷新令牌
.authorizedGrantTypes("implicit","refresh_token")
然后啟動授權服務器即可,我們模擬第三方客戶端對授權服務器發起請求如下,注意response_type改為了token:
http://localhost:8081/oauth/authorize?client_id=iSchool&response_type=token&scope=all&redirect_uri=http://localhost:8080
經過登錄和授權之后,授權服務器就會重定向到第三方客戶端的地址,并帶回來訪問token:
http://localhost:8080/#access_token=faa7813f-c9b2-4100-a11b-7d81d18af1f7&token_type=bearer&expires_in=43199
這樣,第三方客戶端就拿到了訪問token,確實簡化了不少,甚至都不用密鑰,但是缺點也很明顯,訪問token在前端有泄露的風險,主要用于那些沒有后端服務的第三方單頁面應用,不是很推薦。
五、密碼模式
密碼模式是在授權碼模式的基礎上,將用戶的賬號密碼給到第三方客戶端,由第三方客戶端帶著用戶的賬密,以及它自己的標識和密鑰來訪問授權服務器,直接獲取訪問token,由此可以不用用戶在授權服務器上進行登錄和授權操作。
首先,我們需要開啟密碼模式:
.authorizedGrantTypes("password","refresh_token")
其次,為了支持第三方客戶端可以將用戶的賬密帶過來給到授權服務器,我們還需要在如上的MySecurityConfig
類中增加認證管理器:
/**
* 認證管理器,供密碼模式下認證用戶時使用
* @return
* @throws Exception
*/
@Bean
@Override
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
然后在我們的授權服務配置類MyAuthorizationServerConfig
中使用這個認證管理器:
@Autowired
private AuthenticationManager authenticationManager;
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
// 指定認證管理器,在WebSecurityConfigurerAdapter的實現類中注入,密碼模式需要用到
.authenticationManager(authenticationManager)
}
好了,現在啟動授權服務后,模擬第三方客戶端的后端服務對授權服務器發起請求如下:
http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=password&username=zhangxun&password=mm123
得到的返回內容為:
{"access_token":"325d0c02-89d5-4361-9930-bc91fa9255b0","token_type":"bearer","refresh_token":"b75bfb72-102f-4038-939d-b14b343eda0c","expires_in":43199,"scope":"all"}
這樣,第三方客戶端就拿到了訪問token,但是,需要用戶將自己在授權服務器上的賬密泄露給第三方客戶端,這對于很多授權服務方來說是不可忍受的,除非第三方客戶端就是自己方的應用。
六、客戶端模式
客戶端模式也比較簡單,只需要第三方客戶端給出自己的標識和密鑰,授權服務就返回給它訪問token,甚至都不用用戶的授權行為。
首先,我們需要開啟客戶端模式:
.authorizedGrantTypes("client_credentials","refresh_token")
然后可以將上述密碼模式添加的認證管理器予以刪除,重啟授權服務器即可。
模擬第三方客戶端的后端服務對授權服務器發起請求如下:
http://localhost:8081/oauth/token?client_id=iSchool&client_secret=mysecret&grant_type=client_credentials
得到的返回內容如下:
{"access_token":"885bd2f8-9ada-41b9-ac61-2c9a74a8b805","token_type":"bearer","expires_in":43199,"scope":"all"}
這樣,第三方客戶端就拿到了訪問token,但是,這中間根本沒有讓用戶進行授權,不能確保第三方客戶端是否會對客戶的信息用作非法用途,因此,只有第三方客戶端是完全授信的情況下才能使用。
七、總結
綜上四種模式中,授權碼模式是最復雜,但是最安全的,也是現在業內最流行使用的方式;簡化模式會導致訪問token泄露到前端,安全性得不到保證;密碼模式和客戶端模式要求第三方客戶端是受控制的,能得到完全信任的情況。
八、思考
7.1 授權碼的必要性是什么?直接返回訪問token不行嗎?
不行。
- 授權碼是為了將瀏覽器地址重定向到第三方客戶端的網址,同時告知一個授權碼;
- 授權碼即使泄露,沒有第三方客戶端的密鑰也是無法獲取訪問token的;
- 訪問token是需要保護的令牌,不能在前端出現;
7.2 如何確保第三方客戶端只能拿到授權用戶的信息?
待研究