一文帶你瞭解 OAuth2 協議與 Spring Security OAuth2 集成

OAuth 2.0 允許第三方應用程序訪問受限的HTTP資源的授權協議,像平常大家使用 Github 、 Google 賬號來登陸其他系統時使用的就是 OAuth 2.0 授權框架,下圖就是使用 Github 賬號登陸 Coding 系統的授權頁面圖:

類似使用 OAuth 2.0 授權的還有很多,本文將介紹 OAuth 2.0 相關的概念如:角色、授權類型等知識,以下是我整理一張 OAuth 2.0 授權的腦頭,希望對大家瞭解 OAuth 2.0 授權協議有幫助。

文章將以腦圖中的內容展開 OAuth 2.0 協議同時除了 OAuth 2.0 外,還會配合 Spring Security OAuth2 來搭建 OAuth2客戶端 ,這也是學習 OAuth 2.0 的目的,直接應用到實際項目中,加深對 OAuth 2.0 和 Spring Security 的理解。

OAuth 2.0 角色

OAuth 2.0 中有四種類型的角色分別為: 資源Owner 、 授權服務 、 客戶端 、 資源服務 ,這四個角色負責不同的工作,為了方便理解先給出一張大概的流程圖,細節部分後面再分別展開:

資源 Owner

資源 Owner可以理解為一個用戶,如之前提到使用 Github 登陸 Coding 中的例子中,用戶使用GitHub賬號登陸Coding,Coding就需要知道用戶在GitHub系統中的的頭像、用戶名、email等信息,這些賬戶信息都是屬於用戶的這樣就不難理解 資源 Owner 了。在Coding請求從GitHub中獲取想要的用戶信息時也是沒那容易的,GitHub為了安全起見,至少要通過用戶(資源 Owner)的同意才行。

資源服務器

明白 資源 Owner 後,相信你已經知道什麼是 資源服務器 ,在這個例子中用戶賬號的信息都存放在GitHub的服務器中,所以這裡的資源服務器就是GitHub服務器。GitHub服務器負責保存、保護用戶的資源,任何其他第三方系統想到使用這些信息的系統都需要經過 資源 Owner 授權,同時依照 OAuth 2.0 授權流程進行交互。

客戶端

知道 資源 Owner 和 資源服務器 後,OAuth中的客戶端角色也相對容易理解了,簡單的說 客戶端就是想要獲取資源的系統 ,如例子中的使用GitHub登陸Coding時,Coding就是OAuth中的客戶端。客戶端主要負責發起授權請求、獲取AccessToken、獲取用戶資源。

有了 資源 Owner 、 資源服務器 、 客戶端 還不能完成OAuth授權的,還需要有 授權服務器 。在OAuth中授權服務器除了負責與用戶(資源 Owner)、客房端(Coding)交互外,還要生成AccessToken、驗證AccessToken等功能,它是OAuth授權中的非常重要的一環,在例子中授權服務器就是GitHub的服務器。

小結

OAuth中: 資源Owner 、 授權服務 、 客戶端 、 資源服務 有四個角色在使用GitHub登陸Coding的例子中分別表示:

資源Owner:GitHub用戶授權服務:GitHub服務器客戶端:Coding系統資源服務:GitHub服務器

其中授權服務服務器、資源服務器可以單獨搭建(鬼知道GitHub怎麼搭建的)。在微服務器架構中可單獨弄一個授權服務,資源服務服務可以多個如:用戶資源、倉庫資源等,可根據需求自由分服務。

OAuth2 Endpoint

OAuth2有三個重要的Endpoint其中 授權 Endpoint 、 Token Endpoint 結點在授權服務器中,還有一個可選的 重定向 Endpoint 在客戶端中。

<code>授權 Endpoint
重定向 Endpoint
/<code>

通過四個OAuth角色,應該對OAuth協議有一個大概的認識,不過可能還是一頭霧水不知道OAuth中的角色是如何交互的,沒關係繼續往下看一下 授權類型 就知道OAuth中的角色是如何完成自己的職責,進一步對OAuth的理解。在OAuth中定義了四種 授權類型 ,分別為:

授權碼授權客房端憑證授權資源Owner的密碼授權隱式的授權

不同的 授權類型 可以使用在不同的場景中。

