闲来无事,想起很多人催我写一篇单点登录(SSO, Single Sign On)的原理和操作文章,索性就现在写了吧,还是以南科大采用的 CAS(Central Authentication Service) 作为范例进行讲解。
目标读者:服务器开发者;想了解单点登录(和一些常见的第三方登录)的同学们
实现:Java,基于 SpringBoot 构建的项目,本文代码已经分离出来,只需要 Java 网络运行环境即可。
无图不丈夫,我先画个图(图片源自自己造的,不许笑!我很认真的)。
图1. 客户端 User (一般是浏览器)的视野
客户端 User 的视野就是这么简单熟悉明了。比如常见的第三放登录(QQ、微信、微博等)基本都是如此,操作方式都是:打开 APP(或者网页),点击 ⌈QQ登录⌋就可以跳转到 QQ 的登录页面或者手机QQ里面进行登录,登录授权成功后就跳转回 APP 或者网站,此时就能正常访问数据了~
但是,作为一个开发者,我们需要站在服务器端看世界:
图2. 服务器 Server 的视野
服务器可能会收到假票,很多用户(或是中间人)想借此上车,那是坚决不行的!这时候 Server 就得拿票去 CAS 中心验证一下,看看票是不是有效,如果有效,CAS 会返回给 Server 一个用户信息(一般包含:ID、Name 等)。
细心的读者一下子就发现了,这样的话不就是把以前传统的自己验证账号密码的方式分离出去给了 CAS 吗?那这样不就是 CAS 说啥就是啥?的确如此,把用户身份验证交给第三方解决,充分信任第三方的信息这是 CAS 这类 SSO 的建设基础。俗话说,没有信任,世界就没了美好~为了享受单点登录的美好,咱先信一次它。其实转念一想,如果是第三方登录,比如用户想用 ⌈Google 账号⌋ 登录 ⌈亚马逊商城⌋,想用 ⌈QQ 账户⌋ 登录 ⌈知乎⌋,你觉得 Google 会把自己的账号密码信息给亚马逊?所以大家才想出来这么一个招搞定相互鉴权的业务模型。多说一句,这套路一出,受益的始终是用户,开发者得多搞几个常见的第三方登录接口,只要用户舒服就好啊~;而且如果是企业项目,经常会有很多子项目出现,一个项目一个账号,谁记着不烦,要是能统一登录一下,天下大同啊~
接下来我们看看 CAS 的视野:
图3. CAS 鉴权中心的视野
CAS 的视野中,Server 其实就是 CAS 的 Client,User 还是 User。第 3 – 4 步骤中验明正身,自己搞了个 TOKEN 给 User,然后 6 – 7 步的时候由 Client( Server )把 TOKEN 拿回来给自己,当然知道这玩意是真的假的了啊!然后把信息发给 Client 就好了~
为了能让 CAS ⌈记住⌋ TOKEN,攻城狮们可想了很多法子。有的建缓存,保存在内存里,一个 TOKEN 作为一个 key,对应的 value 就是用户的数据;有的觉得用户那么多,缓存哪够啊,索性建数据库存数据;有的觉得数据库查找也挺费时的,要是能有一个不消耗服务器内存和硬盘资源的方法就好了~于是搞了一套加密算法,把用户数据直接加密到 TOKEN 里面,别人传回 TOKEN 只有自己知道怎么解密,这样子连内存都不用消耗只需要肝 CPU 资源就行了~。
来,我们撸代码!
撸前三思:我是谁?我要做什么?该怎么做?
这次我们模拟 User 进行鉴权登录,所以我们就是 User,我们需要对两个对象(不是指面向对象里的对象,可以理解为 Subject 而不是 Object )进行操作。细分步骤如下:
如果不出意外,上述步骤是每次正确登录所走的路线,其中第 1、2 步骤可以省略(因为我们已经知道 Server 采取了 CAS 登录,不再使用原来的自有账号密码登录了),我们只需要实现步骤 3 -6 就行,当服务器返回第 7 步的信息就算大功告成!
那么我们用 HTTP 语言细述一下:
现假设 Server 端的地址是:http://jwxt.sustc.edu.cn;CAS 中心鉴权地址是:https://cas.sustc.edu.cn/login
1.【GET】https://cas.sustc.edu.cn/cas/login
2.【POST】https://cas.sustc.edu.cn/cas/login?service=http://jwxt.sustc.edu.cn
3.【POST】http://jwxt.sustc.edu.cn/login?token=xxx会拿到成功/失败的数据
我再详细介绍一下每个步骤的操作:
步骤 1 是请求 CAS 登录页面,拿到 execution、_eventId、submit 三个字段值,execution 可理解为流水号信息(其实可以随便填写,如果 CAS 方验证不严的话)。其中 _eventId 一般是默认值字符串”submit”,execution 一般是一个超长超长的字符串,submit 默认值一般也是字符串”submit”。(别问我 username 和 password 填啥!= =。。。)
步骤 2 是发送表单到 CAS 的过程。将以上 5 个字段 post 到登录链接,会得到一个 TGC 字段和 TOKEN 字段的内容。其中 TGC 会存在 Cookie 里面,这个值一般是通过 JWT 加密的一串信息,存储了用户的个人数据,用途放在最后再讲,一般不会用到;TOKEN 字段一般可能是 Ticket 字段,是门票,得拿这个去 Server 鉴定。
步骤 3 就是拿票鉴定的一步。把 TOKEN 拿到 Server 的鉴定地址进行验证,通过则返回成功信息。
(以上所有步骤都有可能存在失败的可能性,比如账号密码错啦~网络断啦~TOKEN 过期了啊~铺开讲太废话了,所以就只列出成功了的场景,如果路线正确,那么一切就都会正常)
贴代码!
(代码是局部截取的一个 .java 文件,依赖了一些业务模块,未做整理,但见其骨干即可)
package com.gpaer.service.v3;
import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.CookieStore;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.conn.socket.LayeredConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.cookie.Cookie;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.message.BasicNameValuePair;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.stereotype.Service;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.List;
/**
* Created by neo on 19/10/2017.
* <p>
* 模拟 CAS 登录目标客户端服务,返回对应的会话 ID 和 CAS TGC 票据
* <p>
* <不依赖任何自有类,独立文件>
*/
@Service
public class CASConnector {
/**
* @param username
* @param password
* @param redirectUrl 目标地址(可以为空)
* @return
* @throws Exception
*/
public CASResult genLogin(String username, String password, String redirectUrl) throws Exception {
// 创建客户端
CookieStore httpCookieStore = new BasicCookieStore();
CloseableHttpClient client = createHttpClientWithNoSsl(httpCookieStore);
/* 第一次请求[GET] 拉取流水号信息 */
HttpGet request = new HttpGet("https://cas.sustc.edu.cn/cas/login?service=" + redirectUrl);
HttpResponse response = client.execute(request);
Document htmlPage = Jsoup.parse(readResponse(response));
Element form = htmlPage.select("#fm1").first();
String execution = form.select("[name=execution]").first().val();
String _eventId = "submit";
String submit = "登录";
/* 第二次请求[POST] 发送表单验证信息 */
HttpPost request2 = new HttpPost("https://cas.sustc.edu.cn/cas/login");
List<NameValuePair> params = new ArrayList<NameValuePair>();
params.add(new BasicNameValuePair("username", username));
params.add(new BasicNameValuePair("password", password));
params.add(new BasicNameValuePair("execution", execution));
params.add(new BasicNameValuePair("_eventId", _eventId));
params.add(new BasicNameValuePair("submit", submit));
request2.setEntity(new UrlEncodedFormEntity(params));
HttpResponse response2 = client.execute(request2);
Header headerSetCookie = response2.getFirstHeader("Set-Cookie");
String TGC = headerSetCookie == null ? null : headerSetCookie.getValue().substring(4, headerSetCookie.getValue().indexOf(";")); // TGC
Header headerLocation = response2.getFirstHeader("Location");
String location = headerLocation == null ? null : headerLocation.getValue();
/* 第三次请求[GET],前往 CAS 客户端进行验证获取会话 */
HttpGet request3 = new HttpGet(location);
HttpResponse response3 = client.execute(request3);
CASResult casResult = new CASResult();
List<Cookie> cookies = httpCookieStore.getCookies();
for (Cookie cookie : cookies) {
if (cookie != null && cookie.getName().equals("TGC")) {
casResult.setTGC(cookie.getValue());
continue;
}
if (cookie != null && cookie.getName().equals("JSESSIONID")) {
casResult.setJSESSIONID(cookie.getValue());
continue;
}
}
// set Uri (scheme, host, path, query..)
casResult.setUri(request3.getURI());
/* 第四次请求[GET],获取目标页面内容 */
/* 该获取移交由用户自行操作,不在此做多余请求 */
return casResult;
}
/* 读取 response body 内容为字符串 */
private String readResponse(HttpResponse response) throws IOException {
BufferedReader in = new BufferedReader(
new InputStreamReader(response.getEntity().getContent()));
String result = new String();
String line;
while ((line = in.readLine()) != null) {
result += line;
}
return result;
}
/**
* 创建模拟客户端(针对 https 客户端禁用 SSL 验证)
*
* @param cookieStore 缓存的 Cookies 信息
* @return
* @throws Exception
*/
private CloseableHttpClient createHttpClientWithNoSsl(CookieStore cookieStore) throws Exception {
// Create a trust manager that does not validate certificate chains
TrustManager[] trustAllCerts = new TrustManager[]{
new X509TrustManager() {
public X509Certificate[] getAcceptedIssuers() {
return null;
}
public void checkClientTrusted(X509Certificate[] certs, String authType) {
// don't check
}
public void checkServerTrusted(X509Certificate[] certs, String authType) {
// don't check
}
}
};
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(null, trustAllCerts, null);
LayeredConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(ctx);
return HttpClients.custom()
.setSSLSocketFactory(sslSocketFactory)
.setDefaultCookieStore(cookieStore == null ? new BasicCookieStore() : cookieStore)
.build();
}
/**
* 最终返回结果集
*/
public class CASResult {
/* 认证通过则返回 TGC,否则为空 */
private String TGC;
private String JSESSIONID; // 会话 ID
private URI uri; // 包含 (scheme, host, path, query..)
public String getTGC() {
return TGC;
}
public void setTGC(String TGC) {
this.TGC = TGC;
}
public String getJSESSIONID() {
return JSESSIONID;
}
public void setJSESSIONID(String JSESSIONID) {
this.JSESSIONID = JSESSIONID;
}
public URI getUri() {
return uri;
}
public void setUri(URI uri) {
this.uri = uri;
}
}
}
代码没有采用一些常见的设计模式,这是当时踩坑 CAS 的时候做的,对于数据的返回处理那块其实可以利用策略模式优化,更易用一些。以后有机会了再上来修改。
此处对以上部分内容进行解释:
TGC:这是 CAS 鉴权成功后留在你浏览器里的数据,下次如果有新的客户端(Server)需要登录,CAS 就会直接从 TGC 读取信息来验证你的身份,而不用你重新输入账号密码了~
TOKEN:一串字符串,CAS 里也称 Ticket 票据,一般长度十几二十吧,有的会带有一些肉眼可见的信息,像这样:http://ST-1820-GvrxTYfpm25J6ROcDrcz-cas.sustc.edu.cn
关于 HTTPS 问题:有的同学可能会遇到 HTTPS 证书问题,用 Python 实现的可能会轻松点。Java 需要提供一个 HTTPS 的证书才能访问 HTTPS 的网站,或者把 HTTPS 链接建立为 ⌈信任所有的⌋ 网站。本文采用的后者,会存在一定的安全风险,但如果只是访问一下已知的 CAS 和 Server 站点,也就不担心什么了。
对于第三方登录的开发者:如果你是后端开发,需要对接第三方登录,只需要去对应的开放平台找到 API 地址然后拿前端给的 TOKEN 鉴权就好了,鉴权成功就把会话设定为已经登录的状态(比如参考腾讯
OAuth2.0开发文档 - 文档资料--QQ互联其中 TOKEN 获取参见
使用Authorization_Code获取Access_Token - 文档资料--QQ互联)。如果你是前端开发,你需要添加一个第三方登录的按钮,并将页面跳转至对应的授权地址,用户登录后会跳转回来,所以你要准备一个回调页面接受回来的参数,拿到参数后把参数提交给后端,后端会告诉你登录成功与否。
更多问题可以私信我,我会尽量用最通俗易懂的方式来把问题写明白~
欢迎关注我的微信公众号:小亦日記 (搜索:seeuxiaoyi 即可找到我)
加油!
【本文所有代碼均可在項目 SUSTC-GPA 找到!GitHub 傳送】
Nexoi/SUSTC-GPA