手记

《SSO CAS单点系列》之 自己动手实现一个属于自己的SSO认证服务器!

作者: 常明,Java架构师
[请尊重原创,盗版必究,转载请指明出处]

上篇《实现一个SSO认证服务器是这样的》中,我们详细讲述了实现SSO的基本思路,本篇我们按照这个思路,亲自动手实现一个轻量级的SSO认证中心。
除了认证中心,我们还要改造系统应用的登录登出部分,使之与认证中心交互,共同完成SSO。

因此我们的实现分成两大部分,一个是SSO Server,代表认证中心,另一个是SSO Client,代表使用SSO系统应用的登录登出组件。我们给我们实现的这个SSO工程起个名字,叫Nebula。


我们先讨论下Nebula中几个关键问题的实现:

1. 登录令牌token的实现

前面我们讨论了,系统把用户重定向导向认证中心并登录后,认证中心要把登录成功信息通过令牌方式告诉给应用系统。认证中心会记录下来自某个应用系统的某个用户本次通过了认证中心的认证所涉及的基本信息,并生成一个登录令牌token,认证中心需要通过URL参数的形式把token传递回应用系统,由于经过客户端浏览器,故令牌token的安全性很重要。

因此令牌token的实现要满足三个条件:

首先,token具有唯一性,它代表着来自某应用系统用户的一次成功登录。我们可以利用java util包工具直接生成一个32位唯一字符串来实现。

String token = UUID.randomUUID().toString();

同时,我们定义一个javabean, TokenInfo 来承载token所表示的具体内容,即某个应用系统来的某个用户本次通过了认证中心

public class TokenInfo {
 private int userId;   //用户唯一标识ID
 private String username;  //用户登录名
 private String ssoClient;  //来自登录请求的某应用系统标识
 private String globalId;  //本次登录成功的全局会话sessionId
 ...
}

token和tokenInfo形成了一个<key,value>形式的键值对,后续应用系统向认证中心验证token时还会用到。

其次,token存在的有效期间不能过长,这是出于安全的角度,例如token生存最大时长为60秒。

我们可以直接利用redis特性来实现这一功能。redis本质就是<key,value>键值对形式的内存数据库,并且这个键值对可以设置有效时长。

第三,token只能使用一次,用完即作废,不能重复使用。这也是保证系统安全性。

我们可以定义一个TokenUtil工具类,来实现<token,tokenInfo>键值对在redis中的操作,主要接口如下:

public class TokenUtil {
 ...
 // 存储临时令牌到redis中,存活期60秒
 public static void setToken(String tokenId, TokenInfo tokenInfo){
  ...  
 }
 //根据token键取TokenInfo
 public static TokenInfo getToken(String tokenId){
 ...
 }
 //删除某个 token键值
 public static void delToken(String tokenId){
 ...
 }
}

2. 全局会话和本地会话的实现
用户登录成功后,在浏览器用户和认证中心之间会建立全局会话,浏览器用户与访问的应用系统之间,会建立本地局部会话。

为简便,可以使用web应用服务器(如tomcat)提供的session功能来直接实现。

这里需要注意的是,我们需要根据会话ID(即sessionId)能访问到这个session。因为根据前面登出流程说明,认证中心的登出请求不是直接来自连接的浏览器用户,可能来自某应用系统。认证中心也会通知注册的系统应用进行登出。

这些请求,都是系统之间的交互,不经过用户浏览器。系统要有根据sessionId访问session的能力。同时,在认证中心中,还需要维护全局会话ID和已登录系统本地局部会话ID的关系,以便认证中心能够通知已登录的系统进行登出处理。

为了安全,目前的web应用服务器,如tomcat,是不提供根据sessionId访问session的能力的,那是容器级范围内的能力。我们需要在自己的应用中,自己维护一个sessionId和session直接的对应关系,我们把它放到一个Map中,方便需要时根据sessionId找到对应的session。同时,我们借助web容器提供的session事件监听能力,程序来维护这种对应关系。

认证中心涉及到两个类,GlobalSessions和GlobalSessionListener,相关代码如下:

public class GlobalSessions {
 //存放所有全局会话
 private static Map<String, HttpSession> sessions =
        new HashMap<String,HttpSession>();

 public static void addSession(String sessionId,
                      HttpSession session) {
  sessions.put(sessionId, session);
 }

 public static void delSession(String sessionId) {
  sessions.remove(sessionId);
 }

 //根据id得到session
 public static HttpSession getSession(String sessionId) {
  return sessions.get(sessionId);
 }
}

public class GlobalSessionListener implements
                    HttpSessionListener{

 public void sessionCreated(HttpSessionEvent httpSessionEvent) {  
  GlobalSessions.addSession(
         httpSessionEvent.getSession().getId(),
         httpSessionEvent.getSession());  
 }

 public void sessionDestroyed(HttpSessionEvent httpSessionEvent) {  
 GlobalSessions.delSession(httpSessionEvent.getSession().getId());
 }  

}

SSO Client对应的是LocalSessions和LocalSessionListener,实现方式同上。

3. 应用系统和认证中心之间的通信
根据SSO实现流程,应用系统和认证中心之间需要直接通信。如应用系统需要向认证中心验证令牌token的真伪,应用系统通知认证中心登出,认证中心通知所有已注册应用系统登出等。这是Server之间的通信,如何实现呢?我们可以使用HTTP进行通信,返回的消息应答格式可采用JSON格式。