這種形式就是我們常見的授權形式(如使用GitHub賬號登陸Coding),在整個授權流程中會有 資源Owner 、 授權服務器 、 客戶端 三個OAuth角色參與,之所以叫做 授權碼授權 是因為在交互流程中授權服務器會給客房端發放一個 code ,隨後客房端拿著授權服務器發放的code繼續進行授權如:請求授權服務器發放AccessToken。

為方便理解再將上圖的內容帶進真實的場景中,用文字表述一下整個流程:

A.1、用戶訪問Coding登陸頁( https://coding.net/login ),點擊Github登陸按鈕;A.2、Coding服務器將瀏覽器重定向到Github的授權頁( https://github.com/login/oauth/authorize?client_id=a5ce5a6c7e8c39567ca0&scope=user:email&redirect_uri=https://coding.net/api/oauth/github/callback&response_type=code ),同時URL帶上 client_id 和 redirect_uri 參數;B.1、用戶輸入用戶名、密碼登陸Github;B.2、用戶點擊 授權按鈕 ,同意授權;C.1、Github授權服務器返回 code ;C.2、Github通過將瀏覽器重定向到 A.2 步驟中傳遞的 redirect_uri 地址(https://coding.net/api/oauth/github/callback&response_type=code);D、Coding拿到code後,調用Github授權服務器API獲取AccessToken,由於這一步是在Coding服務器後臺做的瀏覽器中捕獲不到,基本就是使用 code 訪問github的access_token節點獲取AccessToken;

以上是大致的 授權碼授權 流程,大部分是客戶端與授權服務器的交互,整個過程中有幾個參數說明如下:

client_id:在Github中註冊的Appid,用於標記客戶端redirect_uri:可以理解一個 callback ,授權服務器驗證完客戶端與用戶名等信息後將瀏覽器重定向到此地址並帶上 code 參數code:由授權服務器返回的一個憑證,用於獲取AccessTokenstate:由客戶端傳遞給授權服務器,授權服務器一般到調用 redirect_uri 時原樣返回

在使用授權碼授權的模式中,作為客戶端請求授權的的時候都需要按規範請求,以下是使用授權碼授權發起授權時所需要的參數 :

如使用Github登陸Coding例子中的 https://github.com/login/oauth/authorize?client_id=a5ce5a6c7e8c39567ca0&scope=user:email&redirect_uri=https://coding.net/api/oauth/github/callback&response_type=code 授權請求URL,就有 client_id 、 redirect_uri 參數,至於為啥沒有 response_type 在下猜想是因為Github給省了吧。

如果用戶同意授權,那授權服務器也會返回標準的OAuth授權響應:

如Coding登陸中的 https://coding.net/api/oauth/github/callback&response_type=code ,用戶同意授權後Github授權服務器回調Coding的回調地址,同時返回 code 、 state 參數。

客房端憑證授權 授權的過程中只會涉及客戶端與授權服務器交互,相比較其他三種授權類型是比較簡單的。一般這種授權模式是用於服務之間授權,如在AWS中兩臺服務器分別為應用服務器(A)和數據服務器(B),A 服務器需要訪問 B 服務器就需要通過授權服務器授權,然後才能去訪問 B 服務器獲取數據。

簡單二步就可以完成 客房端憑證授權 啦,不過在使用 客房端憑證授權 時客戶端是直接訪問的授權服務器中獲取AccessToken接口。

客房端憑證授權中客戶端會直接發起獲取AccessToken請求授權服務器的 AccessToken Endpoint,請求參數如下:

注意:在OAuth中 AccessToken Endpoint是使用HTTP Basic認證,在請求時還需要攜帶 Authorization 請求頭,如使用 postman 測試請求時:

其中的 username 和 password 參數對於OAuth協議中的 client_id 和 client_secret , client_id 和 client_secret 都是由授權服務器生成的。

授權服務器驗證完 client_id 和 client_secret 後返回token:

<code>{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,


"example_parameter":"example_value"
}/<code>

用戶憑證授權 與 客戶端憑證授權 類似,不同的地方是進行授權時要提供用戶名和用戶的密碼。

基本流程如下:

A、客戶端首先需要知道用戶的憑證B、使用用戶憑證獲取AccessTokenC、授權服務器驗證客戶端與用戶憑證,返回AccessToken

用戶憑證授權請求參數要比客戶端憑證授權多 username 和 pwssword 參數:

注意: 獲取Token時使用HTTP Basic認證,與客戶端憑證授權一樣。

用戶憑證授權響應與客戶端憑證授權差不多:

<code>{
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"example",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"example_parameter":"example_value"
}/<code>

隱式授權用於獲取AccessToken,但是獲取的方式與 用戶憑證授權 和 客戶端授權 不同的是,它是在訪問 授權Endpoint 的時候就會獲取AccessToken而不是訪問 Token Endpoing ,而且AccessToken的會作為 redirect_uri 的Segment返回。

A.1、A.2、瀏覽器訪問支持隱式授權的服務器的授權Endpoint;B.1、用戶輸入賬號密碼;B.2、用戶點擊 授權按鈕 ,同意授權;C、授權服務器使用 redirect_uri 返回AccessToken;D、授權服務器將瀏覽器重定向到 redirect_uri ,並攜帶AccessToken如: http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA&state=xyz&token_type=example&expires_in=3600 ;D、 redirect_uri 的地址是指向一個 Web資源客戶端E、 Web資源客戶端 返回一段腳本F、瀏覽器執行腳本D、客戶端獲得AccessToken

隱式授權不太好理解,但是仔細比較 客戶端憑證授權 和 用戶憑證授權 會發現 隱式授權 不需要知道 用戶憑證 或 客戶端憑證 ,這樣做相對更安全。

再使用 隱式授權 時,所需要請求參數如下:

隱式授權響應參數是通過 redirect_uri 回調返回的,如 http://example.com/cb#access_token=2YotnFZFEjr1zCsicMWpAA &state=xyz&token_type=example&expires_in=3600 就是隱式授權響應參數,其中需要注意的是響應的參數是使用Segment的形式的,而不是普通的URL參數。

OAuth2 客戶端

前面提到過OAuth協議中有四個角色,這一節使用Spring Boot實現一個登陸 GitHub 的 OAuthClient ,要使用OAuth2協議登陸GitHub首先要雲GitHub裡面申請:

申請 OAuth App

填寫必需的信息

上圖中的 Authorization callback URL 就是 redirect_uri 用戶同意授權後GitHub會將瀏覽器重定向到該地址,因此先要在本地的OAuth客戶端服務中添加一個接口響應GitHub的重定向請求。

配置OAuthClient

熟悉OAuth2協議後,我們在使用 Spring Security OAuth2 配置一個GitHub授權客戶端,使用 認證碼 授權流程(可以先去看一遍認證碼授權流程圖),示例工程依賴:

<code><dependency>
<groupid>org.springframework.boot/<groupid>
<artifactid>spring-boot-starter-oauth2-client/<artifactid>
/<dependency>


<dependency>
<groupid>org.springframework.boot/<groupid>
<artifactid>spring-boot-starter-web/<artifactid>
/<dependency>/<code>

Spring Security OAuth2 默認集成了Github、Goolge等常用的授權服務器,因為這些常用的授權服務的配置信息都是公開的,Spring Security OAuth2 已經幫我們配置了,開發都只需要指定必需的信息就行如:clientId、clientSecret。

Spring Security OAuth2 使用 Registration 作為客戶端的的配置實體:

<code>public static class Registration {
//授權服務器提供者名稱
private String provider;
//客戶端id
private String clientId;
//客戶端憑證


private String clientSecret;
..../<code>

下面是之前註冊好的 GitHub OAuth App 的信息:

<code>spring.security.oauth2.client.registration.github.clientId=5fefca2daccf85bede32
spring.security.oauth2.client.registration.github.clientSecret=01dde7a7239bd18bd8a83de67f99dde864fb6524``/<code>

配置redirect_uri

Spring Security OAuth2 內置了一個redirect_uri模板: {baseUrl}/login/oauth2/code/{registrationId} ,其中的 registrationId 是在從配置中提取出來的:

<code>spring.security.oauth2.client.registration.[registrationId].clientId=xxxxx/<code>

如在上面的GitHub客戶端的配置中,因為指定的 registrationId 是 github ,所以重定向uri地址就是:

<code>{baseUrl}/login/oauth2/code/github/<code>

啟動服務器

OAuth2客戶端和重定向Uri配置好後,將服務器啟動,然後打開瀏覽器進入: http://localhost:8080/ 。第一次打開因為沒有認證會將瀏覽器重客向到GitHub的 授權Endpoint :

常用授權服務器(CommonOAuth2Provider)

Spring Security OAuth2 內置了一些常用的授權服務器的配置,這些配置都在 CommonOAuth2Provider 中:

<code>public enum CommonOAuth2Provider {

GOOGLE {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.authorizationUri("https://accounts.google.com/o/oauth2/v2/auth");
builder.tokenUri("https://www.googleapis.com/oauth2/v4/token");
builder.jwkSetUri("https://www.googleapis.com/oauth2/v3/certs");
builder.userInfoUri("https://www.googleapis.com/oauth2/v3/userinfo");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Google");
return builder;
}
},

GITHUB {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
},

FACEBOOK {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,


ClientAuthenticationMethod.POST, DEFAULT_REDIRECT_URL);
builder.scope("public_profile", "email");
builder.authorizationUri("https://www.facebook.com/v2.8/dialog/oauth");
builder.tokenUri("https://graph.facebook.com/v2.8/oauth/access_token");
builder.userInfoUri("https://graph.facebook.com/me?fields=id,name,email");
builder.userNameAttributeName("id");
builder.clientName("Facebook");
return builder;
}
},

