前言
上篇文章简单的讲解了Hydra的基本用法,本篇文章将深化对单点登录在分布式微服务体系的介绍以及详细用法。
应用场景
想象这样一个场景,你有好几个应用,每个应用对应一个微服务Server,那么如果你需要给一个用户分配一个权限去同时访问这些服务时,你总不能每个服务都重新登录一次吧?这样用户体验及其不好,所以单点登录的概念就应运而生了。
单点登录:所谓的单点登录,说直白了就是只需要登录一次,就可以访问多个服务器。如下图所示,用户仅需要在AuthServer这个服务登录一次,就可以用获取到的idToken对资源服务器进行多服务访问而不需要重新登录。
这样一种概念,开源框架里有一个不错的东西,叫做 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
从第二张图就可以看出来,第二次访问 https://oa.tommy.cn,的时候你只会看到浏览器url变化,但是不需要输入帐号密码,也无需访问到SSO前端,最终是可以获取到idToken并且访问相应的服务。 虽然操作复杂,但是好处也是有的,这一波操作之后,所有的微服务的权限都可以是idToken了,那么你只需要登录一次即可访问其他服务,且不需要重新登录,方便了整体服务的权限控制。
3.代码实操部分
首先用户不管访问你的什么服务,oa.tommy.cn或者xx.tommy.cn,每一个项目都是一个应用,都是需要在hydra创建一下的,下面使用tommy-oa,这个应用来举例。
https://oa.tommy.cn这个前端项目只要访问,首先就跳转下面这个url。
URL:
前段是你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会重定向到你的前端项目,这个地址其实是你最开始前端页面跳转的时候填写的地址:
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
欢迎留下你的坑,大家一起探讨。。。。。