Java的net包,提供了http访问服务器的能力。这里,我们使用apache提供的一个更强大的开源框架,httpclient,来实现应用系统和认证中心之间的直接通信。JSON和JavaBean之间的转换,目前常用的有两个工具包,一个是json-lib,还有一个是Jackson,Jackson效率较高,依赖包少,社区活跃度大,这里我们使用Jackson这个工具包。

如应用系统向认证中心发送token验证请求的代码片段如下:

//向认证中心发送验证token请求  
String verifyURL = "http://" + server
                 + PropertiesConfigUtil.getProperty("sso.server.verify");

HttpClient httpClient = new DefaultHttpClient();

//serverName作为本应用标识
HttpGet httpGet = new HttpGet(verifyURL + "?token=" + token  
               + "&localId=" + request.getSession().getId());
try{
 HttpResponse httpResponse = httpClient.execute(httpGet);
 int statusCode = httpResponse.getStatusLine().getStatusCode();

 if (statusCode == HttpStatus.SC_OK){
  String result = EntityUtils.toString(httpResponse.getEntity(), "utf-8");
  //解析json数据
  ObjectMapper objectMapper = new ObjectMapper();
  VerifyBean verifyResult = objectMapper.readValue(result, 
                                   VerifyBean.class);

  //验证通过,应用返回浏览器需要验证的页面
  if(verifyResult.getRet().equals("0")){
    Auth auth = new Auth();
    auth.setUserId(verifyResult.getUserId());
    auth.setUsername(verifyResult.getUsername());
    auth.setGlobalId(verifyResult.getGlobalId());
    request.getSession().setAttribute("auth", auth); //建立本地会话

    return "redirect:http://" + returnURL;  
 }
}

}catch(Exception e){
 return "redirect:" + loginURL;
}


核心实现细节讨论清楚了,我们就可以根据上篇登录登出操作流程,定义Nebula Server和Nebula Client所提供的接口。为了解释方便,我们把上篇刻画的登录登出时系统之间调用的时序交互图重新展示在这里:

首次登录时:

系统登出时:

Nebula Server认证中心包含四个重要相关接口,分别如下:

说明:此接口主要接受来自应用系统的认证请求,此时,returnURL参数需加上,用以向认证中心标识是哪个应用系统,以及返回该应用的URL。如用户没有登录,应用中心向浏览器用户显示登录页面。如已登录,则产生临时令牌token,并重定向回该系统。上面登录时序交互图中的2和此接口有关。

当然,该接口也同时接受用户直接向认证中心登录,此时没有returnURL参数,认证中心直接返回登录页面。

说明: 处理浏览器用户登录认证请求。如带有returnURL参数,认证通过后,将产生临时认证令牌token,并携带此token重定向回系统。如没有带returnURL参数,说明用户是直接从认证中心发起的登录请求,认证通过后,返回认证中心首页提示用户已登录。上面登录时序交互图中的3和此接口有关。

说明:认证应用系统来的token是否有效,如有效,应用系统向认证中心注册,同时认证中心会返回该应用系统登录用户的相关信息,如ID,username等。上面登录时序交互图中的4和此接口有关。

说明:登出接口处理两种情况,一是直接从认证中心登出,一是来自应用重定向的登出请求。这个根据gId来区分,无gId参数说明直接从认证中心注销,有,说明从应用中来。接口首先取消当前全局登录会话,其次根据注册的已登录应用,通知它们进行登出操作。上面登出时序交互图中的2和4与此接口有关。


Nebula Client连接组件包含两个重要接口:

说明:接收来自认证中心携带临时令牌token的重定向,向认证中心/auth/verify接口去验证此token的有效性,如有效,即建立本地会话,根据returnURL返回浏览器用户的实际请求。如验证失败,再重定向到认证中心登录页面。上面登录时序交互图中的4与此接口有关。

说明:处理两种情况,一种是浏览器向本应用接口发出的直接登出请求,应用会消除本地会话,调用认证服务器/auth/logout接口,通知认证中心删除全局会话和其它已登录应用的本地会话。 如果是从认证中心来的登出请求,此时带有localId参数,接口实现会直接删除本地会话,返回字符串"ok"。上面登出时序交互图中的1和2与此接口有关。

至此,我们把整个Nebula Server和Nebula Client实现细节都介绍清楚了。有了核心代码片段、有了详细接口说明,我想你能够自己动手实现这个Nebula。当然,笔者后续会整理相关工程代码,以某种适当形式开放给本社群会员们!有什么问题可直接给本公众号发消息,笔者收集后会统一回答。
接下来,我们介绍业界影响最大、使用最多的SSO开源解决方案,CAS。

本文出自慕课网,转载请注明出处,侵权必究。
27人推荐
随时随地看视频
慕课网APP

热门评论

如果是从认证中心来的登出请求,此时带有localId参数,接口实现会直接删除本地会话,返回字符串"ok"。

是不是也要删除全局、和所有子系统的局部会话?!

通过本篇文章自己写了,实现了单点登录登出功能。源代码分享给大家https://pan.baidu.com/s/1qYI6Has 。有疑问可以回复我,但是时间长了不敢保证我还熟悉自己写的代码。

大神,求一份DEMO源码啊 不甚感激

查看全部评论