OKTA {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("openid", "profile", "email");
builder.userNameAttributeName(IdTokenClaimNames.SUB);
builder.clientName("Okta");
return builder;
}
};

private static final String DEFAULT_REDIRECT_URL = "{baseUrl}/{action}/oauth2/code/{registrationId}";
}/<code>

CommonOAuth2Provider 中有四個授權服務器配置: OKTA 、 FACEBOOK 、 GITHUB 、 GOOGLE 。在OAuth2協議中的配置項 redirect_uri 、 Token Endpoint 、 授權 Endpoint 、 scope 都會在這裡配置:

<code>GITHUB {

@Override
public Builder getBuilder(String registrationId) {
ClientRegistration.Builder builder = getBuilder(registrationId,
ClientAuthenticationMethod.BASIC, DEFAULT_REDIRECT_URL);
builder.scope("read:user");
builder.authorizationUri("https://github.com/login/oauth/authorize");
builder.tokenUri("https://github.com/login/oauth/access_token");
builder.userInfoUri("https://api.github.com/user");
builder.userNameAttributeName("id");
builder.clientName("GitHub");
return builder;
}
}/<code>

重定向Uri攔截

腦瓜子有點蒙了,感覺自己就配置了 clientid 和 clientSecret 一個OAuth2客戶端就完成了,其中的一些原由還沒搞明白啊。。。,最好奇的是重定向Uri是怎麼被處理的。

