最近老板提了一个需求:
自己做一个SSO系统,要求登录之后第三方产品(如gitlab、jira)也实现免登陆的效果。
我听到之后第一反应就是:人家的认证系统走的不是我们的逻辑,除了改源码好像没别的实现方式了。。
但是经过几天的测试之后,最终还是实现了不修改源码实现第三方系统免登陆的效果。这个倒是提醒了我以后遇到事情不能太早下定论,多做些测试和调研,就算不行也得用充分的测试结果或官方的论证依据来印证说法。
由于HTTP是无状态协议,所以web应用无法辨认请求发送者的身份,因此就有了各种各样帮助web应用记录用户身份信息的方法,解决本次问题的就是其中之一——Cookie。
gitlab的登陆原理如下:1.用户访问gitlab登录页面并输入用户名密码 → 2.浏览器携带参数发送请求 → 3.gitlab服务器发放session → 4.服务器将session设置到cookie中 → 5.重定向到欢迎页
user[login]
和user[password]
,同时body里还有一些额外的参数,如utf8、authenticity_token
、user[remember_me];_gitlab_session
;_gitlab_session
,将其设置到cookie中,然后重定向到了http://gitlab.test.com
这个url;
<dependency>
<groupId>org.apache.httpcomponentsgroupId>
<artifactId>httpclientartifactId>
<version>4.5.3version>
dependency>
CloseableHttpClient httpClient = HttpClients.custom().build();
登录请求的body参数中一共有5个参数,其中utf8、user[remember_me]为定值写死即可,user[login]
、user[password]
可直接让用户通过表单输入,而authenticity_token
则需要爬取页面信息获取。这里着重讲一下authenticity_token
的获取方式。
查看登录页面源代码可以发现页面头有一个name为csrf-token
的标签:
这个就是我们需要的authenticity_token
,其目的是用于防止跨站请求伪造,这里我们只需要用爬虫将其抓取后作为参数传递给gitlab服务器即可。
// 爬取页面内容
HttpGet getCsrfToken = new HttpGet("http://gitlab.test.com");
CloseableHttpResponse getCsrfTokenResponse = httpClient.execute(getCsrfToken);
// 匹配authenticity_token
Matcher csrfTokenMatcher = Pattern.compile("(?<=).matcher(EntityUtils.toString(getCsrfTokenResponse.getEntity(), "UTF-8"));
if (csrfTokenMatcher.find()) {
String csrfToken = csrfTokenMatcher.group();
}
在前面的请求分析中可以看到gitlab在登录时会随请求发送一个_gitlab_session
。虽然这个_gitlab_session
和请求返回的_gitlab_session
同名,但是此时的session并不能使我们实现免登陆的效果,推测是_gitlab_session
中保存了用户的登录状态等信息。直接在上一步的响应头中就可以找到未登录状态的_gitlab_session
。
// 匹配登录前的_gitlab_session
Matcher logoutSessionMatcher = Pattern.compile("(?<=_gitlab_session=).+?(?=;)").matcher(getCsrfTokenResponse.getFirstHeader("Set-Cookie").getValue());
if (logoutSessionMatcher.find()) {
String logoutSession = logoutSessionMatcher.group();
}
万事俱备,所有的登录必备参数我们已经获取完成,接下来开始模拟发送登录请求。
HttpPost getSession = new HttpPost("http://gitlab.test.com/users/sign_in");
// 设置body
List<NameValuePair> paramList = new ArrayList<>();
paramList.add(new BasicNameValuePair("utf8", "✓"));
paramList.add(new BasicNameValuePair("authenticity_token", csrfTokenMatcher.group()));
paramList.add(new BasicNameValuePair("user[login]", username));
paramList.add(new BasicNameValuePair("user[password]", password));
paramList.add(new BasicNameValuePair("user[remember_me]", "0"));
getSession.setEntity(new UrlEncodedFormEntity(paramList, "utf-8"));
Matcher logoutSessionMatcher = Pattern.compile("(?<=_gitlab_session=).+?(?=;)").matcher(getCsrfTokenResponse.getFirstHeader("Set-Cookie").getValue());
if (logoutSessionMatcher.find()) {
// 设置Header
getSession.setHeader("Cookie", "_gitlab_session=" + logoutSessionMatcher.group());
}
// 获取登录后的session
CloseableHttpResponse getSessionResponse = httpClient.execute(getSession);
// 匹配登录后的_gitlab_session
Matcher loginSessionMatcher = Pattern.compile("(?<=_gitlab_session=).+?(?=;)").matcher(getSessionResponse.getFirstHeader("Set-Cookie").getValue());
if (loginSessionMatcher.find()) {
String loginSession = loginSessionMatcher.group();
}
不出意外的话,在上一步中我们已经拿到了免登陆的关键信息:登录后的_gitlab_session
,接下来我们只需要将session信息设置到cookie中就可以完成终极目标了,不过这里还有一个隐藏的坑——cookie的跨域问题。
一般而言,cookie只能跨子域共享(例如a.test.com
和b.test.com
共享cookie),可以通过以下代码实现:
Cookie cookie = new Cookie("name", "value");
cookie.setDomain("test.com");
response.addCookie(cookie);
而无法跨顶级域名共享cookie。(设想以下情景:钓鱼网站诱导用户输入账号密码拿到session,然后将session跨域设置到官方网站的cookie实现免登陆。可怕不?)
此处我们采用一个取巧的方式绕过跨域问题:
在gitlab服务器下放一个专门用于设置cookie的服务(称之为
session适配器
)。我们通过之前的步骤获取到_gitlab_session
后,通过调用API的形式将_gitlab_session
作为参数传递给session适配器
,然后由session适配器
将cookie设置到浏览器中。由于session适配器
和gitlab域名相同,gitlab可以读取到session中的身份信息,所以此时再访问gitlab便实现了免登陆的效果。
@GetMapping("/gitlab")
@CrossOrigin(origins = "*", allowedHeaders = "*",
methods = {RequestMethod.GET, RequestMethod.POST}, maxAge = 3600L)
public void gitlab(@RequestParam String session, HttpServletResponse response) {
// 设置cookie
Cookie gitlabSession = new Cookie("_gitlab_session", session);
gitlabSession.setPath("/");
gitlabSession.setHttpOnly(true);
response.addCookie(gitlabSession);
}
到这里我们已经实现了gitlab免登陆,用户无感知,并且对gitlab本身没有任何侵入式修改。
流程中一共有三个坑需要注意:
authenticity_token
的获取。因为我司开发的主要是企业项目,办公环境在内网(相对安全),所以虽然知道CSRF的基本原理,但是并不需要防范,也就没有机会接触到它的配置。这次还好大佬见多识广,一句话就指明了这个参数可以从页面代码获取,不然这个坑估计得踩很久。Content-Type:application/json
。但是gitlab的请求是form表单传递的,header中使用的是Content-Type:x-www-form-urlencoded
,所以应该使用以下方式设置请求体:HttpPost getSession = new HttpPost("http://gitlab.test.com/users/sign_in");
// 设置body
List<NameValuePair> paramList = new ArrayList<>();
paramList.add(new BasicNameValuePair("utf8", "✓"));
paramList.add(new BasicNameValuePair("authenticity_token", csrfTokenMatcher.group()));
paramList.add(new BasicNameValuePair("user[login]", username));
paramList.add(new BasicNameValuePair("user[password]", password));
paramList.add(new BasicNameValuePair("user[remember_me]", "0"));
getSession.setEntity(new UrlEncodedFormEntity(paramList, "utf-8"));
关于跨域设置cookie的问题后来又找到一个更简便的解决方案:
将
session适配器
中的方法整合到登录中心
的登录方法,然后通过反向代理将该方法设置为和对应的第三方服务同域。(可以和登录中心
下的其他方法不同域)
这样可以省略session适配器
服务,减少代码量,优化部署过程。