为了账号安全,请及时绑定邮箱和手机立即绑定

《SSO CAS单点系列》之 支持Web应用跨域登录CAS (千斤干货)

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


f
前面我们介绍的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;width: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
qq

点击查看更多内容
27人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消