Spring Security OAuth2 是基於 Spring Security 的,之前看過 Spring Security 文章,知道它的處理原理是基於過濾器的,如果你不知道的話推薦看這篇文章: 《Spring Security 架構》 。在源碼中找了一下,發現一個可疑的Security 過濾器:

OAuth2LoginAuthenticationFilter:處理OAuth2授權的過濾器

這個 Security 過濾器有個常量:

<code>public static final String DEFAULT_FILTER_PROCESSES_URI = "/login/oauth2/code/*";/<code>

是一個匹配器,之前提到過 Spring Security OAuth2 中有一個默認的redirect_uri模板: {baseUrl}/{action}/oauth2/code/{registrationId} , /login/oauth2/code/* 正好能與redirect_uri模板匹配成功,所以 OAuth2LoginAuthenticationFilter 會在用戶同意授權後執行,它的構造方法如下:

<code>public OAuth2LoginAuthenticationFilter(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
this(clientRegistrationRepository, authorizedClientService, DEFAULT_FILTER_PROCESSES_URI);
}/<code>

OAuth2LoginAuthenticationFilter 主要將授權服務器返回的 code 拿出來,然後通過AuthenticationManager 來認證(獲取AccessToken),下來是移除部分代碼後的源代碼:

<code>@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {

MultiValueMap<string> params = OAuth2AuthorizationResponseUtils.toMultiMap(request.getParameterMap());
//檢查沒code與state
if (!OAuth2AuthorizationResponseUtils.isAuthorizationResponse(params)) {
OAuth2Error oauth2Error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
//獲取 OAuth2AuthorizationRequest
OAuth2AuthorizationRequest authorizationRequest =
this.authorizationRequestRepository.removeAuthorizationRequest(request, response);
if (authorizationRequest == null) {
OAuth2Error oauth2Error = new OAuth2Error(AUTHORIZATION_REQUEST_NOT_FOUND_ERROR_CODE);


throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
//取出 ClientRegistration
String registrationId = authorizationRequest.getAttribute(OAuth2ParameterNames.REGISTRATION_ID);
ClientRegistration clientRegistration = this.clientRegistrationRepository.findByRegistrationId(registrationId);
if (clientRegistration == null) {
OAuth2Error oauth2Error = new OAuth2Error(CLIENT_REGISTRATION_NOT_FOUND_ERROR_CODE,
"Client Registration not found with Id: " + registrationId, null);
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
String redirectUri = UriComponentsBuilder.fromHttpUrl(UrlUtils.buildFullRequestUrl(request))
.replaceQuery(null)
.build()
.toUriString();

//認證、獲取AccessToken
OAuth2AuthorizationResponse authorizationResponse = OAuth2AuthorizationResponseUtils.convert(params, redirectUri);

Object authenticationDetails = this.authenticationDetailsSource.buildDetails(request);
OAuth2LoginAuthenticationToken authenticationRequest = new OAuth2LoginAuthenticationToken(
clientRegistration, new OAuth2AuthorizationExchange(authorizationRequest, authorizationResponse));
authenticationRequest.setDetails(authenticationDetails);

OAuth2LoginAuthenticationToken authenticationResult =
(OAuth2LoginAuthenticationToken) this.getAuthenticationManager().authenticate(authenticationRequest);

...
return oauth2Authentication;
}/<string>/<code>

獲取AccessToken

前面提到 OAuth2LoginAuthenticationFilter 是使用 AuthenticationManager 來進行OAuth2認證的,一般情況下在 Spring Security 中的 AuthenticationManager 都是使用的 ProviderManager 來進行認證的,所以對應在 Spring Security OAuth2 中有一個 OAuth2LoginAuthenticationProvider 用於獲取AccessToken:

<code>public class OAuth2LoginAuthenticationProvider implements AuthenticationProvider {
private final OAuth2AccessTokenResponseClient<oauth2authorizationcodegrantrequest> accessTokenResponseClient;
private final OAuth2UserService<oauth2userrequest> userService;
private GrantedAuthoritiesMapper authoritiesMapper = (authorities -> authorities);

....

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
OAuth2LoginAuthenticationToken authorizationCodeAuthentication =
(OAuth2LoginAuthenticationToken) authentication;


// Section 3.1.2.1 Authentication Request - https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// scope
// REQUIRED. OpenID Connect requests MUST contain the "openid" scope value.
if (authorizationCodeAuthentication.getAuthorizationExchange()
.getAuthorizationRequest().getScopes().contains("openid")) {
// This is an OpenID Connect Authentication Request so return null
// and let OidcAuthorizationCodeAuthenticationProvider handle it instead
return null;
}

OAuth2AccessTokenResponse accessTokenResponse;
try {
OAuth2AuthorizationExchangeValidator.validate(
authorizationCodeAuthentication.getAuthorizationExchange());

//訪問GitHub TokenEndpoint獲取Token
accessTokenResponse = this.accessTokenResponseClient.getTokenResponse(
new OAuth2AuthorizationCodeGrantRequest(
authorizationCodeAuthentication.getClientRegistration(),
authorizationCodeAuthentication.getAuthorizationExchange()));

} catch (OAuth2AuthorizationException ex) {
OAuth2Error oauth2Error = ex.getError();
throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
}
...
return authenticationResult;
}



@Override
public boolean supports(Class> authentication) {
return OAuth2LoginAuthenticationToken.class.isAssignableFrom(authentication);
}
}/<oauth2userrequest>/<oauth2authorizationcodegrantrequest>/<code>OAuth 2 Developers Guidedraft-ietf-oauth-v2