OAuth2.0
OAuth2.0的含義與思想
OAuth 是一個開放標(biāo)準(zhǔn),該標(biāo)準(zhǔn)允許用戶讓第三方應(yīng)用訪問該用戶在某一網(wǎng)站上存儲的私密資源(如頭像、照片、視頻等),而在這個過程中無需將用戶名和密碼提供給第三方應(yīng)用。實現(xiàn)這一功能是通過提供一個令牌(token),而不是用戶名和密碼來訪問他們存放在特定服務(wù)提供者的數(shù)據(jù)。采用令牌(token)的方式可以讓用戶靈活的對第三方應(yīng)用授權(quán)或者收回權(quán)限。
OAuth2 是 OAuth 協(xié)議的下一版本,但不向下兼容 OAuth 1.0。傳統(tǒng)的 Web 開發(fā)登錄認(rèn)證一般都是基于 session 的,但是在前后端分離的架構(gòu)中繼續(xù)使用 session 就會有許多不便,因為移動端(Android、iOS、微信小程序等)要么不支持 cookie(微信小程序),要么使用非常不便,對于這些問題,使用 OAuth2 認(rèn)證都能解決。
對于大家而言,我們在互聯(lián)網(wǎng)應(yīng)用中最常見的 OAuth2 應(yīng)該就是各種第三方登錄了,例如 QQ 授權(quán)登錄、微信授權(quán)登錄、微博授權(quán)登錄、GitHub 授權(quán)登錄等等。
[快遞員的例子](OAuth 2.0 的一個簡單解釋 - 阮一峰的網(wǎng)絡(luò)日志 (ruanyifeng.com))
我住在一個大型的居民小區(qū)。
小區(qū)有門禁系統(tǒng)。
進(jìn)入的時候需要輸入密碼。
我經(jīng)常網(wǎng)購和外賣,每天都有快遞員來送貨。我必須找到一個辦法,讓快遞員通過門禁系統(tǒng),進(jìn)入小區(qū)。
如果我把自己的密碼,告訴快遞員,他就擁有了與我同樣的權(quán)限,這樣好像不太合適。萬一我想取消他進(jìn)入小區(qū)的權(quán)力,也很麻煩,我自己的密碼也得跟著改了,還得通知其他的快遞員。
有沒有一種辦法,讓快遞員能夠自由進(jìn)入小區(qū),又不必知道小區(qū)居民的密碼,而且他的唯一權(quán)限就是送貨,其他需要密碼的場合,他都沒有權(quán)限?
于是,我設(shè)計了一套授權(quán)機(jī)制:
- 第一步,門禁系統(tǒng)的密碼輸入器下面,增加一個按鈕,叫做**“獲取授權(quán)”**??爝f員需要首先按這個按鈕,去申請授權(quán)。
- 第二步,他按下按鈕以后,屋主(也就是我)的手機(jī)就會跳出對話框:有人正在要求授權(quán)。系統(tǒng)還會顯示該快遞員的姓名、工號和所屬的快遞公司。
- 我確認(rèn)請求屬實,就點擊按鈕,告訴門禁系統(tǒng),我同意給予他進(jìn)入小區(qū)的授權(quán)。
- 第三步,門禁系統(tǒng)得到我的確認(rèn)以后,向快遞員顯示一個進(jìn)入小區(qū)的令牌(access token)。令牌就是類似密碼的一串?dāng)?shù)字,只在短期內(nèi)(比如七天)有效。
- 第四步,快遞員向門禁系統(tǒng)輸入令牌,進(jìn)入小區(qū)。
有人可能會問,為什么不是遠(yuǎn)程為快遞員開門,而要為他單獨生成一個令牌?這是因為快遞員可能每天都會來送貨,第二天他還可以復(fù)用這個令牌。另外,有的小區(qū)有多重門禁,快遞員可以使用同一個令牌通過它們。
互聯(lián)網(wǎng)的例子
例如我們有一個“云打印”
的網(wǎng)站,可以將用戶存儲在Google的照片,打印出來。用戶為了使用該服務(wù),需要讓“云打印”
這個網(wǎng)站訪問自己存儲在Google的照片。
? 如何獲得用戶的授權(quán)呢?
傳統(tǒng)方案是將用戶將自己的Google賬號密碼告訴“云打印”
網(wǎng)站。但是這種方案會有很多缺點:
-
云打印
存儲Google密碼,不安全 - Google必須有密碼登錄的功能
-
云打印
擁有了獲取用戶Google資源的權(quán)力,但是用戶無法限制云打印
獲得授權(quán)的范圍和有效期 - 用戶只有修改密碼,才可以收回賦予的權(quán)利,但是這樣嚴(yán)重影響其它應(yīng)用
- 只要有一個第三方應(yīng)用被破解,密碼就會泄漏
OAuth就是為了解決上面這些問題而誕生的。
用OAuth的方案:云打印
請求獲取授權(quán),用戶同意給云打印
授權(quán),云打印
使用上一步的授權(quán)碼向Google的認(rèn)證服務(wù)器申請令牌,然后云打印
使用令牌向Google的資源服務(wù)器申請資源,Google的資源服務(wù)器確認(rèn)令牌并開放資源。
簡單說,OAuth 就是一種授權(quán)機(jī)制。數(shù)據(jù)的所有者告訴系統(tǒng),同意授權(quán)第三方應(yīng)用進(jìn)入系統(tǒng),獲取這些數(shù)據(jù)。系統(tǒng)從而產(chǎn)生一個短期的進(jìn)入令牌(token),用來代替密碼,供第三方應(yīng)用使用。
令牌與密碼
令牌(token)與密碼(password)的作用是一樣的,都可以進(jìn)入系統(tǒng),但是有三點差異。
- 令牌是短期的,到期會自動失效,用戶自己無法修改。密碼一般長期有效,用戶不修改,就不會發(fā)生變化。
- 令牌可以被數(shù)據(jù)所有者撤銷,會立即失效。以上例而言,屋主可以隨時取消快遞員的令牌。密碼一般不允許被他人撤銷。
- 令牌有權(quán)限范圍(scope),比如只能進(jìn)小區(qū)的二號門。對于網(wǎng)絡(luò)服務(wù)來說,只讀令牌就比讀寫令牌更安全。密碼一般是完整權(quán)限。
上面這些設(shè)計,保證了令牌既可以讓第三方應(yīng)用獲得權(quán)限,同時又隨時可控,不會危及系統(tǒng)安全。這就是 OAuth 2.0 的優(yōu)點。
OAuth2.0的四種授權(quán)方式
用戶如何給與第三方應(yīng)用權(quán)限,從而第三方可以根據(jù)此授權(quán)獲取令牌?
RFC 6749
OAuth 2.0 的標(biāo)準(zhǔn)是 RFC 6749 文件。該文件先解釋了 OAuth 是什么。
OAuth 引入了一個授權(quán)層,用來分離兩種不同的角色:客戶端和資源所有者?!Y源所有者同意以后,資源服務(wù)器可以向客戶端頒發(fā)令牌??蛻舳送ㄟ^令牌,去請求數(shù)據(jù)。
這段話的意思就是,**OAuth 的核心就是向第三方應(yīng)用頒發(fā)令牌。**然后,RFC 6749 接著寫道:
(由于互聯(lián)網(wǎng)有多種場景,)本標(biāo)準(zhǔn)定義了獲得令牌的四種授權(quán)方式(authorization grant )。
也就是說,**OAuth 2.0 規(guī)定了四種獲得令牌的流程。你可以選擇最適合自己的那一種,向第三方應(yīng)用頒發(fā)令牌。**下面就是這四種授權(quán)方式。
- 授權(quán)碼(authorization-code)
- 隱藏式(implicit)
- 密碼式(password):
- 客戶端憑證(client credentials)
注意,不管哪一種授權(quán)方式,第三方應(yīng)用申請令牌之前,都必須先到系統(tǒng)備案,說明自己的身份,然后會拿到兩個身份識別碼:客戶端 ID(client ID)和客戶端密鑰(client secret)。這是為了防止令牌被濫用,沒有備案過的第三方應(yīng)用,是不會拿到令牌的。
一、授權(quán)碼(前后端分離)
授權(quán)碼(authorization code)方式,指的是第三方應(yīng)用先申請一個授權(quán)碼,然后再用該碼獲取令牌。
這種方式是最常用的流程,安全性也最高,它適用于那些有后端的 Web 應(yīng)用。授權(quán)碼通過前端傳送,令牌則是儲存在后端,而且所有與資源服務(wù)器的通信都在后端完成。這樣的前后端分離,可以避免令牌泄漏。
A網(wǎng)站要獲取B網(wǎng)站的授權(quán)
1?? 第一步,A 網(wǎng)站(云打印)提供一個鏈接,用戶點擊后就會跳轉(zhuǎn)到 B (Google)網(wǎng)站,授權(quán)用戶數(shù)據(jù)給 A 網(wǎng)站使用。下面就是 A 網(wǎng)站跳轉(zhuǎn) B 網(wǎng)站的一個示意鏈接。
https://b.com/oauth/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=CALLBACK_URL&
scope=read
上面 URL 中,response_type
參數(shù)表示要求返回授權(quán)碼(code
),client_id
參數(shù)讓 B 知道是誰在請求,redirect_uri
參數(shù)是 B 接受或拒絕請求后的跳轉(zhuǎn)網(wǎng)址,scope
參數(shù)表示要求的授權(quán)范圍(這里是只讀)。
2?? 第二步,用戶跳轉(zhuǎn)后,B 網(wǎng)站會要求用戶登錄,然后詢問是否同意給予 A 網(wǎng)站授權(quán)。用戶表示同意,這時 B 網(wǎng)站就會跳回redirect_uri
參數(shù)指定的網(wǎng)址。跳轉(zhuǎn)時,會傳回一個授權(quán)碼,就像下面這樣。
https://a.com/callback?code=AUTHORIZATION_CODE
3?? 第三步,A 網(wǎng)站拿到授權(quán)碼以后,就可以在后端,向 B 網(wǎng)站請求令牌。
https://b.com/oauth/token?
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&
grant_type=authorization_code&
code=AUTHORIZATION_CODE&
redirect_uri=CALLBACK_URL
上面 URL 中,client_id
參數(shù)和client_secret
參數(shù)用來讓 B 確認(rèn) A 的身份(client_secret
參數(shù)是保密的,因此只能在后端發(fā)請求),grant_type
參數(shù)的值是AUTHORIZATION_CODE
,表示采用的授權(quán)方式是授權(quán)碼,code
參數(shù)是上一步拿到的授權(quán)碼,redirect_uri
參數(shù)是令牌頒發(fā)后的回調(diào)網(wǎng)址。
4?? 第四步,B 網(wǎng)站收到請求以后,就會頒發(fā)令牌。具體做法是向redirect_uri
指定的網(wǎng)址,發(fā)送一段 JSON 數(shù)據(jù)。
{
"access_token":"ACCESS_TOKEN",
"token_type":"bearer",
"expires_in":2592000,
"refresh_token":"REFRESH_TOKEN",
"scope":"read",
"uid":100101,
"info":{...}
}
上面 JSON 數(shù)據(jù)中,access_token
字段就是令牌,A 網(wǎng)站在后端拿到了。
最后A網(wǎng)站就可以通過令牌來訪問B網(wǎng)站的資源了。
二、隱藏式(純前端應(yīng)用)
有些 Web 應(yīng)用是純前端應(yīng)用,沒有后端。這時就不能用上面的方式了,必須將令牌儲存在前端。RFC 6749 就規(guī)定了第二種方式,允許直接向前端頒發(fā)令牌。這種方式?jīng)]有授權(quán)碼這個中間步驟,所以稱為(授權(quán)碼)“隱藏式”(implicit)。
1?? 第一步,A 網(wǎng)站提供一個鏈接,要求用戶跳轉(zhuǎn)到 B 網(wǎng)站,授權(quán)用戶數(shù)據(jù)給 A 網(wǎng)站使用。
https://b.com/oauth/authorize?
response_type=token&
client_id=CLIENT_ID&
redirect_uri=CALLBACK_URL&
scope=read
上面 URL 中,response_type
參數(shù)為token
,表示要求直接返回令牌。
2?? 第二步,用戶跳轉(zhuǎn)到 B 網(wǎng)站,登錄后同意給予 A 網(wǎng)站授權(quán)。這時,B 網(wǎng)站就會跳回redirect_uri
參數(shù)指定的跳轉(zhuǎn)網(wǎng)址,并且把令牌作為 URL 參數(shù),傳給 A 網(wǎng)站。
https://a.com/callback#token=ACCESS_TOKEN
上面 URL 中,token
參數(shù)就是令牌,A 網(wǎng)站因此直接在前端拿到令牌。
這種方式把令牌直接傳給前端,是很不安全的。因此,只能用于一些安全要求不高的場景,并且令牌的有效期必須非常短,通常就是會話期間(session)有效,瀏覽器關(guān)掉,令牌就失效了。
三、密碼式
如果你高度信任某個應(yīng)用,RFC 6749 也允許用戶把用戶名和密碼,直接告訴該應(yīng)用。該應(yīng)用就使用你的密碼,申請令牌,這種方式稱為"密碼式"(password)。
第一步,A 網(wǎng)站要求用戶提供 B 網(wǎng)站的用戶名和密碼。拿到以后,A 就直接向 B 請求令牌。
https://oauth.b.com/token?
grant_type=password&
username=USERNAME&
password=PASSWORD&
client_id=CLIENT_ID
上面 URL 中,grant_type
參數(shù)是授權(quán)方式,這里的password
表示"密碼式",username
和password
是 B 的用戶名和密碼。
第二步,B 網(wǎng)站驗證身份通過后,直接給出令牌。注意,這時不需要跳轉(zhuǎn),而是把令牌放在 JSON 數(shù)據(jù)里面,作為 HTTP 回應(yīng),A 因此拿到令牌。
這種方式需要用戶給出自己的用戶名/密碼,顯然風(fēng)險很大,因此只適用于其他授權(quán)方式都無法采用的情況,而且必須是用戶高度信任的應(yīng)用。
四、憑證式(命令行應(yīng)用)
最后一種方式是憑證式(client credentials),適用于沒有前端的命令行應(yīng)用,即在命令行下請求令牌。
第一步,A 應(yīng)用在命令行向 B 發(fā)出請求。
https://oauth.b.com/token?
grant_type=client_credentials&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET
上面 URL 中,grant_type
參數(shù)等于client_credentials
表示采用憑證式,client_id
和client_secret
用來讓 B 確認(rèn) A 的身份。
第二步,B 網(wǎng)站驗證通過以后,直接返回令牌。
這種方式給出的令牌,是針對第三方應(yīng)用的,而不是針對用戶的,即有可能多個用戶共享同一個令牌。
令牌的使用
A 網(wǎng)站拿到令牌以后,就可以向 B 網(wǎng)站的 API 請求數(shù)據(jù)了。
此時,每個發(fā)到 API 的請求,都必須帶有令牌。具體做法是在請求的頭信息,加上一個Authorization
字段,令牌就放在這個字段里面。
curl -H "Authorization: Bearer ACCESS_TOKEN" \
"https://api.b.com"
上面命令中,ACCESS_TOKEN
就是拿到的令牌。
更新令牌
令牌的有效期到了,如果讓用戶重新走一遍上面的流程,再申請一個新的令牌,很可能體驗不好,而且也沒有必要。OAuth 2.0 允許用戶自動更新令牌。
具體方法是,B 網(wǎng)站頒發(fā)令牌的時候,一次性頒發(fā)兩個令牌,一個用于獲取數(shù)據(jù),另一個用于獲取新的令牌(refresh token 字段)。令牌到期前,用戶使用 refresh token 發(fā)一個請求,去更新令牌。
https://b.com/oauth/token?
grant_type=refresh_token&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&
refresh_token=REFRESH_TOKEN
上面 URL 中,grant_type
參數(shù)為refresh_token
表示要求更新令牌,client_id
參數(shù)和client_secret
參數(shù)用于確認(rèn)身份,refresh_token
參數(shù)就是用于更新令牌的令牌。
B 網(wǎng)站驗證通過以后,就會頒發(fā)新的令牌。
OAuth2.0 客戶端實例
需求描述
我們這里將會演示將我們的應(yīng)用作為一個OAuth2.0客戶端來集成Github登錄,并實現(xiàn)對Github資源的訪問。
環(huán)境準(zhǔn)備
1?? 在Github注冊一個應(yīng)用,生成 client-id
, client-secret
2?? SpringSecurity
的集成,SpringSecurity
本身提供了 GOOGLE
GITHUB
FACEBOOK
OKTA
的 OAuth2.0
接入支持,具體源碼在枚舉類 CommonOAuth2Provider
中。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
演示Demo
1??首先將Github的Client-Id等信息配置到y(tǒng)ml文件:
server:
port: 8888
spring:
security:
oauth2:
client:
registration:
github:
client-id: XXXXXXXXXXXXXXX
client-secret: XXXXXXXXXXXXXXXXXXXX
2?? 提供一個home頁面Controller
@GetMapping(value = "/")
public String index() {
log.info(SecurityContextHolder.getContext().getAuthentication().toString());
return "Welcome " + SecurityContextHolder.getContext().getAuthentication();
}
3?? 訪問localhost:8888/login
點擊通過Github登錄:
我們授權(quán)登錄后,頁面會重定向到我們配置的home頁面:
借助 SpringSecurity
對 OAuth2.0
的支持,我們幾乎不用寫什么代碼就實現(xiàn)了 Github
登錄集成。下面再通過幾個例子來了解更多的細(xì)節(jié)。
查看Github在我們應(yīng)用中的注冊信息
@GetMapping(value = "/user/reg")
public String registration() {
ClientRegistration githubRegistration = this.clientRegistrationRepository.findByRegistrationId("github");
log.info(githubRegistration.toString());
return githubRegistration.toString();
}
訪問之后會返回 registration
信息,其中包含了 clientId
, clientSecret
, authorizationGrantType
, redirectUri
, scopes
等。
查看獲取到的AccessToken
@GetMapping(value = "/user/token")
public OAuth2AccessToken accessToken(OAuth2AuthenticationToken authentication) {
OAuth2AuthorizedClient authorizedClient = this.authorizedClientService.loadAuthorizedClient(
authentication.getAuthorizedClientRegistrationId(), authentication.getName());
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
return accessToken;
}
請求接口我們可以獲取到對應(yīng)的token信息:
{
"tokenValue":"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",
"issuedAt":"2023-02-08T06:05:05.107Z",
"expiresAt":"2023-02-08T06:05:06.107Z",
"tokenType":{
"value":"Bearer"
},
"scopes":[
"read:user"
]
}
通過AccessToken請求Github API
定義抽象 API
綁定類,通過攔截器將獲取到的 AccessToken
設(shè)置到后續(xù)請求頭中,通過 RestTemplate
實現(xiàn)對 API
的請求:
資料: 用戶 - GitHub 文檔
1?? 封裝Api Binding 為RestTemplate綁定請求頭
/**
* @Description: 綁定請求頭Authorization
* @Author: Ze WANG
**/
public abstract class ApiBinding {
protected RestTemplate restTemplate;
public ApiBinding(String accessToken) {
this.restTemplate = new RestTemplate();
if (accessToken != null) {
this.restTemplate.getInterceptors().add(getBearerTokenInterceptor(accessToken));
} else {
this.restTemplate.getInterceptors().add(getNoTokenInterceptor());
}
}
private ClientHttpRequestInterceptor getBearerTokenInterceptor(String accessToken) {
return new ClientHttpRequestInterceptor() {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] bytes, ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add("Authorization", "Bearer " + accessToken);
return execution.execute(request, bytes);
}
};
}
private ClientHttpRequestInterceptor getNoTokenInterceptor() {
return new ClientHttpRequestInterceptor() {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] bytes, ClientHttpRequestExecution execution) throws IOException {
throw new IllegalStateException("Can't access the Github API without an access token");
}
};
}
}
/**
* @Description: Github請求
* @Author: Ze WANG
**/
public class Github extends ApiBinding {
private static final String BASE_URL = "https://api.github.com";
public Github(String accessToken) {
super(accessToken);
}
public String getProfile() {
return restTemplate.getForObject(BASE_URL + "/user", String.class);
}
}
2?? 封裝獲取accessToken的過程
/**
* @Description: 封裝獲取accessToken的過程
* @Author: Ze WANG
**/
@Configuration
@Slf4j
public class SocialConfig {
@Bean
@RequestScope
public Github github(OAuth2AuthorizedClientService clientService) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
String accessToken = null;
if (authentication.getClass().isAssignableFrom(OAuth2AuthenticationToken.class)) {
OAuth2AuthenticationToken oauthToken = (OAuth2AuthenticationToken) authentication;
String clientRegistrationId = oauthToken.getAuthorizedClientRegistrationId();
if (clientRegistrationId.equals("github")) {
OAuth2AuthorizedClient client = clientService.loadAuthorizedClient(clientRegistrationId, oauthToken.getName());
if (client != null) {
accessToken = client.getAccessToken().getTokenValue();
}
log.info(accessToken);
}
}
return new Github(accessToken);
}
}
3?? Controller
@GetMapping(value = "/user/info")
public String info() {
String profile = github.getProfile();
log.info(github.getProfile());
return profile;
}
4?? 測試請求
OAuth2.0 授權(quán)碼實例
上一章節(jié)我們僅僅是模擬了第三方應(yīng)用如何通過OAuth2.0來實現(xiàn)Github的授權(quán)登錄,這一章節(jié),我們能將通過一個完整的Demo來梳理OAuth2.0的授權(quán)碼模式。
在這個案例中,主要包括如下服務(wù):
- 第三方應(yīng)用
- 授權(quán)服務(wù)器
- 資源服務(wù)器
- 用戶
項目 | 端口 | 備注 |
---|---|---|
auth-server | 8081 | 授權(quán)服務(wù)器 |
user-server | 8082 | 資源服務(wù)器 |
client-app | 8083 | 第三方應(yīng)用 |
搭建授權(quán)服務(wù)器
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
1?? 首先配置SpringSecurity的基礎(chǔ)配置:這段配置的目的,實際上就是配置用戶。
/**
* @Description: SpringSecurity的基本配置
* @Author: Ze WANG
**/
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* 創(chuàng)建兩個用戶綁定角色
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin")
.password(new BCryptPasswordEncoder().encode("123"))
.roles("admin")
.and()
.withUser("wangze")
.password(new BCryptPasswordEncoder().encode("123"))
.roles("user");
}
/**
* 配置表單登錄
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().formLogin();
}
}
2?? 配置授權(quán)服務(wù)器
首先我們提供了一個 TokenStore 的實例,這個是指你生成的 Token 要往哪里存儲,我們可以存在 Redis 中,也可以存在內(nèi)存中,也可以結(jié)合 JWT 等等,這里,我們就先把它存在內(nèi)存中,所以提供一個 InMemoryTokenStore 的實例即可。
/**
* @Description: Token存儲位置
* @Author: Ze WANG
**/
@Configuration
public class AccessTokenConfig {
@Bean
TokenStore tokenStore() {
return new InMemoryTokenStore();
}
}
/**
* @Description: 授權(quán)服務(wù)
* @Author: Ze WANG
**/
//@EnableAuthorizationServer 注解,表示開啟授權(quán)服務(wù)器的自動化配置。
@EnableAuthorizationServer
@Configuration
public class AuthorizationServer extends AuthorizationServerConfigurerAdapter {
@Autowired
TokenStore tokenStore;
@Autowired
ClientDetailsService clientDetailsService;
/**
* 主要用來配置 Token 的一些基本信息,例如 Token 是否支持刷新、Token 的存儲位置、Token 的有效期以及刷新 Token 的有效期等等。
*/
@Bean
AuthorizationServerTokenServices tokenServices() {
DefaultTokenServices services = new DefaultTokenServices();
//設(shè)置客戶端詳細(xì)信息服務(wù)
services.setClientDetailsService(clientDetailsService);
//設(shè)置支持刷新令牌
services.setSupportRefreshToken(true);
//設(shè)置支持刷新令牌
services.setTokenStore(tokenStore);
//設(shè)置訪問令牌有效期秒數(shù)
services.setAccessTokenValiditySeconds(60 * 60 * 2);
//設(shè)置刷新令牌有效期秒數(shù)
services.setRefreshTokenValiditySeconds(60 * 60 * 24 * 3);
return services;
}
/**
* 用來配置令牌端點的安全約束,也就是這個端點誰能訪問,誰不能訪問。
*/
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.checkTokenAccess("permitAll()")
.allowFormAuthenticationForClients();
}
/**
* 第三方應(yīng)用(客戶端)詳細(xì)信息服務(wù)配置,此處類似與github上注冊應(yīng)用
*/
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("wz-app")
.secret(new BCryptPasswordEncoder().encode("123"))
.resourceIds("res1")
.authorizedGrantTypes("authorization_code","refresh_token")
.scopes("all")
.redirectUris("http://localhost:8083/index.html");
}
/**
* 用來配置令牌的訪問端點和令牌服務(wù)
*/
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
//authorizationCodeServices用來配置授權(quán)碼(Code)的存儲,這里我們是存在在內(nèi)存中
endpoints.authorizationCodeServices(authorizationCodeServices())
//tokenServices 用來配置令牌的存儲,即 access_token 的存儲位置,這里我們也先存儲在內(nèi)存中
.tokenServices(tokenServices());
}
@Bean
AuthorizationCodeServices authorizationCodeServices() {
return new InMemoryAuthorizationCodeServices();
}
}
搭建資源服務(wù)器
/**
* @Description: 資源服務(wù)器配置
* @Author: Ze WANG
**/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
/**
* RemoteTokenServices 中我們配置了 access_token 的校驗地址、client_id、client_secret 這三個信息,
*/
@Bean
RemoteTokenServices tokenServices() {
RemoteTokenServices services = new RemoteTokenServices();
//Spring Security 默認(rèn)校驗地址
services.setCheckTokenEndpointUrl("http://localhost:8081/oauth/check_token");
services.setClientId("wz-app");
services.setClientSecret("123");
return services;
}
/**
* 當(dāng)用戶來資源服務(wù)器請求資源時,會攜帶上一個 access_token,通過這里的配置,就能夠校驗出 token 是否正確等。
*/
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId("res1").tokenServices(tokenServices());
}
/**
* 配置一下資源的攔截規(guī)則,admin的資源需要有admin的權(quán)限
*/
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated();
}
}
資源:
/**
* @Description: 測試接口
* @Author: Ze WANG
**/
@RestController
public class ResController {
@GetMapping("/res")
public String hello() {
return "====普通資源====";
}
@GetMapping("/admin/res")
public String admin() {
return "====admin資源====";
}
}
第三方應(yīng)用搭建
為了簡單的演示,此處使用Thymeleaf來寫少量簡單的前端代碼:在resources/template
目錄下,創(chuàng)建index.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>wz-app</title>
</head>
<body>
<h1>Hello! WZ-APP</h1>
<hr>
登錄:
<a href="http://localhost:8081/oauth/authorize?client_id=wz-app&response_type=code&scope=all&redirect_uri=http://localhost:8083/index.html">第三方登錄</a>
<br>
<h3 th:text="${token}"></h3>
<h1 th:text="${res}"></h1>
</body>
</html>
然后提供一個測試Controller:
@Controller
public class HelloController {
@Autowired
private RestTemplate restTemplate;
@GetMapping("/index.html")
public String hello(String code, Model model) {
if (code != null) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("code", code);
map.add("client_id", "wz-app");
map.add("client_secret", "123");
map.add("redirect_uri", "http://localhost:8083/index.html");
map.add("grant_type", "authorization_code");
//獲取令牌
Map<String,String> resp = restTemplate.postForObject("http://localhost:8081/oauth/token", map, Map.class);
String access_token = resp.get("access_token");
//請求資源
System.out.println("令牌: "+access_token);
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8082/admin/res", HttpMethod.GET, httpEntity, String.class);
model.addAttribute("token","令牌:"+access_token);
model.addAttribute("res", "資源"+entity.getBody());
}
return "index";
}
}
下面我們先分析一下預(yù)期的流程,然后再來測試看是否符合預(yù)期:
下面通過測試來走一遍流程:
首先進(jìn)入第三方應(yīng)用首頁:
點擊第三方登錄:通過admin賬號 登錄
授權(quán):
授權(quán)后我們可以看到會url上會帶有code,并且獲得了令牌和資源:
這里僅僅是一個簡單的例子,為了方便熟悉OAuth2.0的授權(quán)碼模式全流程。access_token通常會通過一個定時任務(wù)來維護(hù),不需要每次請求頁面都去獲取,定期更新即可。
案例分析與優(yōu)化
通過上邊的例子,我們發(fā)現(xiàn)我們大部分的存儲都是在內(nèi)存中做的。我們可以從以下幾個方面進(jìn)行簡單的優(yōu)化:
- 令牌的存儲位置
- 客戶端信息入庫
- 第三方應(yīng)用優(yōu)化
令牌的存儲位置
在我們配置授權(quán)碼的時候,將授權(quán)碼和令牌都存儲在了內(nèi)存中,我們可以看看TokenStroe
的類圖:
-
InMemoryTokenStore
,這是我們之前使用的,也是系統(tǒng)默認(rèn)的,就是將 access_token 存到內(nèi)存中,單機(jī)應(yīng)用這個沒有問題,但是在分布式環(huán)境下不推薦。 -
JdbcTokenStore
,看名字就知道,這種方式令牌會被保存到數(shù)據(jù)中,這樣就可以方便的和其他應(yīng)用共享令牌信息。 -
JwtTokenStore
,這個其實不是存儲,因為使用了 jwt 之后,在生成的 jwt 中就有用戶的所有信息,服務(wù)端不需要保存,這也是無狀態(tài)登錄。 -
RedisTokenStore
,這個很明顯就是將 access_token 存到 redis 中。 -
JwkTokenStore
,將 access_token 保存到 JSON Web Key。
雖然這里支持的方案比較多,但是我們常用的實際上主要是兩個,RedisTokenStore 和 JwtTokenStore
客戶端信息存儲
客戶端也就是第三方app的信息,之前我們也是直接寫在內(nèi)存中,同樣我們可以通過ClientDetailsService
的類圖發(fā)現(xiàn)其提供的存儲方法:
除了內(nèi)存的方式,只有額外數(shù)據(jù)庫的存儲的方式,通過源碼可以分析出數(shù)據(jù)庫的結(jié)構(gòu):
DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details` (
`client_id` varchar(48) NOT NULL,
`resource_ids` varchar(256) DEFAULT NULL,
`client_secret` varchar(256) DEFAULT NULL,
`scope` varchar(256) DEFAULT NULL,
`authorized_grant_types` varchar(256) DEFAULT NULL,
`web_server_redirect_uri` varchar(256) DEFAULT NULL,
`authorities` varchar(256) DEFAULT NULL,
`access_token_validity` int(11) DEFAULT NULL,
`refresh_token_validity` int(11) DEFAULT NULL,
`additional_information` varchar(4096) DEFAULT NULL,
`autoapprove` varchar(256) DEFAULT NULL,
PRIMARY KEY (`client_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
第三方應(yīng)用優(yōu)化
我們上面分析了,demo中的令牌不能自動續(xù)期,我們可以通過一個TokenTask來管理Token:
@GetMapping("/index.html")
public String res(String code, Model model) {
model.addAttribute("res", tokenTask.getData(code));
return "index";
}
@Component
@Slf4j
public class TokenTask {
@Autowired
RestTemplate restTemplate;
public String access_token = "";
public String refresh_token = "";
public String getData(String code) {
if ("".equals(access_token) && code != null) {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("code", code);
map.add("client_id", "wz-app");
map.add("client_secret", "123");
map.add("redirect_uri", "http://localhost:8083/index.html");
map.add("grant_type", "authorization_code");
Map<String, String> resp = restTemplate.postForObject("http://localhost:8081/oauth/token", map, Map.class);
access_token = resp.get("access_token");
refresh_token = resp.get("refresh_token");
return loadDataFromResServer();
} else {
return loadDataFromResServer();
}
}
private String loadDataFromResServer() {
try {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + access_token);
HttpEntity<Object> httpEntity = new HttpEntity<>(headers);
ResponseEntity<String> entity = restTemplate.exchange("http://localhost:8082/admin/res", HttpMethod.GET, httpEntity, String.class);
log.info("資源數(shù)據(jù)為=={}",entity.getBody());
return entity.getBody();
} catch (RestClientException e) {
return "未加載";
}
}
@Scheduled(cron = "0 55 0/1 * * ?")
public void tokenTask() {
MultiValueMap<String, String> map = new LinkedMultiValueMap<>();
map.add("client_id", "wz-app");
map.add("client_secret", "123");
map.add("refresh_token", refresh_token);
map.add("grant_type", "refresh_token");
Map<String, String> resp = restTemplate.postForObject("http://localhost:8081/oauth/token", map, Map.class);
log.debug("定時任務(wù)獲取的data=={}",resp);
access_token = resp.get("access_token");
refresh_token = resp.get("refresh_token");
}
}
OAuth2.0 單點登錄實例
單點登錄是我們在分布式系統(tǒng)中很常見的一個需求。
分布式系統(tǒng)由多個不同的子系統(tǒng)組成,而我們在使用系統(tǒng)的時候,只需要登錄一次即可,這樣其他系統(tǒng)都認(rèn)為用戶已經(jīng)登錄了,不用再去登錄。
下面的例子通過 Spring Boot+OAuth2
做單點登錄,利用 @EnableOAuth2Sso
注解快速實現(xiàn)單點登錄功能。
我們要實現(xiàn)單點登錄,需要我們再提供多個客戶端,并且當(dāng)這個客戶端登錄成功后,其它客戶端不需要再登錄。
認(rèn)證與資源服務(wù)依舊采用上個例子中的服務(wù)。我們再來開發(fā)兩個客戶端來實現(xiàn)單點登錄的效果。
項目 | 端口 | 備注 |
---|---|---|
auth-res-server | 8086 | 鑒權(quán)與資源中心 |
client1 | 8084 | 第三方應(yīng)用client1 |
client2 | 8085 | 第三方應(yīng)用client2 |
認(rèn)證與資源中心配置
這里為了簡便,采用認(rèn)證與資源使用一個服務(wù)的方式。
依賴:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
項目創(chuàng)建成功之后,這個模塊由于要扮演授權(quán)服務(wù)器+資源服務(wù)器的角色,所以我們先在這個項目的啟動類上添加 @EnableResourceServer
注解,表示這是一個資源服務(wù)器:
@SpringBootApplication
@EnableResourceServer
public class AuthResServerApplication {
public static void main(String[] args) {
SpringApplication.run(AuthResServerApplication.class, args);
}
}
接下來我們進(jìn)行授權(quán)服務(wù)器的配置,由于資源服務(wù)器和授權(quán)服務(wù)器合并在一起,因此授權(quán)服務(wù)器的配置要省事很多:
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
PasswordEncoder passwordEncoder;
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("sso")
.secret(passwordEncoder.encode("123"))
.autoApprove(true)//自動授權(quán)
.redirectUris("http://localhost:8084/login", "http://localhost:8085/login")
.scopes("user")
.accessTokenValiditySeconds(7200)
.authorizedGrantTypes("authorization_code");
}
}
接下來我們再來配置 Spring Security:
@Configuration
@Order(1)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/login.html", "/css/**", "/js/**", "/images/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.requestMatchers()
.antMatchers("/login")
.antMatchers("/oauth/authorize")
.and()
.authorizeRequests().anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login.html") //自定義的一個登錄頁面
.loginProcessingUrl("/login")
.permitAll()
.and()
.csrf().disable();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("wz")
.password(passwordEncoder().encode("123"))
.roles("admin");
}
}
添加一個暴露用戶信息的資源接口:
@GetMapping("/user")
public Principal getCurrentUser(Principal principal) {
return principal;
}
客戶端服務(wù)創(chuàng)建
我們需要創(chuàng)建兩個客戶端,名字分別為
client1
和client2
。都添加Spring Security + Oauth2
的依賴。
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
然后我們配置以下客戶端的Spring Security
:
/**
* @Description: SecurityConfig,client中的所有接口都需要認(rèn)證之后才能訪問
* @Author: Ze WANG
**/
@Configuration
@EnableOAuth2Sso //開啟單點登錄的功能
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().anyRequest().authenticated().and().csrf().disable();
}
}
提供測試接口:
/**
* @Description: UserController,返回當(dāng)前登錄的用戶的姓名和角色信息
* @Author: Ze WANG
**/
@RestController
public class UserController {
@GetMapping("/user")
public String user() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
return authentication.getName() + Arrays.toString(authentication.getAuthorities().toArray());
}
}
配置OAuth2的相關(guān)信息:
# client-secret
security.oauth2.client.client-secret=123
# client-id
security.oauth2.client.client-id=sso
# get user authorize
security.oauth2.client.user-authorization-uri=http://localhost:8086/oauth/authorize
# get token
security.oauth2.client.access-token-uri=http://localhost:8086/oauth/token
# user info
security.oauth2.resource.user-info-uri=http://localhost:8086/user
#port
server.port=8084
#cookie-name
server.servlet.session.cookie.name=client1_cookie
單點登錄測試
-
直接訪問
client1
的/user
接口,會要求我們登錄,重定向到client1
的/login
,由于我們配置了@EnableOAuth2Sso
所以這個操作會被攔截下來,根據(jù)我們的配置自動發(fā)起請求去獲取授權(quán)碼。 -
跳轉(zhuǎn)到鑒權(quán)中心的
/oauth/authorize
,需要先登錄,登錄之后,授權(quán),授權(quán)后獲得授權(quán)碼。 -
獲取到授權(quán)碼之后,這個時候會重定向到我們 client1 的 login 頁面,但是實際上我們的 client1 其實是沒有登錄頁面的,所以這個操作依然會被攔截,此時攔截到的地址包含有授權(quán)碼,拿著授權(quán)碼,在
OAuth2ClientAuthenticationProcessingFilter
類中向鑒權(quán)中心發(fā)起請求,就能拿到 access_token 了。 -
拿到
access_token
之后,接下來在向我們配置的user-info-uri
地址發(fā)送請求,獲取登錄用戶信息。 -
這時候在請求client2的
/user
接口,不需要手動再次登錄。
github地址:Oauth2-sso-demo
參考:
[1].OAuth 2.0 的一個簡單解釋 - 阮一峰的網(wǎng)絡(luò)日志 (ruanyifeng.com)文章來源:http://www.zghlxwxcb.cn/news/detail-404138.html
[2].OAuth 2.0 的四種方式 - 阮一峰的網(wǎng)絡(luò)日志 (ruanyifeng.com)文章來源地址http://www.zghlxwxcb.cn/news/detail-404138.html
到了這里,關(guān)于OAuth2.0從入門到實戰(zhàn)(附github地址)的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!