@[Toc]
一、OAuth 簡介
1、什麼是OAuth
開放授權(Open Authorization,OAuth)是一種資源提供商用于授權第三方應用代表資源所有者擷取有限通路權限的授權機制。由于在整個授權過程中,第三方應用都無須觸及使用者的密碼就可以取得部分資源的使用權限,是以OAuth是安全開放的。
例如,使用者想通過 QQ 登入csdn,這時csdn就是一個第三方應用,csdn要通路使用者的一些基本資訊就需要得到使用者的授權,如果使用者把自己的 QQ 使用者名和密碼告訴csdn,那麼csdn就能通路使用者的所有資料,井且隻有使用者修改密碼才能收回授權,這種授權方式安全隐患很大,如果使用 OAuth ,就能很好地解決這一問題。
OAuth第一個版本誕生于2007年12月,并于2010年4月正式被IETF作為标準釋出(編号RFC 5849)。由于OAuth1.0複雜的簽名邏輯以及單一的授權流程存在較大缺陷,随後标準工作組又推出了 OAuth2.0草案,并在2012年10月正式釋出其标準(編号RFC 6749)。OAuth2.0放棄了OAuth1.0中讓開發者感到痛苦的數字簽名和加密方案,使用已經得到驗證并廣泛使用的HTTPS技術作為安全保障手 段。OAuth2.0與OAuth1.0互不相容,由于OAuth1.0已經基本退出曆史舞台,是以下面提到的OAuth都是指OAuth2.0。
2、OAuth 角色
想要了解OAuth的運作流程,則必須要認識4個重要的角色。
- Resource Owner:資源所有者,通常指使用者,例如每一個QQ使用者。
- Resource Server:資源伺服器,指存放使用者受保護資源的伺服器,通常需要通過Access Token(通路令牌)才能進行通路。例如,存儲QQ使用者基本資訊的伺服器,充當的便是資源伺服器的 角色。
- Client:用戶端,指需要擷取使用者資源的第三方應用,如CSDN網站。
- Authorization Server:授權伺服器,用于驗證資源所有者,并在驗證成功之後向用戶端發放相關通路令牌。
3、OAuth 授權流程
這是 個大緻的流程,因為 OAuth2 中有 種不同的授權模式,每種授權模式的授權流程又會有差異,基本流程如下:
- 用戶端(第三方應用)向資源所有者請求授權。
- 服務端傳回一個授權許可憑證給用戶端。
- 用戶端拿着授權許可憑證去授權伺服器申請令牌。
- 授權伺服器驗證資訊無誤後,發放令牌給用戶端。
- 用戶端拿着令牌去資源伺服器通路資源。
- 資源伺服器驗證令牌無誤後開放資源。
4、OAuth授權模式
OAuth 協定的授權模式共分為4種。
4.1、授權碼
授權碼(authorization code)方式,指的是第三方應用先申請一個授權碼,然後再用該碼擷取令牌。這種方式是最常用的流程,安全性也最高,它适用于那些有後端的 Web 應用。授權碼通過前端傳送,令牌則是儲存在後端,而且所有與資源伺服器的通信都在後端完成。這樣的前後端分離,可以避免令牌洩漏。
- 第一步,A 網站提供一個連結,使用者點選後就會跳轉到 B 網站,授權使用者資料給 A 網站使用。下面就是 A 網站跳轉 B 網站的一個示意連結。
https://b.com/oauth/authorize?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=CALLBACK_URL&
scope=read
上面 URL 中,response_type參數表示要求傳回授權碼(code),client_id參數讓 B 知道是誰在請求,redirect_uri參數是 B 接受或拒絕請求後的跳轉網址,scope參數表示要求的授權範圍(這裡是隻讀)。
- 第二步,使用者跳轉後,B 網站會要求使用者登入,然後詢問是否同意給予 A 網站授權。使用者表示同意,這時 B 網站就會跳回redirect_uri參數指定的網址。跳轉時,會傳回一個授權碼,就像下面這樣。
https://a.com/callback?code=AUTHORIZATION_CODE
上面 URL 中,code參數就是授權碼。
- 第三步,A 網站拿到授權碼以後,就可以在後端,向 B 網站請求令牌。
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 參數和 client_secret 參數用來讓 B 确認 A 的身份(client_secret參數是保密的,是以隻能在後端發請求),grant_type參數的值是 AUTHORIZATION_CODE,表示采用的授權方式是授權碼,code參數是上一步拿到的授權碼,redirect_uri 參數是令牌頒發後的回調網址。
- 第四步,B 網站收到請求以後,就會頒發令牌。具體做法是向redirect_uri指定的網址,發送一段 JSON 資料。
{
"access_token":"ACCESS_TOKEN",
"token_type":"bearer",
"expires_in":2592000,
"refresh_token":"REFRESH_TOKEN",
"scope":"read",
"uid":100101,
"info":{...}
}
上面 JSON 資料中,access_token字段就是令牌,A 網站在後端拿到了。
4.2、隐藏式
有些 Web 應用是純前端應用,沒有後端。這時就不能用上面的方式了,必須将令牌儲存在前端。
RFC 6749 就規定了第二種方式,允許直接向前端頒發令牌。這種方式沒有授權碼這個中間步驟,是以稱為(授權碼)"隐藏式"(implicit)。- 第一步,A 網站提供一個連結,要求使用者跳轉到 B 網站,授權使用者資料給 A 網站使用。
https://b.com/oauth/authorize?
response_type=token&
client_id=CLIENT_ID&
redirect_uri=CALLBACK_URL&
scope=read
上面 URL 中,response_type參數為token,表示要求直接傳回令牌。
- 第二步,使用者跳轉到 B 網站,登入後同意給予 A 網站授權。這時,B 網站就會跳回redirect_uri參數指定的跳轉網址,并且把令牌作為 URL 參數,傳給 A 網站。
https://a.com/callback#token=ACCESS_TOKEN
上面 URL 中,token參數就是令牌,A 網站是以直接在前端拿到令牌。
注意,令牌的位置是 URL 錨點(fragment),而不是查詢字元串(querystring),這是因為 OAuth 2.0 允許跳轉網址是 HTTP 協定,是以存在"中間人攻擊"的風險,而浏覽器跳轉時,錨點不會發到伺服器,就減少了洩漏令牌的風險。
這種方式把令牌直接傳給前端,是很不安全的。是以,隻能用于一些安全要求不高的場景,并且令牌的有效期必須非常短,通常就是會話期間(session)有效,浏覽器關掉,令牌就失效了。
4.3、密碼式
如果你高度信任某個應用,RFC 6749 也允許使用者把使用者名和密碼,直接告訴該應用。該應用就使用你的密碼,申請令牌,這種方式稱為"密碼式"(password)。- 第一步,A 網站要求使用者提供 B 網站的使用者名和密碼。拿到以後,A 就直接向 B 請求令牌。
https://oauth.b.com/token?
grant_type=password&
username=USERNAME&
password=PASSWORD&
client_id=CLIENT_ID
上面 URL 中,grant_type參數是授權方式,這裡的password表示"密碼式",username和password是 B 的使用者名和密碼。
- 第二步,B 網站驗證身份通過後,直接給出令牌。注意,這時不需要跳轉,而是把令牌放在 JSON 資料裡面,作為 HTTP 回應,A 是以拿到令牌。
4.4、憑證式
最後一種方式是憑證式(client credentials),适用于沒有前端的指令行應用,即在指令行下請求令牌。- 第一步,A 應用在指令行向 B 送出請求。
https://oauth.b.com/token?
grant_type=client_credentials&
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET
上面 URL 中,grant_type參數等于client_credentials表示采用憑證式,client_id和client_secret用來讓 B 确認 A 的身份。
- 第二步,B 網站驗證通過以後,直接傳回令牌。
這種方式給出的令牌,是針對第三方應用的,而不是針對使用者的,即有可能多個使用者共享同一個令牌。
二、實踐
1、密碼模式
如果是自建單點服務,一般都會使用密碼模式。資源伺服器和授權伺服器
可以是同一台伺服器,也可以分開。這裡我們學習分布式的情況。
授權伺服器和資源伺服器分開,項目結構如下:
1.1、授權伺服器
授權伺服器的職責:
- 管理用戶端及其授權資訊
- 管理使用者及其授權資訊
- 管理Token的生成及其存儲
- 管理Token的校驗及校驗Key
1.1.1、依賴
<!--security-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--oauth2-->
<dependency>
<groupId>org.springframework.security.oauth</groupId>
<artifactId>spring-security-oauth2</artifactId>
<version>2.3.6.RELEASE</version>
</dependency>
1.1.2、授權伺服器配置
授權伺服器配置通過繼承AuthorizationServerConfigurerAdapter的配置類實作:
/**
* @Author 三分惡
* @Date 2020/5/20
* @Description 授權伺服器配置
*/
@Configuration
@EnableAuthorizationServer
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
@Autowired
private AuthenticationManager authenticationManager;//密碼模式需要注入認證管理器
@Autowired
public PasswordEncoder passwordEncoder;
//配置用戶端
@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients.inMemory()
.withClient("client-demo")
.secret(passwordEncoder.encode("123"))
.authorizedGrantTypes("password") //這裡配置為密碼模式
.scopes("read_scope");
}
@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints.authenticationManager(authenticationManager);//密碼模式必須添加authenticationManager
}
@Override
public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
security.allowFormAuthenticationForClients()
.checkTokenAccess("isAuthenticated()");
}
}
- 用戶端的注冊:這裡通過inMemory的方式在記憶體中注冊用戶端相關資訊;實際項目中可以通過一些管理接口及界面動态實作用戶端的注冊
- 校驗Token權限控制:資源伺服器如果需要調用授權伺服器的/oauth/check_token接口校驗token有效性,那麼需要配置checkTokenAccess("isAuthenticated()")
- authenticationManager配置:需要通過endpoints.authenticationManager(authenticationManager)将Security中的authenticationManager配置到Endpoints中,否則,在Spring Security中配置的權限控制将不會在進行OAuth2相關權限控制的校驗時生效。
1.1.3、Spring Security配置
通過Spring Security來完成使用者及密碼加解密等配置:
/**
* @Author 三分惡
* @Date 2020/5/20
* @Description SpringSecurity 配置
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("fighter")
.password(passwordEncoder().encode("123"))
.authorities(new ArrayList<>(0));
}
@Override
protected void configure(HttpSecurity http) throws Exception {
//所有請求必須認證
http.authorizeRequests().anyRequest().authenticated();
}
}
1.2、資源伺服器
資源伺服器的職責:
- token的校驗
- 給與資源
1.2.1、資源伺服器配置
資源伺服器依賴一樣,而配置則通過繼承自ResourceServerConfigurerAdapter的配置類來實作:
/**
* @Author 三分惡
* @Date 2020/5/20
* @Description
*/
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Bean
public RemoteTokenServices remoteTokenServices() {
final RemoteTokenServices tokenServices = new RemoteTokenServices();
tokenServices.setClientId("client-demo");
tokenServices.setClientSecret("123");
tokenServices.setCheckTokenEndpointUrl("http://localhost:8090/oauth/check_token");
return tokenServices;
}
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.stateless(true);
}
@Override
public void configure(HttpSecurity http) throws Exception {
//session建立政策
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
//所有請求需要認證
http.authorizeRequests().anyRequest().authenticated();
}
}
主要進行了如下配置:
- TokenService配置:在不采用JWT的情況下,需要配置RemoteTokenServices來充當tokenServices,它主要完成Token的校驗等工作。是以需要指定校驗Token的授權伺服器接口位址
- 同時,由于在授權伺服器中配置了/oauth/check_token需要用戶端登入後才能通路,是以也需要配置用戶端編号及Secret;在校驗之前先進行登入
- 通過ResourceServerSecurityConfigurer來配置需要通路的資源編号及使用的TokenServices
1.2.2、資源服務接口
接口比較簡單:
/**
* @Author 三分惡
* @Date 2020/5/20
* @Description
*/
@RestController
public class ResourceController {
@GetMapping("/user/{username}")
public String user(@PathVariable String username){
return "Hello !"+username;
}
}
1.3、測試
授權伺服器使用8090端口啟動,資源伺服器使用預設端口。
1.3.1、擷取token
通路/oauth/token端點,擷取token:
- url: http://localhost :8090/oauth/token?username=fighter&password=123&scope=read_scope&grant_type=password
- 請求頭:
- 傳回的token
1.3.2、使用擷取到的token通路資源接口
相當于在Headers中添加 Authorization:Bearer 4a3c351d-770d-42aa-af39-3f54b50152e9。
OK,可以看到資源正确傳回。
這裡僅僅是密碼模式的精簡化配置,在實際項目中,某些部分如:
- 資源服務通路授權服務去校驗token這部分可能會換成Jwt、Redis等tokenStore實作,
- 授權伺服器中的使用者資訊與用戶端資訊生産環境從資料庫中讀取,對應Spring Security的UserDetailsService實作類或使用者資訊的Provider
2、授權碼模式
很多網站登入時,允許使用第三方網站的身份,這稱為"第三方登入"。所謂第三方登入,實質就是 OAuth 授權。
例如使用者想要登入 A 網站,A 網站讓使用者提供第三方網站的資料,證明自己的身份。擷取第三方網站的身份資料,就需要 OAuth 授權。
以A網站使用GitHub第三方登入為例,流程示意如下:
接下來,簡單地實作GitHub登入流程。
2.1、應用注冊
在使用之前需要先注冊一個應用,讓GitHub可以識别。
- 通路位址: https://github.com/settings/applications/new ,填寫系統資料庫
應用的名稱随便填,首頁 URL 填寫
:8080,回調位址填寫
:8080/oauth/redirect。
- 送出表單以後,GitHub 應該會傳回用戶端 ID(client ID)和用戶端密鑰(client secret),這就是應用的身份識别碼
2.2、具體代碼
- 隻需要引入web依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
- GitHub相關配置
github.client.clientId=29d127aa0753c12263d7
github.client.clientSecret=f3cb9222961efe4c2adccd6d3e0df706972fa5eb
github.client.authorizeUrl=https://github.com/login/oauth/authorize
github.client.accessTokenUrl=https://github.com/login/oauth/access_token
github.client.redirectUrl=http://localhost:8080/oauth/redirect
github.client.userInfoUrl=https://api.github.com/user
- 對應的配置類
@Component
@ConfigurationProperties(prefix = "github.client")
public class GithubProperties {
private String clientId;
private String clientSecret;
private String authorizeUrl;
private String redirectUrl;
private String accessTokenUrl;
private String userInfoUrl;
//省略getter、setter
}
- index.html:首頁比較簡單,一個連結向後端發起登入請求
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>網站首頁</title>
</head>
<body>
<div style="text-align: center">
<a href="http://localhost:8080/authorize">Login in with GitHub</a>
</div>
</body>
</html>
- GithubLoginController.java:
* 使用RestTemplate發送http請求
* 使用Jackson解析傳回的json,不用引入更多依賴
* 快捷起見,發送http請求的方法直接寫在控制器中,實際上應該将工具方法分離出去
* 同樣是快捷起見,傳回的使用者資訊沒有做任何解析
@Controller
public class GithubLoginController {
@Autowired
GithubProperties githubProperties;
/**
* 登入接口,重定向至github
*
* @return 跳轉url
*/
@GetMapping("/authorize")
public String authorize() {
String url =githubProperties.getAuthorizeUrl() +
"?client_id=" + githubProperties.getClientId() +
"&redirect_uri=" + githubProperties.getRedirectUrl();
return "redirect:" + url;
}
/**
* 回調接口,使用者同意授權後,GitHub會将授權碼傳遞給此接口
* @param code GitHub重定向時附加的授權碼,隻能用一次
* @return
*/
@GetMapping("/oauth/redirect")
@ResponseBody
public String redirect(@RequestParam("code") String code) throws JsonProcessingException {
System.out.println("code:"+code);
// 使用code擷取token
String accessToken = this.getAccessToken(code);
// 使用token擷取userInfo
String userInfo = this.getUserInfo(accessToken);
return userInfo;
}
/**
* 使用授權碼擷取token
* @param code
* @return
*/
private String getAccessToken(String code) throws JsonProcessingException {
String url = githubProperties.getAccessTokenUrl() +
"?client_id=" + githubProperties.getClientId() +
"&client_secret=" + githubProperties.getClientSecret() +
"&code=" + code +
"&grant_type=authorization_code";
// 建構請求頭
HttpHeaders requestHeaders = new HttpHeaders();
// 指定響應傳回json格式
requestHeaders.add("accept", "application/json");
// 建構請求實體
HttpEntity<String> requestEntity = new HttpEntity<>(requestHeaders);
RestTemplate restTemplate = new RestTemplate();
// post 請求方式
ResponseEntity<String> response = restTemplate.postForEntity(url, requestEntity, String.class);
String responseStr = response.getBody();
// 解析響應json字元串
ObjectMapper objectMapper = new ObjectMapper();
JsonNode jsonNode = objectMapper.readTree(responseStr);
String accessToken = jsonNode.get("access_token").asText();
System.out.println("accessToken:"+accessToken);
return accessToken;
}
/**
*
* @param accessToken 使用token擷取userInfo
* @return
*/
private String getUserInfo(String accessToken) {
String url = githubProperties.getUserInfoUrl();
// 建構請求頭
HttpHeaders requestHeaders = new HttpHeaders();
// 指定響應傳回json格式
requestHeaders.add("accept", "application/json");
// AccessToken放在請求頭中
requestHeaders.add("Authorization", "token " + accessToken);
// 建構請求實體
HttpEntity<String> requestEntity = new HttpEntity<>(requestHeaders);
RestTemplate restTemplate = new RestTemplate();
// get請求方式
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, requestEntity, String.class);
String userInfo = response.getBody();
System.out.println("userInfo:"+userInfo);
return userInfo;
}
}
2.3、測試
- 通路localhost:8080,點選連結,重定向至GitHub
- 在GitHub中輸入賬号密碼,登入
- 登入成功後,GitHub 就會跳轉到redirect_uri指定的跳轉網址,并且帶上授權碼
http://localhost:8080/oauth/redirect?code=d45683eded3ac7d4e6ed
OK,使用者資訊也一并傳回了。
本文為學習筆記類部落格,學習資料見參考!
參考:【1】:《SpringSecurity 實戰》
【2】:《SpringBoot Vue全棧開發實戰》
【3】:[了解OAuth 2.0](http://www.ruanyifeng.com/blog/2014/05/oauth_2_0.html)
【4】:[OAuth 2.0 的一個簡單解釋](http://www.ruanyifeng.com/blog/2019/04/oauth_design.html)
【5】:[OAuth 2.0 的四種方式](http://www.ruanyifeng.com/blog/2019/04/oauth-grant-types.html)
【6】:[這個案例寫出來,還怕跟面試官扯不明白 OAuth2 登入流程?](https://mp.weixin.qq.com/s/GXMQI59U6uzmS-C0WQ5iUw)
【7】:[做微服務繞不過的 OAuth2,松哥也來和大家扯一扯 ](https://mp.weixin.qq.com/s/AELXf1nmpWbYE3NINpLDRg)
【8】:[GitHub OAuth 第三方登入示例教程](http://www.ruanyifeng.com/blog/2019/04/github-oauth.html)
【9】:[OAuth 2.0 認證的原理與實踐](https://waylau.com/principle-and-practice-of-oauth2/?spm=a2c4e.10696291.0.0.355619a46EKWmX)
【10】:[Spring Security OAuth2 Demo —— 密碼模式(Password)](https://www.cnblogs.com/hellxz/p/12041495.html)
【11】:[Spring Security OAuth專題學習-密碼模式及用戶端模式執行個體](https://blog.csdn.net/icarusliu/article/details/87915600)
【12】:[Spring Boot and OAuth2](https://spring.io/guides/tutorials/spring-boot-oauth2/)
【13】:[Spring Boot+OAuth2使用GitHub登入自己的服務](https://blog.csdn.net/Lee_01/article/details/103691864)