前言
上篇文章簡單的講解了Hydra的基本用法,本篇文章将深化對單點登入在分布式微服務體系的介紹以及詳細用法。
應用場景
想象這樣一個場景,你有好幾個應用,每個應用對應一個微服務Server,那麼如果你需要給一個使用者配置設定一個權限去同時通路這些服務時,你總不能每個服務都重新登入一次吧?這樣使用者體驗及其不好,是以單點登入的概念就應運而生了。
單點登入:所謂的單點登入,說直白了就是隻需要登入一次,就可以通路多個伺服器。如下圖所示,使用者僅需要在AuthServer這個服務登入一次,就可以用擷取到的idToken對資源伺服器進行多服務通路而不需要重新登入。
![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAzNfRHLGZkRGZkRfJ3bs92YsYTMfVmepNHL9kEVONTRE9keBRVT3V1MMBjVtJWd0ckW65UbM5WOHJWa5kHT20ESjBjUIF2X0hXZ0xCMx81dvRWYoNHLrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdssmch1mclRXY39CXldWYtlWPzNXZj9mcw1ycz9WL49zZuBnLygTN1QDN0ATMzADOwEjMwIzLc52YucWbp5GZzNmLn9Gbi1yZtl2Lc9CX6MHc0RHaiojIsJye.png)
這樣一種概念,開源架構裡有一個不錯的東西,叫做 Keycloak,官方位址:Keycloak
用法參考位址為: Keycloak文章參考
如果你覺得Keycloak更好,下面的内容就可以不繼續看了。
編寫一個AuthServer微服務統一進行授權
1.建立一個新應用
既然正式開始,就不能随随便便了,重新建立hydra服務(具體怎麼建立上一篇文章講的很清楚了)
docker run -d \
--restart=always \
--name ory-hydra-tommy--hydra \
--network hydraguide \
-p 4444:4444 \
-p 4445:4445 \
-e SECRETS_SYSTEM=$SECRETS_SYSTEM \
-e DSN=$DSN \
-e URLS_SELF_ISSUER=https://hydra-client.tommy.cn/ \
-e URLS_CONSENT=https://oa.tommy.cn/consent \
-e URLS_LOGIN=https://oa.tommy.cn/login \
-e URLS_LOGOUT=https://oa.tommy.cn/logout \
-e URLS_POST_LOGOUT_REDIRECT=https://oa.tommy.cn/logout/callback \
-e TTL_ID_TOKEN=10000h \
oryd/hydra:v1.10.2 serve all
# 說明:
URLS_SELF_ISSUER 是你的伺服器位址
URLS_CONSENT 是授權的位址
URLS_LOGIN 是使用者登入位址
URLS_LOGOUT 是你登出位址
URLS_POST_LOGOUT_REDIRECT 是你登出成功後跳轉到的位址
TTL_ID_TOKEN id_token 過期時間的設定機關 h m s
其中:https://hydra-client.tommy.cn 對應的的是 http:localhost:4444 伺服器中你自行映射4444
建立一個新應用(具體怎麼建立上一篇文章講的很清楚了)
https://oa.tommy.cn 是你的服務的項目位址,用vue或者React編寫的前端項目
https://oa.tommy.cn/logout 是登出之後跳轉到的位址
https://oa.tommy.cn/callback 是你調用通用退出的接口回調的位址
{
"client_id":"tommy-oa",
"client_name":"tommy-oa",
"client_secret":"99e7315d-0a85-4eef-a5b3-cef6bf637351",
"client_secret_expires_at":0,
"redirect_uris": [
"https://oa.tommy.cn"
],
"post_logout_redirect_uris": [
"https://oa.tommy.cn/logout"
],
"created_at":"2020-01-06T15:09:15.946Z",
"frontchannel_logout_session_required":true,
"frontchannel_logout_uri": "https://oa.tommy.cn/callback",
"scope":"openid offline offline_access",
"token_endpoint_auth_method":"client_secret_post",
"updated_at":"2020-01-07T15:09:15.946Z",
"userinfo_signed_response_alg":"none",
"grant_types": [
"authorization_code","refresh_token","implicit","client_credentials"
],
"response_types": [
"code","id_token","token"
]
}
2.登入詳細流程圖
例如:
使用者系統的位址為:https://oa.tommy.cn
授權的前端系統位址為:https://sso.tommy.cn
Ory Hydra 詳解之進階
從第二張圖就可以看出來,第二次通路 https://oa.tommy.cn,的時候你隻會看到浏覽器url變化,但是不需要輸入帳号密碼,也無需通路到SSO前端,最終是可以擷取到idToken并且通路相應的服務。 雖然操作複雜,但是好處也是有的,這一波操作之後,所有的微服務的權限都可以是idToken了,那麼你隻需要登入一次即可通路其他服務,且不需要重新登入,友善了整體服務的權限控制。
3.代碼實操部分
首先使用者不管通路你的什麼服務,oa.tommy.cn或者xx.tommy.cn,每一個項目都是一個應用,都是需要在hydra建立一下的,下面使用tommy-oa,這個應用來舉例。
https://oa.tommy.cn這個前端項目隻要通路,首先就跳轉下面這個url。
URL:
https://hydra-client.tommy.cn/oauth2/auth?&client_id=tommy-oa&response_type=code&scope=openid&state=tommy123456789&redirect_uri=https://oa.tommy.cn
前段是你hydra伺服器部署的域名,中段你的應用id:tommy-oa,最後段必須寫,即:你建立應用的時候配置的前端回調位址:https://oa.tommy.cn
JavaSDK 先看一下,微服務直接用hydra提供的sdk來實作了。位址:hydra-client-java
引入方式:
// uaa
compile ('cn.sharing.cloud:uaa-auth:1.2.6')
// aop
compile ('org.springframework.boot:spring-boot-starter-aop')
// jwt
compile 'io.jsonwebtoken:jjwt:0.9.0'
compile 'com.auth0:java-jwt:3.9.0'
// ory hydra 清量授權管理
compile 'sh.ory.hydra:hydra-client:1.9.0-alpha.3'
前端項目通路了hydra之後,hydra會重定向到你的AuthServer的接口去,也就是你建立hydra配置的那個位址:https://oa.tommy.cn/login
下面會用到hydra的兩個接口:
getLoginRequest (根據login_challenge擷取使用者資訊,其中 skip 這個字段就是判斷使用者是否登入了的狀态)
acceptLoginRequest (根據login_challenge告訴hydra使用者登入了,post請求,是以這裡向hydra存入了使用者的資訊,存入的資訊可以自定義如使用者uuid)
/**
* Hydra callback 的登入接口
* 注意: login_challenge 這個參數下劃線是不能改的,因為 Hydra 就是這樣傳回的沒辦法
*
* @param login_challenge
* @return
*/
@GetMapping("login")
public ModelAndView login(String login_challenge) {
ModelAndView mv = new ModelAndView();
LoginRequest request = hydraService.getLoginRequest(login_challenge);
if (Objects.isNull(request))
throw new BadRequestException("hydra#LoginRequest請求異常");
// 使用者已登入
if (Boolean.TRUE.equals(request.getSkip())) {
String uri = uaaControllerService.userHaveLoginAccept(login_challenge, request);
if (!TextUtils.isEmpty(uri)) {
mv.setViewName(REDIRECT + uri);
return mv;
}
}
// 使用者未登入
mv.setViewName(REDIRECT + "https://sso.tommy.cn/login?login_challenge=" + login_challenge);
return mv;
}
兩種情況:
第一種,使用者skip為 false,未登入的狀态,那麼直接重定向到我們前端頁面:https://sso.tommy.cn/login/xxxxxxxx,使用者在sso前端頁面輸入帳号密碼,點選登入,你需要寫一個登入接口去判斷使用者帳号密碼是否正确,如果帳号密碼無誤,伺服器直接調用hydra的acceptLoginRequest接口,登入,調用這個接口之後,hydra傳回一個uri,你服務重定向這個uri就會往下一步走。
第二種,使用者skip為 true,沒啥好說,伺服器直接調用hydra的acceptLoginRequest接口,登入,調用這個接口之後,hydra傳回一個uri,你服務重定向這個uri就會往下一步走。
伺服器重定向uri之後,hydra就會重定向到你建立hydra時配置的consent位址:https://oa.tommy.cn/consent?consent_challenge=xxxxx
下面會用到hydra的兩個接口:
getConsentRequest (通過consent_challenge擷取上一步,存入的使用者資訊,通過使用者uuid,你可以擷取你系統中使用者的資訊以及該使用者有哪些權限)
acceptConsentRequest (通過consent_challenge給使用者授權,順便配置cookie也就是使用者在hydra登入的時效,以及你的scope都要在這裡存進去)
/**
* 授權
*
* @param consent_challenge
* @return
*/
@GetMapping("/consent")
public ModelAndView consent(String consent_challenge) {
ModelAndView mv = new ModelAndView();
String uri = uaaControllerService.userConsentAccept(consent_challenge);
// 在這裡給使用者授權了
mv.setViewName(REDIRECT + uri);
return mv;
}
/**
* 使用者授權登入
*
* @param consentChallenge
* @return
*/
public String userConsentAccept(String consentChallenge) {
ConsentRequest request = hydraService.getConsentRequest(consentChallenge);
if (Objects.isNull(request))
throw new BadRequestException("擷取登入資訊異常");
TokenSubject tokenSubject = getTokenSubject(request.getSubject());
String clientId = Objects.requireNonNull(request.getClient()).getClientId();
Client client = clientService.getById(clientId);
if (!client.getRealmId().equals(tokenSubject.getRealmId()))
throw new BadRequestException("應用與域不比對");
// 擷取權限
RoleUser roleUser = roleUserService.getRoleUserByUserId(tokenSubject.getUserId());
User user = userService.getUserById(tokenSubject.getUserId());
RoleAuthority roleAuthority = roleAuthorityService.getByRoleId(roleUser.getRoleId());
IdToken idToken = new IdToken();
StringBuilder scope = new StringBuilder();
for (String authorityKey : roleAuthority.getAuthorityKeys()) {
scope.append(authorityKey).append(" ");
}
// 使用者個人資訊的預設管理權限
scope.append("userinfo:*");
// 設定權限
idToken.setScope(scope.toString());
// 站點資訊
Station station = stationClient.getStationById(Long.valueOf(tokenSubject.getStationId())).getData();
if (Objects.isNull(station))
throw new BadRequestException("查詢站點資訊異常");
StationInfo stationInfo = new StationInfo();
int id = Math.toIntExact(station.getId());
stationInfo.setId(id);
stationInfo.setName(station.getName());
stationInfo.setAlias(station.getAlias());
stationInfo.setIcon(station.getLogo());
stationInfo.setQrcode(station.getQrcode());
idToken.setStationInfo(stationInfo);
// 設定使用者
idToken.setSub(user.getId());
idToken.setSubAccount(user.getAccount());
idToken.setSubName(user.getRealName());
idToken.setAud(clientId);
idToken.setReamId(client.getRealmId());
// 擷取 cookie 有效時間
Realm realm = realmService.getById(tokenSubject.getRealmId());
// 在這裡給使用者授權了
return hydraService.acceptConsentRequest(consentChallenge, idToken, (long) realm.getIdTokenLifespan());
}
送出接口之後,hydra會傳回一個uri,重定向這個uri,hydra就會傳回到:https://oa.tommy.cn?code=SxFbSqu_v6KcOhkIUgNkrdeUVjMsu-V-t3SH9qeUOSk.L7ZSci5O7mKen9xaxl47Jn06i5NaaLs217dXrgAV3T8
hydra會重定向到你的前端項目,這個位址其實是你最開始前端頁面跳轉的時候填寫的位址:
https://hydra-client.tommy.cn/oauth2/auth?&client_id=tommy-oa&response_type=code&scope=openid&state=tommy123456789&redirect_uri=https://oa.tommy.cn
redirect_uri=https://oa.tommy.cn
建立應用的時候,填寫的位址:redirect_uri=https://oa.tommy.cn (跟前端必須呼應)
你在這裡填什麼hydra就會跳回來什麼,并且帶着code回來,前端根據code就可以去擷取到idToken,當然我下面用postman示範,實際上你們需要在前端項目去請求。
注意請求方式是:form方式
當然了, 也可以在AuthServer寫一個接口,前端傳入code、clientId、redirectUri,後端請求hydra擷取idToken也是可以的。
/**
* 擷取 token
*
* @param params
* @return
*/
@PostMapping("/oauth2/token")
@ResponseBody
public ResultModel<Object> oauth2Token(@RequestBody(required = false) Map<String, String> params) {
return new ResultModel.Builder<>().buildData(uaaControllerService.getHydraToken(params));
}
/**
* 擷取 hydraToken
*
* @param params
* @return
*/
public HydraToken getHydraToken(Map<String, String> params) {
String clientId = params.get("clientId");
String code = params.get("code");
String redirectUri = params.get("redirectUri");
if (TextUtils.isBlank(clientId) || TextUtils.isBlank(code))
throw new BadRequestException("clientId or code not be null");
Client client = clientService.getById(clientId);
return hydraService.getOauth2Token(code, clientId, client.getSecret(), redirectUri);
}
/**
* 擷取 id_token
*
* @param code
* @param clientId
* @param secret
* @return
*/
public HydraToken getOauth2Token(String code, String clientId, String secret, String redirectUri) {
OkHttpClient client = new OkHttpClient();
RequestBody formBody = new FormBody.Builder()
.add("grant_type", "authorization_code")
.add("code", code)
.add("client_id", clientId)
.add("client_secret", secret)
.add("redirect_uri", redirectUri)
.build();
Request request = new Request.Builder()
.url(clientHost + "/oauth2/token")
.post(formBody)
.build();
try {
Response response = client.newCall(request).execute();
if (Objects.nonNull(response.body())) {
JsonNode jsonNode = mapper.readTree(response.body().string());
if (StringUtils.isNotBlank(jsonNode.path("access_token").asText())) {
return JSON.parseObject(jsonNode.toString(), HydraToken.class);
}
}
} catch (IOException e) {
e.printStackTrace();
}
throw new BadRequestException("擷取idToken異常");
}
登出部分:
第一種情況:https://hydra-client.tommy.cn/oauth2/sessions/logout 把這個放浏覽器url,回車hydra就會根據你的cookie退出你目前登入的帳号,然後重定向到一個位址,這個位址就是你建立hydra的時候,填寫的:https://oa.tommy.cn/logout/callback,這樣就會回調到你的AuthServer伺服器,你在伺服器做下你的操作。
/**
* 退出成功頁面
*
* @return
*/
@GetMapping("/logout/callback")
public ModelAndView testCallback() {
ModelAndView mv = new ModelAndView();
mv.setViewName("logout");
return mv;
}
第二種情況:(推薦使用)https://hydra-client.tommy.cn/oauth2/sessions/logout?id_token_hint={目前退出使用者的IdToken}&post_logout_redirect_uri={退出成功之後hydra跳哪裡}
我的例子:https://hydra-client.tommy.cn/oauth2/sessions/logout?id_token_hint=xxxx&post_logout_redirect_uri=https://oa.tommy.cn/logout
那麼注意:post_logout_redirect_uri這個位址不能亂寫的,這是你在建立tommy-oa這個應用的時候寫的
必須一緻,不然hydra驗證不通過,你無法退出的。用這種方式退出好處就是,退出成功之後,hydra會重定向到你的前端去,而不是定位到我們後端,畢竟你前端會有很多個,例如:https://oa.tommy.cn 退出之後肯定要跳回到這個域名本身的前端項目,要是用第一種方式,後端根本不知道目前使用者在哪個前端應用下退出的。
/**
* hydra 退出 重定向
*
* @param logout_challenge
* @return
*/
@GetMapping("/logout")
public ModelAndView logout(String logout_challenge) {
ModelAndView mv = new ModelAndView();
String uri = uaaControllerService.userLogout(logout_challenge);
mv.setViewName(REDIRECT + uri);
return mv;
}
/**
* 使用者退出
*
* @param logoutChallenge
* @return
*/
public String userLogout(String logoutChallenge) {
LogoutRequest logoutRequest = hydraService.getLogoutRequest(logoutChallenge);
if (Objects.isNull(logoutRequest))
throw new BadRequestException("擷取Hydra退出資訊異常");
TokenSubject tokenSubject = getTokenSubject(logoutRequest.getSubject());
// 這裡你可以擷取你使用者的資訊,我這裡删除掉了,這是我的東西,你們可以自行那啥
// 比如儲存退出日志什麼的都在這裡寫
String uri = hydraService.acceptLogoutRequest(logoutChallenge);
if (TextUtils.isBlank(uri))
throw new BadRequestException("退出失敗");
return uri;
}
hydra會重定向到你配置的 https://oa.tommy.cn/logout,并且帶着 logout_challenge,你調用hydra退出接口,去實作退出,完了之後hydra會重定向到你傳的回調位址中。
總結
至此,Hydra單點登入部分告一段落,當然hydra還有很多坑,我下次有空會繼續更新,你們的支援我更新動力。。。。
遇到的問題
坑1,使用者在前端(https://oa.tommy.cn/user/info)這個位址丢進浏覽器,一波重定向授權回來,發現回到 (https://oa.tommy.cn)首頁來了,那是因為你轉發給hydra的時候,redirect_uri=https://oa.tommy.cn,是以最後hydra跳這裡,這時候你就要說了,那我一開始就傳 redirect_uris=https://oa.tommy.cn/user/info 不就得了?想法是不錯,但是hydra所有的redirect_uri必須先聲明,如下圖:
你看到他那個 redirect_uris 是一個list數組沒有,你可以填很多個,但是問題又來了,要是你的項目有無數個位置,難道要來這裡填無數個嗎?對沒錯,hydra目前的版本<10.0.2>就是這麼啦跨,也許以後會改吧。下面是源碼分析:(hydra源碼是 Go 語言開發的)
func isMatchingRedirectURI(uri string, haystack []string) (string, bool) {
requested, err := url.Parse(uri)
if err != nil {
return "", false
}
// 原代碼,不支援一個域名比對多個 urls
//for _, b := range haystack {
// if b == uri {
// return b, true
// } else if isMatchingAsLoopback(requested, b) {
// // We have to return the requested URL here because otherwise the port might get lost (see isMatchingAsLoopback)
// // description.
// return uri, true
// }
//}
for _, b := range haystack {
if strings.Contains(uri, b) {
return uri, true
}
if b == uri {
return uri, true
} else if isMatchingAsLoopback(requested, b) {
// We have to return the requested URL here because otherwise the port might get lost (see isMatchingAsLoopback)
// description.
return uri, true
}
}
return "", false
}
源碼的意思很明顯,一個 For 循環你的 redirect_uris 如果 b == 你其中一個uri 那麼傳回 b ,b就是你 redirect_uris 循環的子。當然了,他不支援我們可以自己修改嘛,我改成了包含的關系,比如說:https://oa.tommy.cn/user/info 這個字元串他包含 https://oa.tommy.cn 而且傳回的是前者,這樣就能達到我們的目的了。
if strings.Contains(uri, b) {
return uri, true
}
這樣,我們隻需要在建立應用的時候,填一個項目域名,就能達到我們的前端随意跳位址的目的了 redirect_uris=https://oa.tommy.cn/xxx/xxx
歡迎留下你的坑,大家一起探讨。。。。。