Hello World

吞风吻雨葬落日 欺山赶海踏雪径

0%

SSO服务源码分析

SSO服务源码分析

SSO英文全称Single Sign On,单点登录。SSO是在多个应用系统中,用户只需要登录一次就可以访问所有相互信任的应用系统。
实现单点登录的实质就是要解决如何产生和存储信任,再就是其他系统如何验证这个信任的有效性,因此要点也就以下几个:

  • 存储信任
  • 验证信任
    只要解决了以上的问题,达到了开头讲得效果就可以说是SSO。最简单实现SSO的方法就是用Cookie,实现流程如下所示:
    sso

但是目前Cookie的实现存在两个问题:

  • Cookie不安全
  • 不能跨域免登
    第一个问题可以通过对Cookie加密处理解决,第二个问题却是硬伤了。

SSO的实现除了Cookie之外还有许多实现方式,这里暂且分析一下一个基于Cookie的实现源码。

首先给出本次分析的结论,具体源码贴在结论之后。

具体实现逻辑总结

设置web应用的filter,用于初始化每次请求线程的SSOInfo(其中包含 登陆有效性的ticket用户的业务数据(比如userId),本次请求的HttpServletRequestHttpServletResponse可选))。

  1. 读取Cookies获取ticket放入SSOInfo,无则ticket为null。
  2. 把SSOInfo存储进一个线程隔离级别的容器中(这里使用ThreadLocal实现)
  3. 在需要拦截的Controller前设置拦截器,对SSOInfo中包含的ticket进行有效性校验。(这里的有效性校验实现方式很多,本次系统中是用redis来存储、管理ticket有效性的)无效ticket的情况下引导登陆,创建ticket(由特定字段与UUID.randomUUID()来实现),保存(ticket为redis的key,存储value为用户id),管理(redis设置超时规则)ticket。
    以上就是这个SSO系统的具体实现逻辑。分析出来实现逻辑比较简单。可适用于一般的小型单域名的网站。

以下为具体实现代码:

web项目设置初始化filter。
web.xml

1
2
3
4
5
6
7
8
<filter> 
<filter-name>AuthFilter</filter-name>
<filter-class>com.jc.sso.client.AuthFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>AuthFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

AuthFilter:读取cookies初始化SSOInfo

1
2
3
4
5
6
7
8
9
10
11
12
13
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { 

SSOInfo info = SSOCookieUtil.vistSsoCookie((HttpServletRequest)request);
SsoManager.setSSOInfo(info);
info.setRequestObj((HttpServletRequest)request);
info.setResponseObj((HttpServletResponse)response);
try {
// pass the request along the filter chain
chain.doFilter(request, response);
} finally {
SsoManager.clearSSOInfo();
}
}

SSOInfo
记录SSO的ticket的bean,为了额外信息的获取,同时记录了HttpServletRequest和HttpServletResponse(这里只是为了额外信息的记录,比如访问Ip地址等等)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private User user; 
private String ticket;
private String uid;
//标识pc主机的id
private String app; /** * 是否已经进行过ticket的校验 */
private boolean isValidated = false;
private HttpServletRequest requestObj;
private HttpServletResponse responseObj;
public boolean isLogin() {
if (ticket == null) {
return false;
}
if (!isValidated) {
throw new NotValidateException();
}
return user != null;
}

SSOCookieUtil
一个Cookie的操作类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static SSOInfo vistSsoCookie(HttpServletRequest request) { 
Cookie[] cookies = getAllCookies(request);
if(cookies == null || cookies.length == 0){
return new SSOInfo(null);
}
String ticket = null;
String uid = "none";
for(Cookie cookie : cookies){
if(TICKET_GRANT_TICKET_COOKIE.equals(cookie.getName())){
ticket = cookie.getValue();
} else if(UID_COOKIE.equals(cookie.getName())){
ticket = cookie.getValue();
}
}

SSOInfo si = new SSOInfo(ticket);
si.setUid(uid);
String app = SsoManager.config.getValue(SsoManager.CONFIG_APP_ID);
si.setApp(app);

return si;
}

初次访问会返回一个ticket为null的SSOInfo。

存储线程级别的SSOInfo。(注意,上面是在filter中进行的初始化,此时请求继续分发)

SsoManager.setSSOInfo(info);

SsoManager

private static ThreadLocal tempStore = new ThreadLocal(); public static SSOInfo getSSOInfo() { return tempStore.get(); } public static void setSSOInfo(SSOInfo info) { tempStore.set(info); }

