• java web SSO单点登录


    第一篇:

    Web应用系统的演化总是从简单到复杂,从单功能到多功能模块再到多子系统方向发展。

    .当前的大中型Web互联网应用基本都是多系统组成的应用群,由多个web系统协同为用户提供服务。

    多系统应用群,必然意味着各系统间既相对独立,又保持着某种联系。

    独立,意味着给用户提供一个相对完整的功能服务,比如C2C商城,比如B2C商城。联系,意味着从用户角度看,不管企业提供的服务如何多样化、系列化,在用户看来,仍旧是一个整体,用户体验不能受到影响。

    譬如用户的账号管理,用户应该有一个统一账号,不应该让用户在每个子系统分别注册、分别登录、再分别登出。系统的复杂性不应该让用户承担。

    登录用户使用系统服务,可以看做是一次用户会话过程。在单Web应用中,用户登录、登录状态判断、用户登出等操作,已有很常规的解决方案实现。

    在多系统应用群中,这个问题就变得有些复杂,以前本不是问题的问题,现在可能就变成了一个重大技术问题。我们要用技术手段,屏蔽系统底层本身的技术复杂性,给用户提供自然超爽的用户体验。

    这就是我们所说的单点登录问题,即SSO(Single Sign On)。当然,我们这里主要讨论的是Web系统,确切地讲,应该叫Web SSO。

    下面我们来看一个现实中SSO的例子,例如阿里系统:

    阿里目前给用户提供的服务很庞大,种类也很繁多,我们看几个典型系统应用:www.taobao.com 淘宝应用、www.tmall.com 天猫应用、
    www.alitrip.com 阿里旅游。这些应用,当用户访问时,都需要登录

    显然,对用户来说,他不希望每个子应用分别登录,因为这都是阿里服务,在用户看来,就相当于一个大系统。

    当我在一个应用如淘宝上登录后,再访问阿里旅游、天猫等其它系统,我们发现,系统都显示已登录状态。

    当在任意一系统退出登录后,再刷新访问其它系统,均已显示登出状态。

    可以看出,阿里实现了SSO。实际上,几乎所有提供复杂服务的互联网公司,都实现了SSO,如阿里、百度、新浪、网易、腾讯、58...

    SSO问题,是大中型Web应用经常碰到的问题,是Java架构师需要掌握的必备技能之一,中高级以上Web工程师都应对它有个了解。

    SSO有啥技术难点?为什么我们不能像解决单Web应用系统登录那样自然解决?为说清楚这一问题,我们得先了解下单应用系统下,用户登录的解决方案。

    我们讨论的应用是Web应用,大家知道,对于Web应用,系统是Browser/Server架构,Browser和Server之间的通信协议是HTTP协议。

    HTTP是一个无状态协议。即对服务器来说,每次收到的浏览器HTTP请求都是单一独立的,服务器并不考虑两次HTTP请求是否来自同一会话,即HTTP协议是非连接会话状态协议。

    对于Web应用登录,意味着登录成功后的后续访问,可以看做是登录用户和服务端的一次会话交互过程,直到用户登出结束会话。

    如何在非连接会话协议之上,实现这种会话的管理? 我们需要额外的手段。

    通常有两种做法,一种是通过使用HTTP请求参数传递,这种方式对应用侵入性较大,一般不使用。

    另一种方式就是通过cookie。

    cookie是HTTP提供的一种机制,cookie代表一小撮数据。服务端通过HTTP响应创建好cookie后,浏览器会接收下来,下次请求会自动携带上返回给服务端。

    利用这个机制,我们可以实现应用层的登录会话状态管理。例如我们可以把登录状态信息保存在cookie中,这是客户端保存方式。

    由于会话信息在客户端,需要维护其安全性、需要加密保存、携带量会变大,这样会影响http的处理效率,同时cookie的数据携带量也有一定的限制。

    比较好的方式是服务端保存,cookie只保存会话信息的句柄。即在登录成功后,服务端可以创建一个唯一登录会话,并把会话标识ID通过cookie返回给浏览器,浏览器下次访问时会自动带上这个ID,服务端根据ID即可判断是此会话中的请求,从而判断出是该用户,这种操作直到登出销毁会话为止。

    令人高兴的是,我们使用的Web应用服务器一般都会提供这种会话基础服务,如Tomcat的Session机制。也就是说,应用开发人员不必利用Cookie亲自代码实现会话的创建、维护和销毁等整个生命周期管理,这些内容服务器Session已经提供好了,我们只需正确使用即可。

    当然,为了灵活性和效率,开发人员也可直接使用cookie实现自己的这种会话管理。

    对于Cookie,处于安全性考虑,它有一个作用域问题,这个作用域由属性Domain和Path共同决定的。也就是说,如果浏览器发送的请求不在此Cookie的作用域范围内,请求是不会带上此Cookie的。

    Path是访问路径,我们可以定义/根路径让其作用所有路径,Domain就不一样了。我们不能定义顶级域名如.com,让此Cookie对于所有的com网站都起作用,最大范围我们只能定义到二级域名如.taobao.com,而通常,企业的应用群可能包含有多个二级域名,如taobao.com、tmail.com、alitrip.com等等。

    这时,解决单系统会话问题的Cookie机制不起作用了,多系统不能共享同一会话,这就是问题的所在!
    当然,有的同学会说:我把所有的应用统一用三级域名来表示,如a.taobao.com、b.taobao.com、c.taobao.com或干脆用路径来区分不同的应用如www.taobao.coma、www.taobao.com、www.taobao.comc,这样cookie不就可以共享了么?

    事实是成立的,但现实应用中,多域名策略是普遍存在的,也有商业角度的考虑,这些我们必须要面对。

    退一步讲,即使cookie可以共享了,服务端如何识别处理这个会话?这时,我们是不能直接使用服务器所提供的Session机制的,Session是在单一应用范围内,共享Session需要特殊处理。

    更复杂的情况是,通常这些子系统可能是异构的,session实现机制并不相同,如有的是Java系统,有的是PHP系统。共享Session对原系统入侵性很大。

    至此,SSO技术问题这里讲清楚了。那我们有没有更好的通用解决方案?答案肯定是有的,但比较复杂,这也是我们专题讨论的理由。总体来说,我们需要一个中央认证服务器,来统一集中处理各子系统的登录请求。这是入门,后续会有系列文章深层次探讨。

    图片描述

     

     

    第二篇:

    上篇我们引入了SSO这个话题《15分钟了解SSO是个什么鬼!》。本篇我们一步步深入分析SSO实现机理,并亲自动手实现一个线上可用的SSO认证服务器!

    首先,我们来分析下单Web应用系统登录登出的实现机理。Web系统登录登出功能,通常属于系统安全管理模块的一部分。如上篇所说,登录,意味着用户与系统之间的一次会话开始,登出,意味着本次会话的结束。

    下图列出整个登录登出会话过程中,用户与系统之间的HTTP交互过程:

    图片描述

    如图,服务器内部又做了哪些工作呢?

    通常服务端在用户登录请求到来时(圈1),会先做认证(Authentication)操作,就是证明这个浏览器请求用户是合法系统用户,一般情况就是验证用户名和密码。

    认证通过后,系统紧接着给这个合法用户授权(Authorization),就是根据该用户在此系统中的权限定义,绑定正确的权限信息,为用户后续正确使用系统功能提供安全保障。

    最后建立会话,这个可以基于服务器容器提供的Session机制或自己基于Cookie开发的类似功能,建立起本次会话。

    登录成功后,当浏览器后续请求来时(圈2),服务器需进行登录状态判断,即判别是否处于会话状态,从而识别操作是否是本次登录用户的操作。

    登出时,服务端取消会话,本次登录用户会话结束。下次请求时,系统即判断是非登录用户。

    上面分析了单Web应用登录登出实现机理。那对于多系统的SSO,该如何实现呢?我们先分析下基本实现思路。

    有上面分析可知,单Web应用登录,主要涉及到认证、授权、会话建立、取消会话等几个关键环节。推广到多系统,每个系统也会涉及到认证、授权、会话建立取消等工作。那我们能不能把每个系统的认证工作抽象出来,放到单独的服务应用中取处理,是不是就能解决单点登录问题?

    思考方向是正确的,我们把这个统一处理认证服务的应用叫认证中心。当用户访问子系统需要登录时,我们把它引到认证中心,让用户到认证中心去登录认证,认证通过后返回并告知系统用户已登录。当用户再访问另一系统应用时,我们同样引导到认证中心,发现已经登录过,即返回并告知该用户已登录

    图片描述

    由上图可以看出,原先在各系统中的认证模块,已经剥离出来放到独立的认证中心中去执行。至于授权,每个应用都不一样,可维持原样。会话部分,其实分解成了全局会话和局部会话,这个后面再详细解释。

    思路是有了,但真正实现这一认证服务器我们还得要具体解决几个关键问题

    一、登录信息传递问题

    应用系统将登录请求转给认证中心,这个很好解决,我们一个HTTP重定向即可实现。现在的问题是,用户在认证中心登录后,认证中心如何将消息转回给该系统?

    这是在单web系统中不存在的问题。我们知道HTTP协议传递消息只能通过请求参数方式或cookie方式,cookie跨域问题不能解决,我们只能通过URL请求参数。

    我们可以将认证通过消息做成一个令牌(token)再利用HTTP重定向传递给应用系统。但现在的关键是:该系统如何判断这个令牌的真伪?如果判断这个令牌确实是由认证中心发出的,且是有效的?

    我们还需要应用系统和认证中心之间再来个直接通信,来验证这个令牌确实是认证中心发出的,且是有效的。由于应用系统和认证中心是属于服务端之间的通信,不经过用户浏览器,相对是安全的。
    图片描述
    用户首次登录时流程如下:

    1.用户浏览器访问系统A需登录受限资源。

    2.系统A发现该请求需要登录,将请求重定向到认证中心,进行登录。

    3.认证中心呈现登录页面,用户登录,登录成功后,认证中心重定向请求到系统A,并附上认证通过令牌。

    4.系统A与认证中心通信,验证令牌有效,证明用户已登录。

    5.系统A将受限资源返给用户。

    图片描述

    已登录用户首次访问应用群中系统B时:

    1. 浏览器访问另一应用B需登录受限资源。

    2. 系统B发现该请求需要登录,将请求重定向到认证中心,进行登录。

    3. 认证中心发现已经登录,即重定向请求响应到系统B,附带上认证令牌。

    4. 系统B与认证中心通信,验证令牌有效,证明用户已登录。

    5. 系统B将受限资源返回给客户端。

    二、登录状态判断问题

    用户到认证中心登录后,用户和认证中心之间建立起了会话,我们把这个会话称为全局会话。当用户后续访问系统应用时,我们不可能每次应用请求都到认证中心去判定是否登录,这样效率非常低下,这也是单Web应用不需要考虑的。

    我们可以在系统应用和用户浏览器之间建立起局部会话,局部会话保持了客户端与该系统应用的登录状态,局部会话依附于全局会话存在,全局会话消失,局部会话必须消失。

    用户访问应用时,首先判断局部会话是否存在,如存在,即认为是登录状态,无需再到认证中心去判断。如不存在,就重定向到认证中心判断全局会话是否存在,如存在,按1提到的方式通知该应用,该应用与客户端就建立起它们之间局部会话,下次请求该应用,就不去认证中心验证了。

    三、登出问题

    用户在一个系统登出了,访问其它子系统,也应该是登出状态。要想做到这一点,应用除结束本地局部会话外,还应该通知认证中心该用户登出。

    认证中心接到登出通知,即可结束全局会话,同时需要通知所有已建立局部会话的子系统,将它们的局部会话销毁。这样,用户访问其它应用时,都显示已登出状态。

    图片描述

    整个登出流程如下:

    1.客户端向应用A发送登出Logout请求。

    2.应用A取消本地会话,同时通知认证中心,用户已登出。

    3.应用A返回客户端登出请求。

    4.认证中心通知所有用户登录访问的应用,用户已登出。

    到此,我们完整介绍了实现一个SSO认证服务器的基本思路方法,后面我们就按照这个方法,自己动手利用Java一步步实现一个SSO认证服务器。

    图片描述

     

    第三篇:

    上篇《实现一个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。

    图片描述

     

    第四篇:

    上一篇《相遇篇》我们作为新手初步了解了CAS,安装并进行了简单体验。这篇我们进一步深入认识CAS。
    CAS原理和我们前面自己开发的Nebula基本一致,所有的系统应用都会引导到CAS Server认证中心去登录。登录成功后,认证中心会产生一个票据叫TGT(Ticket Granting Ticket),TGT即代表了用户与认证中心直接的全局会话。TGT存在,表明该用户处于登录状态。

    TGT并没有放在Session中,也就是说,CAS全局会话的实现并没有直接使用Session机制,而是利用了Cookie自己实现的,这个Cookie叫做TGC(Ticket Granting Cookie),它存放了TGT的id,认证中心服务端实现了TGT。

    我们利用上篇安装的CAS,在认证中心登录下,看下登录前后cookie的变化。显然,在登录后,多出一个叫CASTGC的Cookie,它来维持全局会话。

    登录前:

    q1

    登录后:

    q2

    如果是应用系统登录,同理,会引导到认证中心进行登录,登录成功后再重定向回应用系统,这时会带上一个登录令牌,告知系统应用登录成功。

    这个令牌,在CAS中叫做ST(Service Ticket)服务票据,它的作用和Nebula的token类似。当然,和Nebula一样,应用系统收到ST后,会直接向CAS Server去验证,验证通过后,应用系统即可建立本地会话,返回用户访问的受限资源。
    q3
    下面我们利用前面搭建的环境,看下从应用系统首次登录时的情况。首先访问www.ssoclient.com:81/index.do,系统重定向到www.cas.com认证中心,输入用户名密码后,携带ST重定向回www.ssoclient.com:81/index.do 下面是重要的三个HTTP走向。
    q4
    其中第一个,我们看到,登录成功后,系统会设置CASTGC Cookie,同时重定向回应用时带上了一个ticket变量,这个就是ST。

    CAS官网给出了详细的用户登录时序图,非常详细,这里就不重新画轮子了,直接引用如下:

    q5

    从流程图可以看出,和Nebula的时序流程图几乎一模一样。当然,作为成熟开源的CAS,考虑的应用场景更加丰富些。到目前为止,其CAS认证协议已经持续发展了三个版本,v1实现了单点登录,此版本跟Nebula实现的功能差不多。

    v2版则重点增加了proxy模式,代理模式是一种更复杂形式的认证,即认证的Web应用(CAS Client)可以作为代理直接访问需要认证的后端服务(如邮件服务器),浏览器用户无需再和后端服务直接进行认证交互。这个比较复杂,在门户中可能会用到,我们这里不做讨论。

    v3版本则更加丰富了协议,认证中心验证票据后不仅可以返回身份ID,还可携带用户昵称、性别等其他用户基本信息。

    CAS不仅提供专有的CAS协议,还同时支持SAML 1.1、OpenID、OAuth等标准开放协议,具有更广泛的可用性。

    CAS工程采用模块化插件化设计思想,其核心子工程是cas-server-core,提供CAS最核心的功能。其它功能以插件形式提供,放在不同子工程中。

    CAS虽然以Web应用的形式提供服务,但从理论上讲,CAS提供的核心功能是基于票据令牌方式的认证,这种认证,和是否是Web应用方式运行无关。故实际上,cas-server-core提供的核心模块只有两部分,一是票据Ticket,包括票据的产生、查询、删除、存储等各种操作。另一个是认证,提供多种认证方式。

    当然,CAS是支持Web应用的单点登录,确切说是Web SSO解决方案。故cas-server-core还提供了web层的一些最基本逻辑框架,如登录请求接收。

    作为独立运行的Web应用,CAS还需提供与浏览器用户的交互,与需要认证的应用系统交互,这些逻辑,绝大部分放在cas-server-webapp和cas-server-webapp-support两个子工程中。CAS认证中心采用Spring MVC + WebFlow实现,cas-server-webapp提供了相关交互页面和web工程配置,而cas-server-webapp-support提供了基于WebFlow的相关认证流程逻辑代码,它将调用后端cas-server-core提供的票据和认证功能。

    CAS应用的整体架构官方提供了一个比较清晰的架构图,如下所示:

    q6

    对CAS工程内部有个大致认识后,我们开始动手实践,我们一步步实践如何在工程中实际应用CAS。

    首先,我们来看下如何修改登录登出流程及相关页面,满足实际应用需求。我们让CAS完成和我们前面Nebula Server一致的登录登出流程并提供一致的交互页面。

    CAS页面采用主题模板方式,我们的需求其实就是相当于定制一套自己的主题模板。

    在WEB-INF/cas.properties 文件中找到:

    cas.themeResolver.defaultThemeName=cas-theme-default
    cas.viewResolver.basename=default_views

    这两个参数指定了样式定义文件和模板定义文件,他们在WEB-INF/cas-servlet.xml 中引用到。修改这两个值,使用自己定义的文件:

    cas.themeResolver.defaultThemeName=nebula-theme
    cas.viewResolver.basename=nebula_views

    仿照resources/cas-theme-default.properties 创建nebula-theme.properties文件,其内容是定义了css文件和js文件的位置。由于我们没用到js,只定义如下内容:

    standard.custom.css.file=/css/nebula.css

    创建/css/nebula.css文件,将nebula中的css文件内容copy过来。

    同理仿照resources/default_views.properties 创建nebula_views.properties,CAS缺省定义了很多页面模板,代表不同流程节点需要给用户的接口页面。

    按照我们前面开发Nebula所使用的登录登出流程,只提供两个页面,一个是登录页面,用以处理用户登录问题,一个是首页,登录前和登录后给用户的接口页面。因此,在nebula_views中,只定义两个模板即可。

    nebulaLoginView.(class)=org.springframework.web.servlet.view.JstlView
    nebulaLoginView.url=/WEB-INF/view/jsp/nebula/ui/login.jsp
    nebulaIndexView.(class)=org.springframework.web.servlet.view.JstlView
    nebulaIndexView.url=/WEB-INF/view/jsp/nebula/ui/index.jsp

    同时,我们将nebula中两个页面,copy到/WEB-INF/view/jsp/nebula/ui 下并做相应调整。

    按照CAS登录逻辑,对login.jsp,除了username、password两个登录参数外,form中还需要增加三个隐藏参数,lt、execution、_eventId,lt主要是为了增加页面登录安全性,防止重复提交,其它两个确保正确走webflow登录流程。

    CAS的登录登出过程由webflow定义,这样可以更好地实现登录登出过程的定制化。登录的定义流程说明是login-webflow.xml,登出时logout-webflow.xml。我们分别copy这两个文件为nebula-login-webflow.xml和nebula-logout-webflow.xml作为流程定义的基础,并在cas-servlet.xml中修改登录登出流程定义文件为上述文件。

    这里特别提到cas-servlet.xml文件,cas-servlet.xml是CAS的spring mvc配置文件,文件中定义了服务接口和定义处理的Controller:

    q8

    我们看到上面为啥没有/login 和 /logout 接口呢?其实他们已经配置成webflow流程入口,分别走nebula-login-webflow.xml和nebula-logout-webflow.xml流程。

    q9

    id即对应的path /login,logout同理,在文件下面能够找到。

    根据要求,我们修改nebula-login-webflow.xml和nebula-logout-webflow.xml中内容。对于nebula-login-webflow.xml,去掉不必要的流程节点,如warn和proxy等,同时将错误响应直接指向登录页面,故登录流程中只提供两个响应页面即nebulaLoginView和nebulaIndexView。

    对于nebula-logout-webflow.xml,登出正常结束后直接再走登录流程。这样和Nebula的登录登出处理流程基本一致。

    OK,我们看下修改后的效果。先看下直接在认证中心登录登出的情况:

    输入www.cas.com即显示登录页面:

    q10

    登录成功后显示首页:

    q11

    点击登出,又显示登录页面,从应用系统登录同理。至此,我们改造了CAS认证中心登录登出流程和相关界面模板,和Nebula认证中心给用户的体验效果基本一致。

    q12

     

     

    第五篇:

    上一篇《相识篇》我们进行了登录登出流程定制和页面定制实践,本篇我们继续通过实践深入认识CAS。

    记住密码也是我们登录常提供的功能,CAS本身已经提供。下面我们来看下如何配置来实现这个功能,我们还是继续使用前面的例子作为实践。

    1. 修改deployerConfigContext.xml,找到bean id="authenticationManager" 定义区,里面添加如下内容:

    <property name="authenticationMetaDataPopulators">
      <util:list>
        <bean class="org.jasig.cas.authentication.principal.RememberMeAuthenticationMetaDataPopulator" />
      </util:list>
    </property>

    此处的修改主要是将页面提交的rememberMe属性传递到内部生成的authentication对象中。

    2. 修改登录流程文件内容,根据上篇实践工程,我们现在使用的是nebula-login-webflow.xml

    修改credential为:

    <var name="credential" class="org.jasig.cas.authentication.RememberMeUsernamePasswordCredential" />

    viewLoginForm中增加rememberMe绑定变量:

    <binder>
      <binding property="username" />
      <binding property="password" />
      <binding property="rememberMe" />
    </binder>

    此处主要是接收登录页面的rememberMe属性。

    3.ticketExpirationPolicies.xml文件中,注释掉原grantingTicketExpirationPolicy,启用新的票据过期策略:

    <bean id="grantingTicketExpirationPolicy" class="org.jasig.cas.ticket.support.RememberMeDelegatingExpirationPolicy">
     <property name="sessionExpirationPolicy">
       <bean class="org.jasig.cas.ticket.support.TimeoutExpirationPolicy">
         <constructor-arg index="0" value="7200000" />
       </bean>
     </property>
     <property name="rememberMeExpirationPolicy">
      <bean class="org.jasig.cas.ticket.support.TimeoutExpirationPolicy">
        <constructor-arg index="0" value="604800000" />
      </bean>
     </property>
    </bean>

    上述TGT票据过期策略定义的是如果前端页面提交时“remember me”选项没有选中,定义的是false,执行2小时用户没有操作应用TGT过期策略,如果选中了免登录,7天过期。当然,可以根据情况修改上面的数字,注意单位是毫秒。

    这是remember me这个功能的核心。我们知道TGT代表的是全局会话,remember me意味着全局会话的有效期延长,上述就是定义TGT的过期策略。

    4. 最后是修改登录页面,增加“记住我”remember me选项:

    <div><input type="checkbox" name="rememberMe" id="rememberMe" checked="true" />记住我</div>

    好了,做了如上修改,我们启动工程,直接在认证中心登录,显示的登录页面为:

    q2

    登录成功后,我们关掉浏览器,再打开,输入www.cas.com,则显示

    q3

    此时,记住密码功能生效。

    remember me主要涉及的是TGT的有效期问题,这里我们再深入讨论一下。TGT的有效期控制采用策略模式实现。缺省是TicketGrantingTicketExpirationPolicy,即应用空闲超过2小时,TGT存在超过8小时即过期策略。CAS提供了几种Policy,一般都能满足我们的需求,当然我们也可以根据需要自己定义特殊的Policy策略。

    TGT缺省采用虚拟机内存方式存储,其生命周期由Policy控制。同时ticket的时效是被动后验方式,在这种情况下,我们还需要一个清除器定期清除内存中过期的还未经过处理的ticket。这个清除器在ticketRegistry.xml中定义,叫ticketRegistryCleaner,定时任务采用spring集成的Quartz实现。

    当我们采用第三方Cache工具如redis、memcached等能控制数据存储时效的其它存储策略实现时,这时ticketRegistryCleaner就不再需要了。但要注意,存储数据时的有效时长要大于等于policy定义的有效时效。

    我们知道,TGT的ID在客户端TGC Cookie中,因此保持全局会话,不仅要服务端TGT这个票据对象存在,同时TGC这个Cookie也不能过期。在ticketGrantingTicketCookieGenerator.xml中,缺省情况下p:cookieMaxAge设置为-1,表示长期有效。这里不需要修改,我们只需要服务端用policy控制TGT的有效期就可以了。

    CAS给出的缺省例子是将账户信息(用户名/密码)放在配置文件中,实际运行系统,账户信息通常是在数据库中保存。现在我们就配置一下如何对数据库中保存的账户进行认证。

    1. 首先,在mysql数据库中建立一张账户表account,并添加一些账户例子。
    q5
    注意,密码我们使用了MD5加密方式。

    2. 在deployerConfigContext.xml文件中找到bean id="primaryAuthenticationHandler" 区并注释掉,替换成如下内容:

    <bean id="primaryAuthenticationHandler" 
      class="org.jasig.cas.adaptors.jdbc.QueryDatabaseAuthenticationHandler">   
     <property name="dataSource" ref="dataSource" />   
     <property name="sql" value="select password from account where username=? " />  <property  name="passwordEncoder"  ref="passwordEncoder"/>
    </bean>

    也就是说,我们替换了认证插件,采用SQL语句访问数据库获取密码认证方式。QueryDatabaseAuthenticationHandler在cas-server-support-jdbc子工程中。

    CAS提供了多种认证方式,除JDBC访问数据库外,还可以使用LDAP、x509、spnego等方式,这些都以子工程插件的方式提供。当然,我们同样可以开发自己需要的插件。

    由于我们密码使用了MD5加密方式,我们引进了passwordEncoder,定义如下:

    <bean id="passwordEncoder"      
        class="org.jasig.cas.authentication.handler.DefaultPasswordEncoder" autowire="byName">          
      <constructor-arg value="MD5"/>   
      <property name="characterEncoding">   
        <value>UTF-8</value>   
      </property>
    </bean>

    然后我们定义数据源,这在spring工程中很常见:

    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource">   
      <property name="driverClassName">   
        <value>com.mysql.jdbc.Driver</value>   
      </property>   
      <property name="url">   
        <value>jdbc:mysql://127.0.0.1:3306/jiweibu?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true</value>   
      </property>   
      <property name="username" value="root" />   
      <property name="password" value="123456" />   
      <property name="initialSize" value="10" />
      <property name="maxActive" value="500" />  
      <property name="maxWait" value="60000" />  
      <property name="validationQuery"><value>select 1</value></property>
    </bean>

    3. 在pom.xml中引入下面三个工程包,包括CAS JDBC认证插件、Mysql驱动还有dbcp连接池:

    <dependency>
      <groupId>org.jasig.cas</groupId>
      <artifactId>cas-server-support-jdbc</artifactId>
      <version>${project.version}</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>mysql</groupId>
      <artifactId>mysql-connector-java</artifactId>
      <version>5.1.26</version>
    </dependency>
    <dependency>
      <groupId>commons-dbcp</groupId>
      <artifactId>commons-dbcp</artifactId>
      <version>1.4</version>
    </dependency>

    重新编译build,运行修改的CAS认证中心,使用数据库中的账号尝试登录。

    至此,我们介绍了如何修改登录登出流程,如何修改CAS Server相关界面,如何实现记住密码免登录功能,以及如何访问数据库中账号去认证。通过这些实践活动,我们对CAS有了相当的理解,可以应对一般的应用场景了。

    后面,我们将进一步深入CAS,解决在大型互联网应用中,基于CAS实现SSO需要关注的问题。

    q6

     

     

    第六篇:

    前面我们对CAS做了相当的了解,也基本能够将CAS应用于生产环境。本篇笔者将结合自身实际工作经验,谈谈在大型互联网应用中,如何架构CAS的问题。内容绝对干货,值得珍藏:)

    对于大中型互联网应用,网站性能问题提到了前所未有的高度,能够应对高并发、高可用、避免单点故障是系统架构设计的基本准则。

    如果引入了SSO,那个这个认证中心就是整个应用架构中的一个及其重要的关键点,它必须满足两个基本要求:

    1.高可用,不允许发生故障。可想而知,如果认证中心发生故障,整个应用群将无法登陆,将会导致所有服务瘫痪。

    2.高并发,因为所有用户的登录请求都需要经过它处理,其承担的处理量常常是相当巨大的。

    因此,在实际生产系统中,认证中心这个关键部件通常需要进行集群,单个认证中心提供服务是非常危险的。

    当我们用CAS作为SSO解决方案时,CAS Server作为认证中心就会涉及到集群问题。对CAS Server来说,缺省是单应用实例运行的,多实例集群运行,我们需要做特殊考虑。

    考虑集群,就要考虑应用中有哪些点和状态相关,这些状态相关的点和应用的运行环境密切相关。在多实例运行下,运行环境是分布式的,这些状态相关的点需要考虑,在分布式环境下,如何保持状态的一致性。

    鉴于CAS实现方式,状态相关点有两个,一是CAS登录登出流程,采用webflow实现,流程状态存储于session中。二是票据存储,缺省是在JVM内存中。

    那么CAS集群,我们需要保证多个实例下,session中的状态以及票据存储状态,是一致的。常用的解决方案是共享,也就是说,在多CAS实例下,他们的session和票据ticket是共享的,这样就解决了一致性问题。

    CAS在Tomcat下运行的话,官方提出的建议是利用tomcat集群进行Session复制(Session Replication)。在高并发状态下,这种session复制效率不是很高,节点数增多时更是如此,实战中采用较少。

    我们可以采用共享session的技术。但笔者实践中,则采用了另外一种更灵活的方案,那就是session sticky技术。

    什么是session sticky?即将某一用户来的请求,通过前置机合理分配,始终定位在一台tomcat上,这样用户的登录登出webflow流程,始终发生在同一tomcat服务器上,保证了状态的完整性。实际上,采用这种方式,我们绕过了Session共享的需求。

    另一个问题我们绕不过去了,那就是ticket共享问题。我们知道,ticket缺省是存储于虚拟机内存中的,多个CAS Server实例,意味着多个tomcat节点,多个JVM,TicketRegistry是各自独立不共享的。

    我们是否也可使用session sticky解决呢,不可以!因为对于ticket来说,根据认证协议,访问ticket不仅来自浏览器用户请求,而且还来自CAS Client应用系统,这是一个三方合作系统。来自应用系统的请求可能会访问到另一个CAS Server节点从而导致状态不一致。

    因此我们要直面解决ticket共享问题。ticket的存储由TicketRegistry定义,缺省是DefaultTicketRegistry,即JVM内存方式实现,我们可以定义外置存储方式,让多个实例共用这个存储,以达到共享目的。

    外置存储实现方式有多种选择,如存储在数据库中、存储在Cache中、存储在内存数据库中等,CAS也提供了多种实现方式的插件,如利用memcached作为ticket存储方式的插件cas-server-integration-memcached、利用Cache的cas-server-integration-ehcache、cas-server-integration-jboss等。

    这里,使用另外一种方式,即利用目前更流行的内存数据管理系统Redis来存储Ticket。同时,为了保证redis的高可用和高并发处理,我们使用redis主从集群,Sentinel控制,故认证中心具有很好的灵活性和水平可扩展性,整个架构图如下:
    q1
    下面我们就一步步进行配置搭建:

    1.仿照cas-server-integration-memcached工程建立cas-server-integration-redis工程

    q2

    2.pom.xml中添加redis的java客户端jar包,去掉memcached中需要的jar,最后依赖包如下:

    <dependencies>
     <dependency>
      <groupId>org.jasig.cas</groupId>
      <artifactId>cas-server-core</artifactId>
      <version>${project.version}</version>
     </dependency>
     <dependency>
      <groupId>redis.clients</groupId>
      <artifactId>jedis</artifactId>
      <version>2.7.2</version>
     </dependency>
     <!-- Test dependencies -->
     <dependency>
      <groupId>org.mockito</groupId>
      <artifactId>mockito-core</artifactId>
      <version>1.9.0</version>
      <scope>test</scope>
     </dependency>
    </dependencies>
    1. 定义RedisTicketRegistry类,这个是核心,它实现了TicketRegistry接口,我们使用Jedis客户端:
    public final class RedisTicketRegistry extends AbstractDistributedTicketRegistry implements DisposableBean {
    
     /** Redis client. */
     private JedisSentinelPool jedisPool;  
     private int st_time;  //ST最大空闲时间
     private int tgt_time; //TGT最大空闲时间
    
     @Override
     protected void updateTicket(final Ticket ticket) {
       logger.debug("Updating ticket {}", ticket);
       Jedis jedis = jedisPool.getResource();
       String ticketId = ticket.getId() ;
       try {
         jedis.expire(ticketId.getBytes(), getTimeout(ticket));
       }catch (final Exception e) {
        logger.error("Failed updating {}", ticket, e);
       }finally{
        jedis.close();
       }
     }
    
     @Override
     public void addTicket(final Ticket ticket) {
    
      logger.debug("Adding ticket {}", ticket);
      Jedis jedis = jedisPool.getResource();
      String ticketId = ticket.getId() ;
    
      ByteArrayOutputStream bos = new ByteArrayOutputStream();
      ObjectOutputStream oos = null;
      try{
       oos = new ObjectOutputStream(bos);
       oos.writeObject(ticket);
      }catch(IOException e){
       logger.error("adding ticket {} to redis error.", ticket);
      }finally{
       try{ 
        if(null!=oos) oos.close();
       }catch(IOException e){
        logger.error("oos closing error when adding ticket {} to redis.", ticket);
       }
      }
    
      jedis.setex(ticketId.getBytes(), getTimeout(ticket),bos.toByteArray());
      jedis.close();
     }
    
     @Override
     public boolean deleteTicket(final String ticketId) {
    
      logger.debug("Deleting ticket {}", ticketId);
      Jedis jedis = jedisPool.getResource();
      try {
       jedis.del(ticketId.getBytes());
       return true;
      } catch (final Exception e) {
       logger.error("Failed deleting {}", ticketId, e);
       return false;
      } finally{
       jedis.close();
      }
     }
    
     @Override
     public Ticket getTicket(final String ticketId) {
      Jedis jedis = jedisPool.getResource();
      try {
       byte[] value = jedis.get(ticketId.getBytes());
       if (null==value){
        logger.error("Failed fetching {}, ticketId is null. ", ticketId);
        return null;
       }
       ByteArrayInputStream bais = new ByteArrayInputStream(value);
       ObjectInputStream ois = null;
       ois = new ObjectInputStream(bais);
       final Ticket  t = (Ticket)ois.readObject(); 
       if (t != null) {
        return getProxiedTicketInstance(t);
       }
      } catch (final Exception e) {
        logger.error("Failed fetching {}. ", ticketId, e);
      }finally{
        jedis.close();
      }
      return null;
     }
    
     /**
      * {@inheritDoc}
      * This operation is not supported.
      *
      * @throws UnsupportedOperationException if you try and call this operation.
      */
     @Override
     public Collection<Ticket> getTickets() {
       throw new UnsupportedOperationException("GetTickets not supported.");
     }
    
     /**
      * Destroy the client and shut down.
      *
      * @throws Exception the exception
      */
     public void destroy() throws Exception {
       jedisPool.destroy();
     }
    
     @Override
     protected boolean needsCallback() {
       return true;
     }
    
      /**
       * Gets the timeout value for the ticket.
       *
       * @param t the t
       * @return the timeout
       */
      private int getTimeout(final Ticket t) {
        if (t instanceof TicketGrantingTicket) {
         return this.tgt_time;
        } else if (t instanceof ServiceTicket) {
         return this.st_time;
        }
        throw new IllegalArgumentException("Invalid ticket type");
      }
    
     public void setSt_time(int st_time) {
       this.st_time = st_time;
      }
    
      public void setTgt_time(int tgt_time) {
        this.tgt_time = tgt_time;
      }
    
      public void setJedisSentinelPool(JedisSentinelPool jedisPool) {
        this.jedisPool = jedisPool;
      }
    }

    4.同理,仿照cas-server-integration-memcached编写测试用例RedisTicketRegistryTests,核心代码如下:

    @Test
    public void testWriteGetDelete() throws Exception {
      //对ticket执行增查删操作
      final String id = "ST-1234567890ABCDEFGHIJKL-crud";
      final ServiceTicket ticket = 
               mock(ServiceTicket.class, withSettings().serializable());
      when(ticket.getId()).thenReturn(id);
      registry.addTicket(ticket);
      final ServiceTicket ticketFromRegistry = 
                    (ServiceTicket) registry.getTicket(id);
      Assert.assertNotNull(ticketFromRegistry);
      Assert.assertEquals(id, ticketFromRegistry.getId());
      registry.deleteTicket(id);
      Assert.assertNull(registry.getTicket(id));
    }

    相应的配置文件ticketRegistry-test.xml定义如下:

    <bean id="poolConfig" class="redis.clients.jedis.JedisPoolConfig">  
     <property name="maxTotal"  value="4096"/>  
     <property name="maxIdle" value="200"/>  
     <property name="maxWaitMillis" value="3000"/>
     <property name="testOnBorrow" value="true" />
     <property name="testOnReturn" value="true" />
    </bean>  
    
    <bean id="jedisSentinelPool" class="redis.clients.jedis.JedisSentinelPool">
     <constructor-arg index="0" value="mymaster" />
     <constructor-arg index="1">
       <set>  
        <value>192.168.1.111:26379</value>  
       </set> 
     </constructor-arg>
     <constructor-arg index="2" ref="poolConfig"/>
    </bean>
    
    <bean id="testCase1" class="org.jasig.cas.ticket.registry.RedisTicketRegistry" >
     <property name="jedisSentinelPool" ref="jedisSentinelPool" />
     <property name="st_time" value="10" />
     <property name="tgt_time" value="1200" />
    </bean>

    测试用例通过,至此,支持redis票据存储的插件开发完毕。然后我们利用mvn install把该插件安装到本地仓储。

    q3

    下面我们开始在cas-server-webapp工程中使用该插件。

    5.修改cas-server-webapp工程中ticketRegistry.xml文件,替换掉DefaultTicketRegistry,同时注释掉ticketRegistryCleaner相关所有定义(为什么注释掉前文有讨论)。

    <bean id="ticketRegistry" 
       class="org.jasig.cas.ticket.registry.RedisTicketRegistry" >
     <property name="jedisSentinelPool" ref="jedisSentinelPool" />
     <property name="st_time" value="10" />
     <property name="tgt_time" value="1200" />
    </bean>

    6.在POM.xml中添加cas-server-integration-redis模块:

    <dependency>
     <groupId>org.jasig.cas</groupId>
     <artifactId>cas-server-integration-redis</artifactId>
     <version>${project.version}</version>
     <scope>compile</scope>
    </dependency>

    7.本地启动redis,重新build工程,然后tomcat7:run运行CAS Server。直接登录认证中心,观察redis中数据变化。

    q4

    我们看到TGT存到redis中了,做登出操作,会观察到TGT已消失。从应用系统登录,会发现ST也在redis中。

    q5

     

    第七篇:

    前面我们介绍的SSO,无论是CAS还是我们自主开发的Nebula,都有一个共同的特点,就是应用系统需要登录时,都先重定向到认证服务器进行登录。也就是说系统需要从一个应用先跳到另一个应用,我们看阿里的单点登录就是这么做的。

    但有时候,我们想进一步增加用户体验,并不希望用户离开原应用页面,在原有页面基础上进行登录,让用户感受不到认证中心的存在,能不能做到呢?回答是肯定的,大家看下新浪的单点登录方式,就是这么做的。

    在原有应用系统页面进行登录认证中心,如不发生跳转,我们需要使用Ajax方式。而最常用的XMLHttpRequest Ajax方式调用,存在一个跨域问题,即为了安全,Ajax本身是不允许跨域调用的。这也就是为什么单点登录常规做法是重定向到认证中心去登录,然后再重定向回系统应用的原因。(而且为了安全,CAS本身也不提倡跨域远程登录)

    在应用页面中,如何达到远程登录CAS的效果?摆在我们面前有两道坎儿需要克服:

    首先是远程获取lt和execution参数值问题。前面我们介绍过,CAS登录的form提交不仅有username和password两个参数,还包括lt和execution,lt防止重复提交,execution保证走的同一个webflow流程。在进行远程提交时,我们需要远程得到CAS动态产生的这两个参数,从而保证能够向CAS进行正确form提交。

    XMLHttpRequest Ajax不能使用,可以采用另外一种方式,即JSONP。JSONP使用了script标签可以跨域访问其它网站资源的特性,巧妙地返回一段js回调方法代码,通过执行这个回调方法,达到了传递跨域调用数据的目的。

    第二个坎儿是如何在本页面跨域提交form请求。我们能不能也用JSONP方法呢?很遗憾,不行!JSONP提供的是get方式,而我们提交的form是post方式。我们可以使用另外一种ajax技术来解决,iframe。iframe可以加载和操作其它域的资源,根据用户提交的username和password,以及前面获取的lt和execution,在iframe中提交登录form参数,完成登录。

    主页面如何获取iframe提交返回的信息?可以修改CAS的登录流程,让其在远程登录的情况下,将出错信息以参数的方式重定向回应用系统服务端,应用系统再以调用父页面js函数方法,将出错信息通过参数传递给父页面。

    从上面思路可以看出,我们并没有让CAS增加远程登录的功能,CAS登录,还是需要在CAS所在域下登录。我们只是利用iframe方法,让应用系统达到和远程登录一样的用户体验效果。而实现这一效果的关键,是应用登录页对lt和execution动态参数以及CAS登录反馈信息的捕获。
    下面我们就按照上面思路介绍具体开发方法:

    1.改造login-webflow.xml,增加支持跨域远程登录处理流程分支。

    前面我们已经了解,登录流程的控制是在login-webflow.xml中,我们对它进行改造。改造原则是不修改原代码,在原有登录处理流程的基础上,增加一种新情况的处理,即支持跨域远程登录处理。

    在流程初始化处理完成后,我们增加一个新的节点mode,它首先来检查登录请求中是否包含一个变量mode,并且变量的值为rlogin。如果没有,就继续走原常规流程。如果有,说明是跨域远程登录情况。<on-start> 后加入如下分支流程定义:

    <action-state id="mode">
     <evaluate expression="modeCheckAction.check(flowRequestContext)"/>   
     <transition on="rlogin" to="serviceAuthorizationCheckR" />
     <transition on="normal" to="ticketGrantingTicketCheck" /> 
    </action-state>
    
    <action-state id="serviceAuthorizationCheckR">
     <evaluate expression="serviceAuthorizationCheck"/>
     <transition to="generateLoginTicketR"/>
    </action-state>
    
    <action-state id="generateLoginTicketR">
     <evaluate expression="generateLoginTicketAction.generate
    (flowRequestContext)" />
      <transition on="generated" to="rLoginTicket" />
    </action-state>
    
    <view-state id="rLoginTicket" view="rLoginTicket" model="credential">
      <binder>
      <binding property="username" required="true" />
      <binding property="password" required="true"/>
      </binder>
      <on-entry>
        <set name="viewScope.commandName" value="'credential'" />
      </on-entry>
      <transition on="submit" bind="true" validate="true"         
    to="realSubmitWithRLogin">
      <evaluate expression="authenticationViaRFormAction.doBind
    (flowRequestContext, flowScope.credential)" />
       </transition>
    </view-state>
    
    <action-state id="realSubmitWithRLogin">
    <evaluate expression="authenticationViaRFormAction.submit(flowRequestContext, 
    flowScope.credential, messageContext)" />
     <transition on="success" to="sendTicketGrantingTicketR" />
    </action-state>
    
    <action-state id="sendTicketGrantingTicketR">
    <evaluate expression="sendTicketGrantingTicketAction" />
    <transition on="success" to="rLoginRes" />
    </action-state>
    
    <end-state id="rLoginRes" view="rLoginRes" />

    2.增加rLoginTicket和rLoginRes新视图

    新增流程使用了两个新view,rLoginTicket返回的是JSONP要求的js调用,将CAS产生的lt和execution数据传递给调用方。最后的rLoginRes是将出错信息重定向回应用系统。

    前面我们介绍了定义CAS页面和修改页面主题的方法,我们基于前面的工作,在nebula_views.properties中添加(原始是default_views.properties):

    rLoginTicket.(class)=org.springframework.web.servlet.view.JstlView
    rLoginTicket.url=/WEB-INF/view/jsp/nebula/ui/rLoginTicket.jsp
    
    rLoginRes.(class)=org.springframework.web.servlet.view.JstlView
    rLoginRes.url=/WEB-INF/view/jsp/nebula/ui/rLoginRes.jsp

    同时在相应目录下创建这两个文件,文件内容如下:

    rLoginTicket.jsp

    <%@ page contentType="text/javascript; charset=UTF-8"%>
    <%out.print("jsonpcallback({'lt':'");%>${loginTicket}<%out.print
    ("','execution':'");%>${flowExecutionKey}<%out.print("'})");%>

    rLoginRes.jsp

    <%@ page contentType="text/html; charset=UTF-8"%>
    <html>
    <body>
    <script type="text/javascript">
    location.replace("${service}?ticket=${ticket}&ret=${ret}&msg=${msg}"); 
    </script>
    </body>
    </html>

    3.定义新action节点

    流程中,我们定义了两个新action,modeCheckAction和authenticationViaRFormAction,分别处理远程登录流程判断和form提交处理。在cas-servlet.xml中定义:

    <bean id="modeCheckAction" class="org.jasig.cas.web.flow.ModeCheckAction" />
    
    <bean id="authenticationViaRFormAction" 
    class="org.jasig.cas.web.flow.AuthenticationViaRFormAction"
            p:centralAuthenticationService-ref="centralAuthenticationService"
            p:ticketRegistry-ref="ticketRegistry"/>

    按照CAS工程架构,这两个新增的action定义在cas-server-webapp-support工程中。

    ModeCheckAction定义如下:

    package org.jasig.cas.web.flow;
    
    import javax.servlet.http.HttpServletRequest;
    
    import org.jasig.cas.web.support.WebUtils;
    import org.springframework.webflow.execution.Event;
    import org.springframework.webflow.execution.RequestContext;
    
    public class ModeCheckAction{  
    
     public static final String NORMAL = "normal";
     public static final String RLOGIN = "rlogin";
    
      public RLoginCheckAction() {
      }
    
     public Event check(final RequestContext context) {
       final HttpServletRequest request = 
           WebUtils.getHttpServletRequest(context);
       //根据mode判断请求模式,如mode=rlogin,是AJAX登录模式,
       //不存在是原模式,认证中心本地登录
       String mode = request.getParameter("mode");
       if(mode!=null&&mode.equals("rlogin")){
         context.getFlowScope().put("mode", mode);
         return new Event(this, RLOGIN);
       }
       return new Event(this, NORMAL);
     }
    }

    AuthenticationViaRFormAction参照AuthenticationViaFormAction,对出错输出做了处理,核心代码如下:

    public final Event submit(final RequestContext context, 
     final Credential credential,
     final MessageContext messageContext) throws Exception {
    
     // Validate login ticket
     final String authoritativeLoginTicket =                  
    WebUtils.getLoginTicketFromFlowScope(context);
     final String providedLoginTicket = 
              WebUtils.getLoginTicketFromRequest(context);
    
      if (!authoritativeLoginTicket.equals(providedLoginTicket)) {
        logger.warn("Invalid login ticket {}", providedLoginTicket);
        messageContext.addMessage(new MessageBuilder().code 
        ("error.invalid.loginticket").build());
    
        context.getFlowScope().put("ret", -1);
        context.getFlowScope().put("msg", "LT过期,请重新登录!");
      }      
      try {
        final String tgtId = 
    this.centralAuthenticationService.createTicketGrantingTicket(credential);
        WebUtils.putTicketGrantingTicketInFlowScope(context, tgtId);
        final Service service = WebUtils.getService(context);
        final String serviceTicketId = 
         this.centralAuthenticationService.grantServiceTicket(tgtId,service);
        WebUtils.putServiceTicketInRequestScope(context,serviceTicketId);
        context.getFlowScope().put("ticket", serviceTicketId);
        return newEvent(SUCCESS);
      } catch (final AuthenticationException e) {
        context.getFlowScope().put("ret", -2);
        context.getFlowScope().put("msg", 
               "用户名密码错误,请重新登录!");
        return newEvent(SUCCESS);
      } catch (final Exception e) {
        context.getFlowScope().put("ret", -3);
        context.getFlowScope().put("msg", "系统内部错误,请稍后登录!");
        return newEvent(SUCCESS);
      }
    }

    支持跨域远程登录的CAS改造完成。应用系统方怎么调用呢,我们开发一个例子:

    设置CAS认证中心的域名为www.cas.com,应用系统的域名为www.ssoclient.com:81

    首先我们按照前面方法把应用系统配置成SSO Client应用,这个前面介绍过,这里不重复。开发一个应用登录页rlogin.html,代码片段如下:

    我们定义一个隐藏的iframe:

    <iframe style="display:none;0;height:0" id="rlogin" name="rlogin"/>

    登录form部分:

    <div id="sec-login">
    <form id="login-form" name="login-form" 
       action="http://www.cas.com/login" method="post" target="rlogin">  
    
    <div><input name="username" id="username" type="text" autocomplete="off" 
    class="login-ipt"  placeholder="邮箱/手机号" /></div>  
    <div><input name="password" type="password"  id="password"  
           class="login-ipt" placeholder="密码" /></div>  
    <input type="hidden" name="lt" value="" id="lt" />
    <input type="hidden" name="execution" value="" id="execution" />        
    <input type="hidden" name="_eventId" value="submit" /> 
    <input type="button" value="登录" class="login-bnt"  
        onclick="javascript:login();" />    
    </form>  
    </div>

    关键是login js方法,JSON获取lt和execution后,提交form到iframe定义如下:

    var login = function(){  
      $.ajax({ url: 'http://www.cas.com/login?            
      mode=rlogin&service=http://www.ssoclient.com:81/ssoresult.do',
           dataType: "jsonp",
           jsonpCallback: "jsonpcallback",
           success: function (data) {
             $('#lt').val(data.lt); 
            $('#execution').val(data.execution); 
            $('#login-form').submit();
           },  
          error:function(){  
           alert('网络访问错误!');  
           }  
      });
    }; 

    还需要定义一个logincallback方法,用于接收登录后出错信息:

    var logincallback = function(result) {  
       if (result.ret == 0){  
        location.href="index.do";
        } else {
            alert(result.msg);
        $('#login-form')[0].reset();
        }
    };  

    系统应用定义的service是ssoresult.do,这是cas重定向返回的点(rLoginRes.jsp中定义),也是SSO Client系统应用登录成功后返回的点。在这里接收CAS传来的登录出错数据并调用js的方式返回给父页面。核心代码如下:

    @RequestMapping("/ssoresult.do")
    public void ssoResult(HttpServletRequest request, 
                   HttpServletResponse response) {
    
      String ret = request.getParameter("ret");
      String msg = request.getParameter("msg");
    
      if(ret==null){
          ret = "0";
      }
      String result = "<html><head><script language='javascript'>" +
         "parent.logincallback({'ret':" + ret + ",'msg':'" + msg + "'});" +
         "</script></head> </html>";   
    
      response.setContentType("text/html;charset=UTF-8");
      try{
        PrintWriter out = response.getWriter();
        out.print(result);
        out.flush();
        out.close();
      }catch(Exception e){
      }
    }

    OK,运行效果如下:

    应用登录页面前:

    q1

    用户登录信息输入错误:

    q2

    登录信息输入正确:

    q3

     

    第八篇:

    前面系列文章讨论的CAS,确切地说是Web SSO解决方案,应用系统是Web系统,即B/S模式。

    在移动互联网应用大趋势下,传统互联网应用都在向移动化方向延伸。

    移动应用不仅包括移动Web应用(触屏版、H5应用),更多的是Native APP原生应用(安卓、苹果等APP),即软件架构是C/S模式。

    对于CAS认证中心管控的Web应用群,如何将这些原生APP应用纳入其中?

    由于没有Web应用浏览器天然所具有的处理Cookie、处理HTTP重定向能力,原生APP的登录会话管理一般采用自主开发。

    在服务器端创建并保持会话,将会话句柄返给APP客户端持有,后续需要登录后访问的API均需带上这个会话句柄作为请求的一个参数。这个会话句柄和我们Java Web应用的jsessionid很类似。

    上述是Native APP登录管理的实现方式,那如何接入CAS认证中心呢?可以有两种方式:一种是APP直接访问CAS认证中心,先得到TGT,再得到ST。APP拿到ST后,就可以访问配置成CAS Client的移动服务端应用,服务端和认证中心验证过ST后,即可按上述方式建立起本地会话。

    另一种方式可采用服务端代理模式,即APP先向移动服务端应用提交登录请求,服务端再向CAS认证中心登录认证。这种方式将CAS认证中心的非浏览器登录接口只暴露给移动服务端应用,起到很好的安全防护功能。本文将采用第二种方式给大家示范。

    CAS提供了一个支持RESTful风格API的插件,4.1.1新版是cas-server-support-rest,老版是cas-server-integration-restlet 可以获得TGT和ST。

    这里我们使用另外一种方式,不用CAS插件,思路和《支持Web应用跨域登录CAS》文章介绍类似,通过修改login-webflow流程返回JSON格式View。由于是服务端代理模式,不必返回ST,认证成功即可建立本地会话了。

    下面,我们就一步步加以实现:

    1.改造login-webflow.xml,增加Native APP登录处理流程分支(在基于前面文章增加rlogin流程基础上修改)

    在流程初始化处理完成后,增加一新节点mode,它首先来检查登录请求中是否包含一个变量mode,并且mode的值为app。如果没有,就继续走原常规流程。如果有,说明是Native APP登录处理情况。<on-start> 后加入如下分支流程定义:

    <action-state id="mode">
     <evaluate expression="modeCheckAction.check(flowRequestContext)"/>   
     <transition on="rlogin" to="serviceAuthorizationCheckR" />
     <transition on="app" to="serviceAuthorizationCheckR" />
     <transition on="normal" to="ticketGrantingTicketCheck" /> 
    </action-state>

    产生lt后,我们要做个判断,看是app情况还是rlogin情况,app走app处理流程。

    <action-state id="generateLoginTicketR">
     <evaluate expression="generateLoginTicketAction.generate(flowRequestContext)" />
     <transition on="generated" to="modeCheckForLt" />
    </action-state>
    
    <decision-state id="modeCheckForLt">
     <if test="flowScope.mode != null && flowScope.mode == 'rlogin'" then="rLoginTicket" else="appLoginTicket" />
    </decision-state>

    增加appLoginTicket,注意它的输出视图是appLoginTicket。这和rlogin情况的输出视图不同。

    <view-state id="appLoginTicket" view="appLoginTicket"  model="credential">
     <binder>
      <binding property="username" required="true" />
      <binding property="password" required="true"/>
     </binder>
     <on-entry>
      <set name="viewScope.commandName" value="'credential'" />
     </on-entry>
     <transition on="submit" bind="true" validate="true" to="realSubmitWithRLogin">
     <evaluate expression="authenticationViaRFormAction.doBind(flowRequestContext, flowScope.credential)" />
     </transition>
    </view-state>

    登录认证信息提交后,需要根据mode返回不同的VIEW,app模式返回appRes,rlogin模式返回rLoginRes,故修改节点如下:

    <action-state id="sendTicketGrantingTicketR">
     <evaluate expression="sendTicketGrantingTicketAction" />
     <transition on="success" to="modeCheck" />
    </action-state>
    
    <decision-state id="modeCheck">
     <if test="flowScope.mode != null && flowScope.mode == 'rlogin'" then="rLoginRes" else="appRes" />
    </decision-state>
    
    <end-state id="rLoginRes" view="rLoginRes" />
    <end-state id="appRes" view="appRes" />

    2.增加appLoginTicket和appRes新视图

    在nebula_views.properties中添加(原始是default_views.properties):

    appLoginTicket.(class)=org.springframework.web.servlet.view.JstlView
    appLoginTicket.url=/WEB-INF/view/jsp/nebula/ui/appLoginTicket.jsp
    
    appRes.(class)=org.springframework.web.servlet.view.JstlView
    appRes.url=/WEB-INF/view/jsp/nebula/ui/appRes.jsp

    同时在相应目录下创建这两个文件,文件内容如下:

    appLoginTicket.jsp

    <%@ page contentType="text/html; charset=UTF-8"%>
    <%out.print("{"lt":"");%>${loginTicket}<%out.print("","execution":"");%>${flowExecutionKey}<%out.print(""}");%>

    appLoginRes.jsp

    <%@ page contentType="text/html; charset=UTF-8"%>
    <%out.print("{"ret":"");%>${ret}<%out.print("","msg":"");%>${msg}<%out.print(""}");%>

    3.修改modeCheckAction内容,增加处理app情况,核心代码如下:

    public class ModeCheckAction{  
    
     public static final String NORMAL = "normal";
     public static final String APP = "app";
     public static final String RLOGIN = "rlogin";
    
     public ModeCheckAction() {
     }
    
     public Event check(final RequestContext context) {
      final HttpServletRequest request = WebUtils.getHttpServletRequest(context);
    
       //根据mode判断请求模式,如mode=rlogin,是AJAX远程登录模式,
       //app是app登录模式,不存在是原模式,认证中心本地登录
       String mode = request.getParameter("mode");
       if(mode!=null&&mode.equals("rlogin")){
        context.getFlowScope().put("mode", mode);
        return new Event(this, RLOGIN);
       }
       if(mode!=null&&mode.equals("app")){
        context.getFlowScope().put("mode", mode);
         return new Event(this, APP);
       }
       return new Event(this, NORMAL);
     }
    }

    至此,CAS认证中心改造完成!

    4.开发支持APP登录的移动服务端接口。接收APP登录请求,采用HttpClient转发至CAS认证中心登录,返回json数据解析并最终返回给客户端。本地会话采用redis维护,登录成功,返回access_token。

    接口定义:url: /login.json
    入参: username string
    password string
    出参: ret string
    msg string
    access_token string

    核心代码如下:

    @RequestMapping("/login.json")
    public @ResponseBody ResultBean login(HttpServletRequest request, 
                         HttpServletResponse response) {
    
     ResultBean resultBean = new ResultBean();
     String username = request.getParameter("username");
     String password = request.getParameter("password");
    
     HttpClient httpClient = new DefaultHttpClient();
    
     String url = SSO_SERVER_URL + "?mode=app&service=" + SSO_CLIENT_SERVICE;
    
     HttpGet httpGet = new HttpGet(url); 
     try{
      HttpResponse httpClientResponse = httpClient.execute(httpGet);
      int statusCode = httpClientResponse.getStatusLine().getStatusCode();
      if (statusCode == HttpStatus.SC_OK){
       String result = EntityUtils.toString(httpClientResponse.getEntity(),
                  "utf-8").replace('
    ', ' ').replace('
    ', ' ').trim();
       //解析json数据
       ObjectMapper objectMapper = new ObjectMapper();
       LtBean ltBean = objectMapper.readValue(result, LtBean.class);
       List<NameValuePair> formparams = new ArrayList<NameValuePair>();
       formparams.add(new BasicNameValuePair("username", username));
       formparams.add(new BasicNameValuePair("password", password));
       formparams.add(new BasicNameValuePair("lt", ltBean.getLt()));
       formparams.add(new BasicNameValuePair("execution", ltBean.getExecution()));
       formparams.add(new BasicNameValuePair("_eventId", "submit"));
    
       UrlEncodedFormEntity entity = new UrlEncodedFormEntity(formparams, "UTF-8");
       HttpPost httpPost = new HttpPost(SSO_SERVER_URL);
       httpPost.setEntity(entity);
    
       httpClientResponse = httpClient.execute(httpPost);
       statusCode = httpClientResponse.getStatusLine().getStatusCode();
    
       if (statusCode == HttpStatus.SC_OK){
         result = EntityUtils.toString(httpClientResponse.getEntity(), "utf-8")
                    .replace('
    ', ' ').replace('
    ', ' ').trim();
    
         objectMapper = new ObjectMapper();
         resultBean = objectMapper.readValue(result, ResultBean.class);
         if(resultBean.getRet().equals("")){
          String access_token = UUID.randomUUID().toString(); //会话句柄
          TokenUtil.setAccess_token(access_token, username); //放入redis
          resultBean.setRet("0");
          resultBean.setMsg("登录成功");
          resultBean.setAccess_token(access_token);
         }
        }
      }
    
     }catch(Exception e){
      e.printStackTrace();
      resultBean.setRet("-2");
      resultBean.setMsg("系统服务错误,请稍后再试!");
      return resultBean; 
     }finally{
      httpClient.getConnectionManager().shutdown();
     }
      return resultBean;  
    }
    1. 开发app客户端登录

    APP开发不是本文重点,这里略。

     

     

     

     

     

     

     

     



    作者: 手插口袋_ 
    链接:http://www.imooc.com/article/3558
    来源:慕课网

  • 相关阅读:
    CF 234 C Weather(粗暴方法)
    给工作赋予的新意义——Leo鉴书78
    获取集合的方法
    VS 统计代码行数
    , ,
    指针的删除动作
    C++ 名称空间
    boost::token_compress_on
    指针与引用
    容器的end()方法
  • 原文地址:https://www.cnblogs.com/dengyungao/p/7525429.html
Copyright © 2020-2023  润新知