SpringSecurity Oauth2.0
1.使用者認證分析
上面流程圖描述了使用者要操作的各個微服務,使用者檢視個人資訊需要訪問客戶微服務,下單需要訪問訂單微服務,秒殺搶購商品需要訪問秒殺微服務。每個服務都需要認證使用者的身份,身份認證成功後,需要識別使用者的角色然後授權訪問對應的功能。
1.1 認證與授權
身份認證
使用者身份認證即使用者去訪問系統資源時系統要求驗證使用者的身份資訊,身份合法方可繼續訪問。常見的使用者身份認證表現形式有:使用者名稱密碼登入,指紋打卡等方式。說通俗點,就相當於校驗使用者賬號密碼是否正確。
使用者授權
使用者認證通過後去訪問系統的資源,系統會判斷使用者是否擁有訪問資源的許可權,只允許訪問有許可權的系統資源,沒有許可權的資源將無法訪問,這個過程叫使用者授權。
1.2 單點登入
使用者訪問的專案中,至少有3個微服務需要識別使用者身份,如果使用者訪問每個微服務都登入一次就太麻煩了,為了提高使用者的體驗,我們需要實現讓使用者在一個系統中登入,其他任意受信任的系統都可以訪問,這個功能就叫單點登入。
單點登入(Single Sign On),簡稱為 SSO,是目前比較流行的企業業務整合的解決方案之一。 SSO的定義是在多個應用系統中,使用者只需要登入一次就可以訪問所有相互信任的應用系統
1.3 第三方賬號登入
1.3.1 第三方登入介紹
隨著國內及國外巨頭們的平臺開放戰略以及移動網際網路的發展,第三方登入已經不是一個陌生的產品設計概念了。 所謂的第三方登入,是說基於使用者在第三方平臺上已有的賬號和密碼來快速完成己方應用的登入或者註冊的功能。而這裡的第三方平臺,一般是已經擁有大量使用者的平臺,國外的比如Facebook,Twitter等,國內的比如微博、微信、QQ等。
1.3.2 第三方登入優點
1.相比於本地註冊,第三方登入一般來說比較方便、快捷,能夠顯著降低使用者的註冊和登入成本,方便使用者實現快捷登入或註冊。
2.不用費盡心思地應付本地註冊對賬戶名和密碼的各種限制,如果不考慮暱稱的重複性要求,幾乎可以直接一個賬號走遍天下,再也不用在大腦或者什麼地方記住N多不同的網站或App的賬號和密碼,整個世界一下子清靜了。
3.在第一次繫結成功之後,之後使用者便可以實現一鍵登入,使得後續的登入操作比起應用內的登入來容易了很多。
4.對於某些喜歡社交,並希望將更多自己的生活內容展示給朋友的人來說,第三方登入可以實現把使用者在應用內的活動同步到第三方平臺上,省去了使用者手動釋出動態的麻煩。但對於某些比較注重個人隱私的使用者來說,則會有一些擔憂,所以龍哥所說的這個優點是有前提的。
5.因為降低了使用者的註冊或登入成本,從而減少由於本地註冊的繁瑣性而帶來的隱形使用者流失,最終提高註冊轉化率。
6.對於某些應用來說,使用第三方登入完全可以滿足自己的需要,因此不必要設計和開發一套自己的賬戶體系。
7.通過授權,可以通過在第三方平臺上分享使用者在應用內的活動在第三方平臺上宣傳自己,從而增加產品知名度。
8.通過授權,可以獲得該使用者在第三方平臺上的好友或粉絲等社交資訊,從而後續可以針對使用者的社交關係網進行有目的性的營銷宣傳,為產品的市場推廣提供另一種渠道。
1.3.3 第三方認證
當需要訪問第三方系統的資源時需要首先通過第三方系統的認證(例如:微信認證),由第三方系統對使用者認證通過,並授權資源的訪問許可權。
2 認證技術方案
2.1 單點登入技術方案
分散式系統要實現單點登入,通常將認證系統獨立抽取出來,並且將使用者身份資訊儲存在單獨的儲存介質,比如: MySQL、Redis,考慮效能要求,通常儲存在Redis中,如下圖:
單點登入的特點是:
1、認證系統為獨立的系統。
2、各子系統通過Http或其它協議與認證系統通訊,完成使用者認證。
3、使用者身份資訊儲存在Redis叢集。
Java中有很多使用者認證的框架都可以實現單點登入:
1、Apache Shiro. 2、CAS 3、Spring security CAS
2.2 Oauth2認證
OAuth(開放授權)是一個開放標準,允許使用者授權第三方移動應用訪問他們儲存在另外的服務提供者上的資訊,而不需要將使用者名稱和密碼提供給第三方移動應用或分享他們資料的所有內容,OAuth2.0是OAuth協議的延續版本。
2.2.1 Oauth2認證流程
第三方認證技術方案最主要是解決認證協議的通用標準 問題,因為要實現 跨系統認證,各系統之間要遵循一定的介面協議。
1.客戶端請求第三方授權
使用者進入黑馬程式的登入頁面,點選微信的圖示以微信賬號登入系統,使用者是自己在微信裡資訊的資源擁有者。
點選“用QQ賬號登入”出現一個二維碼,此時使用者掃描二維碼,開始給黑馬程式設計師授權。
2.資源擁有者同意給客戶端授權
資源擁有者掃描二維碼錶示資源擁有者同意給客戶端授權,微信會對資源擁有者的身份進行驗證, 驗證通過後,QQ會詢問使用者是否給授權黑馬程式設計師訪問自己的QQ資料,使用者點選“確認登入”表示同意授權,QQ認證伺服器會頒發一個授權碼,並重定向到黑馬程式設計師的網站。
3.客戶端獲取到授權碼,請求認證伺服器申請令牌 此過程使用者看不到,客戶端應用程式請求認證伺服器,請求攜帶授權碼。
4.認證伺服器向客戶端響應令牌 認證伺服器驗證了客戶端請求的授權碼,如果合法則給客戶端頒發令牌,令牌是客戶端訪問資源的通行證。 此互動過程使用者看不到,當客戶端拿到令牌後,使用者在黑馬程式設計師看到已經登入成功。
5.客戶端請求資源伺服器的資源 客戶端攜帶令牌訪問資源伺服器的資源。 黑馬程式設計師網站攜帶令牌請求訪問微信伺服器獲取使用者的基本資訊。
6.資源伺服器返回受保護資源 資源伺服器校驗令牌的合法性,如果合法則向用戶響應資源資訊內容。 注意:資源伺服器和認證伺服器可以是一個服務也可以分開的服務,如果是分開的服務,資源伺服器通常要請求認證伺服器來校驗令牌的合法性。
Oauth2包括以下角色:
1、客戶端 本身不儲存資源,需要通過資源擁有者的授權去請求資源伺服器的資源
2、資源擁有者 通常為使用者,也可以是應用程式,即該資源的擁有者。
3、授權伺服器(也稱認證伺服器) 用來對資源擁有的身份進行認證、對訪問資源進行授權。客戶端要想訪問資源需要通過認證伺服器由資源擁有者授 權後方可訪問。
4、資源伺服器 儲存資源的伺服器
2.3 Spring security Oauth2認證解決方案
本專案採用 Spring security + Oauth2完成使用者認證及使用者授權,Spring security 是一個強大的和高度可定製的身份驗證和訪問控制框架,Spring security 框架集成了Oauth2協議,下圖是專案認證架構圖:
1、使用者請求認證服務完成認證。
2、認證服務下發使用者身份令牌,擁有身份令牌表示身份合法。
3、使用者攜帶令牌請求資源服務,請求資源服務必先經過閘道器。
4、閘道器校驗使用者身份令牌的合法,不合法表示使用者沒有登入,如果合法則放行繼續訪問。
5、資源服務獲取令牌,根據令牌完成授權。
6、資源服務完成授權則響應資源資訊。
3.3 Oauth2授權模式
3.3.1 Oauth2授權模式
Oauth2有以下授權模式
1.授權碼模式(Authorization Code) 2.隱式授權模式(Implicit) 3.密碼模式(Resource Owner Password Credentials) 4.客戶端模式(Client Credentials)
其中授權碼模式和密碼模式應用較多,本小節介紹授權碼模式。
3.3.2 授權碼授權實現
1、客戶端請求第三方授權
2、使用者(資源擁有者)同意給客戶端授權(頒發授權碼)
3、客戶端獲取到授權碼,請求認證伺服器申請令牌
4、認證伺服器向客戶端響應令牌
5、客戶端請求資源伺服器的資源,資源服務校驗令牌合法性,完成授權
6、資源伺服器返回受保護資源
(1)申請授權碼
http://localhost:9001/oauth/authorize?client_id=changgou&response_type=code&scop=app&redirect_uri=http://localhost
client_id:客戶端id,和授權配置類中設定的客戶端id一致。
response_type:授權碼模式固定為code
scop:客戶端範圍,和授權配置類中設定的scop一致。
redirect_uri:跳轉uri,當授權碼申請成功後會跳轉到此地址,並在後邊帶上code引數(授權碼)
首先跳轉到登入頁面:
輸入賬號和密碼,點選Login。 Spring Security接收到請求會呼叫UserDetailsService介面的loadUserByUsername方法查詢使用者正確的密碼。 當前匯入的基礎工程中客戶端ID為changgou,祕鑰也為changgou即可認證通過。
接下來進入授權頁面:
點選Authorize,接下來返回授權碼: 認證服務攜帶授權碼跳轉redirect_uri,code=k45iLY就是返回的授權碼
(2)申請令牌
拿到授權碼後,申請令牌。 Post請求:http://localhost:9001/oauth/token 引數如下:
grant_type:授權型別,填寫authorization_code,表示授權碼模式
code:授權碼,就是剛剛獲取的授權碼,注意:授權碼只使用一次就無效了,需要重新申請。
redirect_uri:申請授權碼時的跳轉url,一定和申請授權碼時用的redirect_uri一致。
此連結需要使用 httpBasic認證。 什麼是httpBasic認證?http協議定義的一種認證方式,將客戶端id和客戶端密碼按照“客戶端ID:客戶端密碼”的格式拼接,並用base64編碼,放在header中請求服務端,
一個例子: Authorization:Basic WGNXZWJBcHA6WGNXZWJBcHA=WGNXZWJBcHA6WGNXZWJBcHA= 是使用者名稱:密碼的base64編碼。 認證失敗服務端返回 401 Unauthorized。
以上測試使用postman完成:
http basic認證:
客戶端Id和客戶端密碼會匹配資料庫oauth_client_details表中的客戶端id及客戶端密碼。
點擊發送: 申請令牌成功
access_token:訪問令牌,攜帶此令牌訪問資源
token_type:有MAC Token與Bearer Token兩種型別,兩種的校驗演算法不同,RFC 6750建議Oauth2採用 Bearer Token(http://www.rfcreader.com/#rfc6750)。
refresh_token:重新整理令牌,使用此令牌可以延長訪問令牌的過期時間。
expires_in:過期時間,單位為秒。
scope:範圍,與定義的客戶端範圍一致。
jti:當前token的唯一標識
(3)令牌校驗
Spring Security Oauth2提供校驗令牌的端點,如下:
Get:http://localhost:9001/oauth/check_token?token=[access_token]
引數:
token:令牌
使用postman測試如下:
如果令牌校驗失敗,會出現如下結果:
(4)重新整理令牌
重新整理令牌是當令牌快過期時重新生成一個令牌,它於授權碼授權和密碼授權生成令牌不同,重新整理令牌不需要授權碼 也不需要賬號和密碼,只需要一個重新整理令牌、客戶端id和客戶端密碼。
3.3.3 密碼授權實現
(1)認證
密碼模式(Resource Owner Password Credentials)與授權碼模式的區別是申請令牌不再使用授權碼,而是直接 通過使用者名稱和密碼即可申請令牌。
測試如下:
Post請求:http://localhost:9001/oauth/token
引數:
grant_type:密碼模式授權填寫password
username:賬號
password:密碼
並且此連結需要使用 http Basic認證。
(2)校驗令牌
Spring Security Oauth2提供校驗令牌的端點,如下:
Get:http://localhost:9001/oauth/check_token?token=
引數:
token:令牌
使用postman測試如下:
返回結果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"companyId": null,
"userpic": null,
"scope": [
"app"
],
"name": null,
"utype": null,
"active": true,
"id": null,
"exp": 1990221534,
"jti": "5b96666e-436b-4301-91b5-d89f9bbe6edb",
"client_id": "changgou",
"username": "szitheima"
}
exp:過期時間,long型別,距離1970年的秒數(new Date().getTime()可得到當前時間距離1970年的毫秒數)。
user_name: 使用者名稱
client_id:客戶端Id,在oauth_client_details中配置
scope:客戶端範圍,在oauth_client_details表中配置
jti:與令牌對應的唯一標識 companyId、userpic、name、utype、
id:這些欄位是本認證服務在Spring Security基礎上擴充套件的使用者身份資訊
(3)重新整理令牌
重新整理令牌是當令牌快過期時重新生成一個令牌,它於授權碼授權和密碼授權生成令牌不同,重新整理令牌不需要授權碼 也不需要賬號和密碼,只需要一個重新整理令牌、客戶端id和客戶端密碼。
測試如下: Post:http://localhost:9001/oauth/token
引數:
grant_type: 固定為 refresh_token
refresh_token:重新整理令牌(注意不是access_token,而是refresh_token)
重新整理令牌成功,會重新生成新的訪問令牌和重新整理令牌,令牌的有效期也比舊令牌長。
重新整理令牌通常是在令牌快過期時進行重新整理 。
4 資源服務授權
4.1 資源服務授權流程
(1)傳統授權流程
資源伺服器授權流程如上圖,客戶端先去授權伺服器申請令牌,申請令牌後,攜帶令牌訪問資源伺服器,資源伺服器訪問授權服務校驗令牌的合法性,授權服務會返回校驗結果,如果校驗成功會返回使用者資訊給資源伺服器,資源伺服器如果接收到的校驗結果通過了,則返回資源給客戶端。
傳統授權方法的問題是使用者每次請求資源服務,資源服務都需要攜帶令牌訪問認證服務去校驗令牌的合法性,並根 據令牌獲取使用者的相關資訊,效能低下。
(2)公鑰私鑰授權流程
傳統的授權模式效能低下,每次都需要請求授權服務校驗令牌合法性,我們可以利用公鑰私鑰完成對令牌的加密,如果加密解密成功,則表示令牌合法,如果加密解密失敗,則表示令牌無效不合法,合法則允許訪問資源伺服器的資源,解密失敗,則不允許訪問資源伺服器資源。
上圖的業務流程如下:
1、客戶端請求認證服務申請令牌
2、認證服務生成令牌認證服務採用非對稱加密演算法,使用私鑰生成令牌。
3、客戶端攜帶令牌訪問資源服務客戶端在Http header 中新增: Authorization:Bearer 令牌。
4、資源服務請求認證服務校驗令牌的有效性資源服務接收到令牌,使用公鑰校驗令牌的合法性。
5、令牌有效,資源服務向客戶端響應資源資訊
5 認證開發
5.1 需求分析
使用者登入的流程圖如下:
執行流程:
1、使用者登入,請求認證服務
2、認證服務認證通過,生成jwt令牌,將jwt令牌及相關資訊寫入cookie
3、使用者訪問資源頁面,帶著cookie到閘道器
4、閘道器從cookie獲取token,如果存在token,則校驗token合法性,如果不合法則拒絕訪問,否則放行
5、使用者退出,請求認證服務,刪除cookie中的token
5.2 認證服務
5.2.1 認證需求分析
認證服務需要實現的功能如下:
1、登入介面
前端post提交賬號、密碼等,使用者身份校驗通過,生成令牌,並將令牌寫入cookie。
2、退出介面 校驗當前使用者的身份為合法並且為已登入狀態。 將令牌從cookie中刪除。
5.2.2 工具封裝
在changgou-user-oauth工程中新增如下工具物件,方便操作令牌資訊。
建立com.changgou.oauth.util.AuthToken類,儲存使用者令牌資料,程式碼如下:
1
2
3
4
5
6
7
8
9
10
11
public class AuthToken implements Serializable{
//令牌資訊
String accessToken;
//重新整理token(refresh_token)
String refreshToken;
//jwt短令牌
String jti;
//...get...set
}
建立com.changgou.oauth.util.CookieUtil類,操作Cookie,程式碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class CookieUtil {
/**
* 設定cookie
*
* @param response
* @param name cookie名字
* @param value cookie值
* @param maxAge cookie生命週期 以秒為單位
*/
public static void addCookie(HttpServletResponse response, String domain, String path, String name,
String value, int maxAge, boolean httpOnly) {
Cookie cookie = new Cookie(name, value);
cookie.setDomain(domain);
cookie.setPath(path);
cookie.setMaxAge(maxAge);
cookie.setHttpOnly(httpOnly);
response.addCookie(cookie);
}
/**
* 根據cookie名稱讀取cookie
* @param request
* @return map<cookieName,cookieValue>
*/
public static Map<String,String> readCookie(HttpServletRequest request, String ... cookieNames) {
Map<String,String> cookieMap = new HashMap<String,String>();
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
String cookieName = cookie.getName();
String cookieValue = cookie.getValue();
for(int i=0;i<cookieNames.length;i++){
if(cookieNames[i].equals(cookieName)){
cookieMap.put(cookieName,cookieValue);
}
}
}
}
return cookieMap;
}
}
建立com.changgou.oauth.util.UserJwt類,封裝SpringSecurity中User資訊以及使用者自身基本資訊,程式碼如下:
1
2
3
4
5
6
7
8
9
10
public class UserJwt extends User {
private String id; //使用者ID
private String name; //使用者名稱字
public UserJwt(String username, String password, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
}
//...get...set
}
5.2.3 業務層
如上圖,我們現在實現一個認證流程,使用者從頁面輸入賬號密碼,到認證服務的Controller層,Controller層呼叫Service層,Service層呼叫OAuth2.0的認證地址,進行密碼授權認證操作,如果賬號密碼正確了,就返回令牌資訊給Service層,Service將令牌資訊給Controller層,Controller層將資料存入到Cookie中,再響應使用者。
建立com.changgou.oauth.service.AuthService介面,並新增授權認證方法:
1
2
3
4
5
6
7
public interface AuthService {
/***
* 授權認證方法
*/
AuthToken login(String username, String password, String clientId, String clientSecret);
}
建立com.changgou.oauth.service.impl.AuthServiceImpl實現類,實現獲取令牌資料,這裡認證獲取令牌採用的是密碼授權模式,用的是RestTemplate向OAuth服務發起認證請求,程式碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private LoadBalancerClient loadBalancerClient;
@Autowired
private RestTemplate restTemplate;
/***
* 授權認證方法
* @param username
* @param password
* @param clientId
* @param clientSecret
* @return
*/
@Override
public AuthToken login(String username, String password, String clientId, String clientSecret) {
//申請令牌
AuthToken authToken = applyToken(username,password,clientId, clientSecret);
if(authToken == null){
throw new RuntimeException("申請令牌失敗");
}
return authToken;
}
/****
* 認證方法
* @param username:使用者登入名字
* @param password:使用者密碼
* @param clientId:配置檔案中的客戶端ID
* @param clientSecret:配置檔案中的祕鑰
* @return
*/
private AuthToken applyToken(String username, String password, String clientId, String clientSecret) {
//選中認證服務的地址
ServiceInstance serviceInstance = loadBalancerClient.choose("user-auth");
if (serviceInstance == null) {
throw new RuntimeException("找不到對應的服務");
}
//獲取令牌的url
String path = serviceInstance.getUri().toString() + "/oauth/token";
//定義body
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
//授權方式
formData.add("grant_type", "password");
//賬號
formData.add("username", username);
//密碼
formData.add("password", password);
//定義頭
MultiValueMap<String, String> header = new LinkedMultiValueMap<>();
header.add("Authorization", httpbasic(clientId, clientSecret));
//指定 restTemplate當遇到400或401響應時候也不要丟擲異常,也要正常返回值
restTemplate.setErrorHandler(new DefaultResponseErrorHandler() {
@Override
public void handleError(ClientHttpResponse response) throws IOException {
//當響應的值為400或401時候也要正常響應,不要丟擲異常
if (response.getRawStatusCode() != 400 && response.getRawStatusCode() != 401) {
super.handleError(response);
}
}
});
Map map = null;
try {
//http請求spring security的申請令牌介面
ResponseEntity<Map> mapResponseEntity = restTemplate.exchange(path, HttpMethod.POST,new HttpEntity<MultiValueMap<String, String>>(formData, header), Map.class);
//獲取響應資料
map = mapResponseEntity.getBody();
} catch (RestClientException e) {
throw new RuntimeException(e);
}
if(map == null || map.get("access_token") == null || map.get("refresh_token") == null || map.get("jti") == null) {
//jti是jwt令牌的唯一標識作為使用者身份令牌
throw new RuntimeException("建立令牌失敗!");
}
//將響應資料封裝成AuthToken物件
AuthToken authToken = new AuthToken();
//訪問令牌(jwt)
String accessToken = (String) map.get("access_token");
//重新整理令牌(jwt)
String refreshToken = (String) map.get("refresh_token");
//jti,作為使用者的身份標識
String jwtToken= (String) map.get("jti");
authToken.setJti(jwtToken);
authToken.setAccessToken(accessToken);
authToken.setRefreshToken(refreshToken);
return authToken;
}
/***
* base64編碼
* @param clientId
* @param clientSecret
* @return
*/
private String httpbasic(String clientId,String clientSecret){
//將客戶端id和客戶端密碼拼接,按“客戶端id:客戶端密碼”
String string = clientId+":"+clientSecret;
//進行base64編碼
byte[] encode = Base64Utils.encode(string.getBytes());
return "Basic "+new String(encode);
}
}
5.2.4 控制層
建立控制層com.changgou.oauth.controller.AuthController,編寫使用者登入授權方法,程式碼如下
@RestController
@RequestMapping(value = "/user")
public class AuthController {
//客戶端ID
@Value("${auth.clientId}")
private String clientId;
//祕鑰
@Value("${auth.clientSecret}")
private String clientSecret;
//Cookie儲存的域名
@Value("${auth.cookieDomain}")
private String cookieDomain;
//Cookie生命週期
@Value("${auth.cookieMaxAge}")
private int cookieMaxAge;
@Autowired
AuthService authService;
@PostMapping("/login")
public Result login(String username, String password) {
if(StringUtils.isEmpty(username)){
throw new RuntimeException("使用者名稱不允許為空");
}
if(StringUtils.isEmpty(password)){
throw new RuntimeException("密碼不允許為空");
}
//申請令牌
AuthToken authToken = authService.login(username,password,clientId,clientSecret);
//使用者身份令牌
String access_token = authToken.getAccessToken();
//將令牌儲存到cookie
saveCookie(access_token);
return new Result(true, StatusCode.OK,"登入成功!");
}
/***
* 將令牌儲存到cookie
* @param token
*/
private void saveCookie(String token){
HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
CookieUtil.addCookie(response,cookieDomain,"/","Authorization",token,cookieMaxAge,false);
}
}