在请求中设置拦截器

1
2
3
4
5
6
 <mvc:interceptors> 
<mvc:interceptor>
<mvc:mapping path="/user/*.html" />
<bean class="com.jc.site.common.interceptor.UserAccountInterceptor"></bean>
</mvc:interceptor>
</mvc:interceptors>

UserAccountInterceptor.preHandle

1
2
if (SsoManager.validateWebTicket() ) {//登陆状态 
String userId = SsoManager.getSSOInfo().getUser().getUserId();

验证登录状态

1
2
3
4
5
6
7
8
public static boolean validateWebTicket() { 
SSOInfo si = tempStore.get();
if (si == null) {
logger.warn("The ssoinfo object is missed, check whether some unexpected operation on ThreadLocal is executed!"); return false;
}
validate2Server(si);
return si.isLogin();
}

validate2Server

1
2
3
4
5
6
7
8
9
10
11
12
private static void validate2Server(SSOInfo si) {
if (si == null) {
return;
}
if (si.isValidated()) {
return ;
}
if (si.getTicket() == null || si.getTicket().length() == 0) {
si.setValidated(true);
return;
} …………(后台是远程调用验证系统传入ticket)
}

验证系统

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@RequestMapping(value = "validate.html") 
@ResponseBody
public String validateLogin(@RequestParam(value="t", required=false)String ticket, String app,
@RequestParam(value="did", required=false)String deviceId) {
if (StringUtils.isEmpty(ticket)) {
return setErrorView("ticket值为空");
}else if(StringUtils.isEmpty(app)) {
return setErrorView("app类型不能为空");
} else if(StringUtils.isEmpty(deviceId)) {
return setErrorView("设备ID为空");
}
try {
String user = authManager.checkTGT(ticket, app);
if (user != null) {
return buildSuccessResponse(user);
} else {
return buildErrorResponse(user);
}

} catch (Exception e) {
logger.error("登录异常(Unexpected)", e);
return setErrorView("服务异常,请稍后再试");
}
}

这里以ticket作为key来从redis中获取userId的信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public String checkTGT(String tgt, String app) { 
String user = null;
try{
user = redisTemplate.get(tgt);
int tmpApp = StringUtil.getIntValue(app, SSOConstant.APP_SITE);
if(!StringUtils.isEmpty(user)){
prolongTicket(tgt,app, user, getValiateTime(tmpApp));
}

}catch(Exception e){
logger.error("检查TGT异常:",e);
}
return user;
}

以上就是认证的整个流程,下面是登陆流程

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
try { 
userInDb = loginServiceImpl.login(user);
} catch (PasswordNotMatchException e) {
if (logger.isInfoEnabled()) {
logger.info("登录失败,密码错误");
}
}

//4. 登陆成功情况下,生成ticket
user.setType(SSOConstant.APP_SITE);
String ticket = authManager.generateSiteTGT(request, response, "" + user.getType(), userInDb);

public String generateSiteTGT(HttpServletRequest request,HttpServletResponse response, String app, User user) {
String tgt = null;
try{
UUID uuid = UUID.randomUUID();
tgt = app + "-" + SSOConstant.TICKET_GRANT_TICKET + "-" + uuid.toString().replaceAll("-", "");
int tmpApp = StringUtil.getIntValue(app, SSOConstant.APP_SITE);
int longLogin = getValiateTime(tmpApp);
setupTicket(tgt, app, "", user, longLogin);

Cookie ticket = new Cookie(SSOConstant.TICKET_GRANT_TICKET_COOKIE, tgt);
String domain = PropertiesUtil.getString(SSOConstant.PROPERY_DOMAIN);
ticket.setDomain(domain);
ticket.setPath("/");
ticket.setMaxAge(longLogin);

response.addCookie(ticket);
}catch(Exception e){
logger.error("生成TGT异常:",e);
}
return tgt;
}



private void setupTicket(String ticket, String app, String deviceId, User user, int longLogin) {
if (ticket == null) {
return ;
}

if(longLogin < 1){
//保存30分钟
longLogin = SSOConstant.TICKET_GRANT_TICKET_TIME_OUT_DEFAULT;
}
String oldTicket = redisTemplate.get(app + "_" + user.getUserId());
if (oldTicket != null) {
redisTemplate.delKey(oldTicket);
}
redisTemplate.setex(ticket, longLogin, user.getId() + ":" + user.getUserId());
redisTemplate.setex(app + "_" + user.getUserId(), longLogin, ticket);
}