10 張圖,終於把 OAuth2 搞清楚了
對於身份認證和用戶授權,之前寫過幾篇關於 Shiro 和 Security 的文章。從發送口令獲取源碼的反饋來看,大家還是比較認可的。今天給大家帶來一種新的授權方式:oauth2
。
理論
OAuth
是一個關於授權(authorization
)的開放網絡標準,用來授權第三方應用獲取用戶數據,是目前最流行的授權機制,它當前的版本是 2.0。
應用場景
假如你正在 “網站 A” 上衝浪,看到一篇帖子表示非常喜歡,當你情不自禁的想要點贊時,它會提示你進行登錄操作。
打開登錄頁面你會發現,除了最簡單的賬戶密碼登錄外,還爲我們提供了微博、微信、QQ 等快捷登錄方式。假設選擇了快捷登錄,它會提示我們掃碼或者輸入賬號密碼進行登錄。
登錄成功之後便會將 QQ / 微信的暱稱和頭像等信息回填到 “網站 A” 中,此時你就可以進行點贊操作了。
名詞定義
在詳細講解oauth2
之前,我們先來了解一下它裏邊用到的名詞定義吧:
-
Client:客戶端,它本身不會存儲用戶快捷登錄的賬號和密碼,只是通過資源擁有者的授權去請求資源服務器的資源,即例子中的網站 A;
-
Resource Owner:資源擁有者,通常是用戶,即例子中擁有 QQ / 微信賬號的用戶;
-
Authorization Server:認證服務器,可以提供身份認證和用戶授權的服務器,即給客戶端頒發
token
和校驗token
; -
Resource Server:資源服務器,存儲用戶資源的服務器,即例子中的 QQ / 微信存儲的用戶信息;
認證流程
如圖是oauth2
官網的認證流程圖,我們來分析一下:
-
A 客戶端向資源擁有者發送授權申請;
-
B 資源擁有者同意客戶端的授權,返回授權碼;
-
C 客戶端使用授權碼向認證服務器申請令牌
token
; -
D 認證服務器對客戶端進行身份校驗,認證通過後發放令牌;
-
E 客戶端拿着認證服務器頒發的令牌去資源服務器請求資源;
-
F 資源服務器校驗令牌的有效性,返回給客戶端資源信息;
爲了大家更好的理解,阿 Q 特地畫了一張圖:
到這兒,相信大家對理論知識已經掌握的差不多了,接下來我們就進入實戰訓練吧。
實戰
在正式開始搭建項目之前我們先來做一些準備工作:要想使用oauth2
的服務,我們得先創建幾張表。
數據庫
oauth2
相關的建表語句可以參考官方初始化 sql,也可以查看阿 Q 項目中的 init.sql 文件,回覆 “oauth2” 獲取源碼。
至於表結構,大家可以先大體瞭解下,其中字段的含義,在 init.sql 文件中阿 Q 已經做了說明。
-
oauth_client_details:存儲客戶端的配置信息,操作該表的類主要是
JdbcClientDetailsService.java
; -
oauth_access_token:存儲生成的令牌信息,操作該表的類主要是
JdbcTokenStore.java
; -
oauth_client_token:在客戶端系統中存儲從服務端獲取的令牌數據,操作該表的類主要是
JdbcClientDetailsService.java
; -
oauth_code:存儲授權碼信息與認證信息,即只有
grant_type
爲authorization_code
時,該表纔會有數據,操作該表的類主要是JdbcAuthorizationCodeServices.java
; -
oauth_approvals:存儲用戶的授權信息;
-
oauth_refresh_token:存儲刷新令牌的
refresh_token
,如果客戶端的grant_type
不支持refresh_token
,那麼不會用到這張表,操作該表的類主要是JdbcTokenStore
;
在oauth_client_details
表中添加一條數據
client_id:cheetah_one //客戶端名稱,必須唯一
resource_ids:product_api //客戶端所能訪問的資源id集合,多個資源時用逗號(,)分隔
client_secret:$2a$10$h/TmLPvXozJJHXDyJEN22ensJgaciomfpOc9js9OonwWIdAnRQeoi //客戶端的訪問密碼
scope:read,write //客戶端申請的權限範圍,可選值包括read,write,trust。若有多個權限範圍用逗號(,)分隔
authorized_grant_types:client_credentials,implicit,authorization_code,refresh_token,password //指定客戶端支持的grant_type,可選值包括authorization_code,password,refresh_token,implicit,client_credentials, 若支持多個grant_type用逗號(,)分隔
web_server_redirect_uri:http://www.baidu.com //客戶端的重定向URI,可爲空, 當grant_type爲authorization_code或implicit時, 在Oauth的流程中會使用並檢查與註冊時填寫的redirect_uri是否一致
access_token_validity:43200 //設定客戶端的access_token的有效時間值(單位:秒),可選, 若不設定值則使用默認的有效時間值(60 * 60 * 12, 12小時)
autoapprove:false //設置用戶是否自動Approval操作, 默認值爲 'false', 可選值包括 'true','false', 'read','write'
數據庫中對密碼進行了加密處理,大家可以在此路徑下自行生成
用戶角色相關的表也在 init.sql 文件中,表結構非常簡單,大家自行查閱。我的初始化數據爲
依賴引入
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-jwt</artifactId>
</dependency>
至於其它依賴,大家可以根據需要自行引入,不再贅述,回覆 “oauth2” 獲取源碼。
資源服務
配置文件對服務端口、應用名稱、數據庫、mybatis
和日誌進行了配置。
寫了一個簡單的控制層代碼,用來模擬資源訪問
@RestController
@RequestMapping("/product")
public class ProductController {
@GetMapping("/findAll")
public String findAll(){
return "產品列表查詢成功";
}
}
接着創建配置類繼承ResourceServerConfigurerAdapter
並增加@EnableResourceServer
註解開啓資源服務,重寫兩個configure
方法
/**
* 指定token的持久化策略
* InMemoryTokenStore 表示將token存儲在內存中
* RedisTokenStore 表示將token存儲在redis中
* JdbcTokenStore 表示將token存儲在數據庫中
* @return
*/
@Bean
public TokenStore jdbcTokenStore(){
return new JdbcTokenStore(dataSource);
}
/**
* 指定當前資源的id和token的存儲策略
* @param resources
* @throws Exception
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
//此處的id可以寫在配置文件中,這裏我們先寫死
resources.resourceId("product_api").tokenStore(jdbcTokenStore());
}
/**
* 設置請求權限和header處理
* @param http
* @throws Exception
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//固定寫法
http.authorizeRequests()
//指定不同請求方式訪問資源所需的權限,一般查詢是read,其餘都是write
.antMatchers(HttpMethod.GET,"/**").access("#oauth2.hasScope('read')")
.antMatchers(HttpMethod.POST,"/**").access("#oauth2.hasScope('write')")
.antMatchers(HttpMethod.PATCH,"/**").access("#oauth2.hasScope('write')")
.antMatchers(HttpMethod.PUT,"/**").access("#oauth2.hasScope('write')")
.antMatchers(HttpMethod.DELETE,"/**").access("#oauth2.hasScope('write')")
.and()
.headers().addHeaderWriter((request,response) -> {
//域名不同或者子域名不一樣並且是ajax請求就會出現跨域問題
//允許跨域
response.addHeader("Access-Control-Allow-Origin","*");
//跨域中會出現預檢請求,如果不能通過,則真正請求也不會發出
//如果是跨域的預檢請求,則原封不動向下傳遞請求頭信息,否則預檢請求會丟失請求頭信息(主要是token信息)
if(request.getMethod().equals("OPTIONS")){
response.setHeader("Access-Control-Allow-Methods",request.getHeader("Access-Control-Allow-Methods"));
response.setHeader("Access-Control-Allow-Headers",request.getHeader("Access-Control-Allow-Headers"));
}
});
}
當然我們也可以配置忽略校驗的url
,在上邊的public void configure(HttpSecurity http) throws Exception
中進行配置
ExpressionUrlAuthorizationConfigurer<HttpSecurity>
.ExpressionInterceptUrlRegistry config = http.requestMatchers().anyRequest()
.and()
.authorizeRequests();
properties.getUrls().forEach(e -> {
config.antMatchers(e).permitAll();
});
因爲我們是需要進行校驗的,所以我把對應的代碼給註釋掉了,大家可以回覆 “oauth2” 下載源碼自行查看。
然後將實現了UserDetails
的SysUser
和實現了GrantedAuthority
的SysRole
放到項目中,當請求發過來時,oauth2
會幫我們自行校驗。
認證服務
配置文件對服務端口、應用名稱、數據庫、mybatis
和日誌進行了配置。
Security 配置
還是和之前 Security+JWT 組合拳的配置大同小異,不瞭解的可以先看下該文。
①將繼承了UserDetailsService
的ISysUserService
的實現類SysUserServiceImpl
重寫loadUserByUsername
方法
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return this.baseMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username));
}
②繼承WebSecurityConfigurerAdapter
類,增加@EnableWebSecurity
註解並重寫方法
/**
* 指定認證對象的來源和加密方式
* @param auth
* @throws Exception
*/
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService).passwordEncoder(passwordEncoder());
}
/**
* 安全攔截機制(最重要)
* @param httpSecurity
* @throws Exception
*/
@Override
public void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
//CSRF禁用,因爲不使用session
.csrf().disable()
.authorizeRequests()
//登錄接口和靜態資源不需要認證
.antMatchers("/login*","/css/*").permitAll()
//除上面的所有請求全部需要認證通過才能訪問
.anyRequest().authenticated()
//返回HttpSecurity以進行進一步的自定義,證明是一次新的配置的開始
.and()
.formLogin()
//如果未指定此頁面,則會跳轉到默認頁面
// .loginPage("/login.html")
.loginProcessingUrl("/login")
.permitAll()
//認證失敗處理類
.failureHandler(customAuthenticationFailureHandler);
}
/**
* AuthenticationManager 對象在OAuth2.0認證服務中要使用,提前放入IOC容器中
* @return
* @throws Exception
*/
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
AuthorizationServer 配置
①繼承AuthorizationServerConfigurerAdapter
類,增加@EnableAuthorizationServer
註解開啓認證服務
②依賴注入,注入 7 個實例Bean
對象
/**
* 數據庫連接池對象
*/
private final DataSource dataSource;
/**
* 認證業務對象
*/
private final ISysUserService userService;
/**
* 授權碼模式專用對象
*/
private final AuthenticationManager authenticationManager;
/**
* 客戶端信息來源
* @return
*/
@Bean
public JdbcClientDetailsService jdbcClientDetailsService(){
return new JdbcClientDetailsService(dataSource);
}
/**
* token保存策略
* @return
*/
@Bean
public TokenStore tokenStore(){
return new JdbcTokenStore(dataSource);
}
/**
* 授權信息保存策略
* @return
*/
@Bean
public ApprovalStore approvalStore(){
return new JdbcApprovalStore(dataSource);
}
/**
* 授權碼模式數據來源
* @return
*/
@Bean
public AuthorizationCodeServices authorizationCodeServices(){
return new JdbcAuthorizationCodeServices(dataSource);
}
③重寫方法進行配置
/**
* 用來配置客戶端詳情服務(ClientDetailsService)
* 客戶端詳情信息在這裏進行初始化
* 指定客戶端信息的數據庫來源
* @param clients
* @throws Exception
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.withClientDetails(jdbcClientDetailsService());
}
/**
* 檢測 token 的策略
* @param security
* @throws Exception
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security
//允許客戶端以form表單的方式將token傳達給我們
.allowFormAuthenticationForClients()
//檢驗token必須需要認證
.checkTokenAccess("isAuthenticated()");
}
/**
* OAuth2.0的主配置信息
* @param endpoints
* @throws Exception
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
//刷新token時會驗證當前用戶是否已經通過認證
.userDetailsService(userService)
.approvalStore(approvalStore())
.authenticationManager(authenticationManager)
.authorizationCodeServices(authorizationCodeServices())
.tokenStore(tokenStore());
}
其它關於用戶表和權限表的代碼可參考源碼,回覆 “oauth2” 獲取源碼。
模式
授權碼模式
我們前邊所講的內容都是基於授權碼模式,授權碼模式被稱爲最安全的一種模式,它獲取令牌的操作是在兩個服務端進行的,極大的減小了令牌泄漏的風險。
啓動兩個服務,當我們再次請求127.0.0.1:9002/product/findAll
接口時會提示以下錯誤
{
"error": "unauthorized",
"error_description": "Full authentication is required to access this resource"
}
①調用接口獲取授權碼
發送127.0.0.1:9001/oauth/authorize?response_type=code&client_id=cheetah_one
請求,前邊的路徑是固定形式的,response_type=code
表示獲取授權碼,client_id=cheetah_one
表示客戶端的名稱是我們數據庫配置的數據。
該頁面是oauth2
的默認頁面,輸入用戶的賬戶密碼點擊登錄會提示我們進行授權,這是數據庫oauth_client_details
表我們設置autoapprove
爲false
起到的效果。
選擇Approve
點擊Authorize
按鈕,會發現我們設置的回調地址(oauth_client_details
表中的web_server_redirect_uri
)後邊拼接了code
值,該值就是授權碼。
查看數據庫發現oauth_approvals
和oauth_code
表已經存入數據了。
拿着授權碼去獲取token
獲取到token
之後oauth_access_token
和oauth_refresh_token
表中會存入數據以用於後邊的認證。而oauth_code
表中的數據被清除了,這是因爲code
值是直接暴漏在網頁鏈接上的,oauth2
爲了防止他人拿到code
非法請求而特意設置爲僅用一次。
拿着獲取到的token
去請求資源服務的接口,此時有兩種請求方式
接下來我們再來看一下oauth2
的其它模式。
簡化模式
所謂簡化模式是針對授權碼模式進行的簡化,它將授權碼模式中獲取授權碼的步驟省略了,直接去請求獲取token
。
流程:發送請求127.0.0.1:9001/oauth/authorize?response_type=token&client_id=cheetah_one
跳轉到登錄頁進行登錄,response_type=token
表示獲取token
。
輸入賬號密碼登錄之後會直接在瀏覽器返回token
,我們就可以像授權碼方式一樣攜帶token
去請求資源了。
該模式的弊端就是token
直接暴漏在瀏覽器中,非常不安全,不建議使用。
密碼模式
密碼模式下,用戶需要將賬戶和密碼提供給客戶端向認證服務器申請令牌,所以該種模式需要用戶高度信任客戶端。
流程:請求如下
獲取成功之後可以去訪問資源了。
客戶端模式
客戶端模式已經不太屬於oauth2
的範疇了,用戶直接在客戶端進行註冊,然後客戶端去認證服務器獲取令牌時不需要攜帶用戶信息,完全脫離了用戶,也就不存在授權問題了。
發送請求如下
獲取成功之後可以去訪問資源了。
刷新 token
權限校驗
除了我們在數據庫中爲客戶端配置資源服務外,我們還可以動態的給用戶分配接口的權限。
①開啓Security
內置的動態配置
在開啓資源服務時給ResourceServerConfig
類增加註解@EnableGlobalMethodSecurity(securedEnabled = true,prePostEnabled = true)
②給接口增加權限
@GetMapping("/findAll")
@Secured("ROLE_PRODUCT")
public String findAll(){
return "產品列表查詢成功";
}
③在用戶登錄時設置用戶權限
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
SysUser sysUser = this.baseMapper.selectOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getUsername, username));
sysUser.setRoleList(AuthorityUtils.commaSeparatedStringToAuthorityList("ROLE_PRODUCT"));
return sysUser;
}
然後測試會發現可以正常訪問。
採坑
包名問題
當我在創建項目的時候,給product
和server
兩個模塊設置了不同的包名,導致發送請求獲取資源時報錯。
經過分析得知,在登錄賬號時會將用戶的信息存儲到oauth_access_token
表的authentication
中,在進行token
校驗時會根據token_id
取出該字段進行反序列化,如果此時發現包名不一致便會導致解析token
失敗,因此請求資源失敗。
解決思路
-
兩個項目的包名改爲一致;
-
可以將用戶和權限的實體抽成單獨的模塊,供其它模塊引用;
-
loadUserByUsername
方法中使用的用戶實體類不需要繼承UserDetailsService
類,每次返回時用user
類包裝一下即可;
數據庫問題
當我在進行權限校驗測試時,在設置權限時發現少打了一個單詞,導致請求一直出錯。修改完成之後繼續請求,仍提示權限不足。
於是我將數據庫中oauth_refresh_token
和oauth_access_token
的數據清除,重新開始測試就可以了。
個人認爲是生成token
時發現數據庫中token
存在,故不刷新token
,但進行校驗時卻用帶有權限標識的token
前去校驗導致失敗。
至於其它的小坑在這不再贅述,如果遇到問題,建議按照流程對比我的源碼仔細檢查,回覆 “oauth2” 獲取源碼。
小結
本文從原理、應用場景、認證流程出發,對oauth2
進行了基本的講解,並且手把手帶大家完成了項目的搭建。大家在對授權碼模式、簡化模式、密碼模式、客戶端模式進行測試的同時要將重點放到授權碼模式上。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/sTaTaLf6HybatAfvOLaDmA