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