denger 发表于 2013-1-23 01:20:47

CAS 之 跨域 Ajax 登录实践

因最近经常有时候被一些朋友问到关于 CAS 跨全域下的 Ajax 登录方式实现,正好之前也分析Sina微博的SSO实现,文中也说了 SINA 的 SSO 实际上(或机制)直接使用了 CAS 这个开源项目。于是本文中要说的CAS AJAX登录方式便参考了 SINA 的AJAX登录实现。 关于具体方案,CAS官方上好象没有提供相关说明,倒是有一文说到 Without the Login Screen (详情参见 CAS 之自定义登录页实践),其具体实现方式甚是麻烦,又是改源码,又是通过JS跳转,又是一堆配置。 当然,虽然如此,但该文中所提到的获取 login tikcet 的方式还是值的参考的,因为无论什么方式登录,前提是必须获取到该ticket才允许登录验证。
   虽然这里所说的主要是针对 CAS,其实具体的实现方式中有些还是值得参考的,如跨域设置 cookie, jsonp + iframe 跨域异步请求、P3P 及 关于 spring webflow 等其它相关的一些信息。

思路
   关于具体的实现思路基本上都是参考了 SINA,所以详细信息可以在 分析Sina微博的SSO实现 看到 或 自己去 firebug 一下 sina micro-blog。


实践
    Environment:
      cas-server-3.4.2.1       http://www.passport.com:8080/cas/
      cas-client-3.1.10         http://www.portal.com:8080/login
    以上域名是方便测试跨域,故修改本机 hosts。

    Step 1:在首次进入登录时(portal域中/login),通过 JSONP 从 passport 域中获取 login ticket。
    登录表单:
    <form action="http://www.passport.com:8080/cas/login" method="post"target="ssoLoginFrame"><ul><span class="red" style="height:12px;" id="J_ErrorMsg"></span><li><em>用户名:</em><input name="username" id="J_Username" type="text" autocomplete="off" class="line" style="width: 180px" /></li><li><em>密 码:</em><input name="password" type="password"id="J_Password" class="line" style="width: 180px" /></li><li class="mai"><em> </em><input type="checkbox" name="rememberMe" id="rememberMe" value="true"/> 自动登录<a href="/retrieve">忘记密码?</a></li><li><em> </em><input type="hidden" name="isajax" value="true" /><input type="hidden" name="isframe" value="true" /><input type="hidden" name="lt" value="" id="J_LoginTicket"><input type="hidden" name="_eventId" value="submit" /><input name="" type="submit" value="登录" class="loginbanner" /></li></ul></form>    $(document).ready(function(){ flushLoginTicket();// 进入登录页,则获取login ticket,该函数在下面定义。});    关于 cas-server 如何返回 lt ,在 Without the Login Screen 文章中有提到。


    Step 2:输入用户名密码,提交验证。将表单信息将会被POST提交至 动态的iframe中,定义该登录页面中登录后的处理逻辑。
// 登录验证函数, 由 onsubmit 事件触发var loginValidate = function(){var msg;if ($.trim($('#J_Username').val()).length == 0 ){msg = "用户名不能为空。";} else if ($.trim($('#J_Password').val()).length == 0 ){msg = "密码不能为空。";}if (msg && msg.length > 0) {$('#J_ErrorMsg').fadeOut().text(msg).fadeIn();return false;// Can't request the login ticket.} else if ($('#J_LoginTicket').val().length == 0){$('#J_ErrorMsg').text('服务器正忙,请稍后再试..');return false;} else {// 验证成功后,动态创建用于提交登录的 iframe    $('body').append($('<iframe/>').attr({    style: "display:none;width:0;height:0",   id: "ssoLoginFrame",    name: "ssoLoginFrame",    src: "javascript:false;"    }));return true;}}// 登录处理回调函数,将由 iframe 中的页同自动回调var feedBackUrlCallBack = function (result) {customLoginCallBack(result);deleteIFrame('#ssoLoginFrame');// 删除用完的iframe,但是一定不要在回调前删除,Firefox可能有问题的};// 自定义登录回调逻辑var customLoginCallBack = function(result){// 登录失败,显示错误信息if (result.login == 'fails'){$('#J_ErrorMsg').fadeOut().text(result.msg).fadeIn();// 重新刷新 login ticketflushLoginTicket();}// do more....}var deleteIFrame = function (iframeName) {var iframe = $(iframeName); if (iframe) { // 删除用完的iframe,避免页面刷新或前进、后退时,重复执行该iframe的请求iframe.remove()}};// 由于一个 login ticket 只允许使用一次, 当每次登录需要调用该函数刷新 ltvar flushLoginTicket = function(){var _services = 'service=' + encodeURIComponent('http://www.portal.com:8080/uc/');$.getScript('http://www.passport.com:8080/cas/login?'+_services+'&get-lt=true&n=' + new Date().getTime(), function(){// 将返回的 _loginTicket 变量设置到input name="lt" 的value中。$('#J_LoginTicket').val(_loginTicket);});// Response Example:// var _loginTicket = 'e1s1';}    当点击登录后,则动态创建一个 iframe,并且登录表单提交至该 iframe 中。在下面截图中看以 body 中的变化:

http://dl.iteye.com/upload/attachment/511891/e105a32e-99d0-347a-a260-2281bfab205b.png

    由于原本的 CAS 登录方式是通过跳转、重定向的方式实现,所以需要对 CAS的Server端进行调整,使其同时支持 Ajax 方式登录。


    Step 3:调整 CAS Server端,使其适应 Iframe 方式登录,并使其支持回调。
    打开 login-webflow.xml,找到 <action-state id="generateServiceTicket"> 的 Flow-Action 配置项: <!--当执行到该 action 的时候,表示已经登录成功,将生成 ST(Service Ticket)。--><action-state id="generateServiceTicket"><evaluate expression="generateServiceTicketAction" />      <!--当生成 ST 成功后,则进入登录成功页,新增 loginResponse Action 处理项,判断是否是 ajax/iframe 登录 --><!-- <transition on="success" to="warn" /> --><transition on="success" to="loginResponse" /><!--<transition on="error" to="viewLoginForm" />-->      <!-- 可能生成 service ticket 失败,同样,也是进入 loginResponse --><transition on="error" to="loginResponse" /><transition on="gateway" to="redirect" /></action-state>    再新增 loginResponse Action配置项:
<action-state id="loginResponse"><evaluate expression="ajaxLoginServiceTicketAction" /><!--非ajax/iframe方式登录,采取原流程处理 --><transition on="success" to="warn" /><transition on="error" to="viewLoginForm" /><!-- 反之,则进入 viewAjaxLoginView 页面 --><transition on="local" to="viewAjaxLoginView" /></action-state>   再调整,当验证失败后,也需要判断是否是 iframe/ajax登录:<action-state id="realSubmit"><evaluateexpression="authenticationViaFormAction.submit(flowRequestContext, flowScope.credentials, messageContext)" /><transition on="warn" to="warn" /><transition on="success" to="sendTicketGrantingTicket" /><!--将 to="viewLoginForm" 修改为 to="loginResponse" -->               <transition on="error" to="loginResponse" /></action-state>
还需要配置 viewAjaxLoginView 的 state:    <end-state id="viewAjaxLoginView" view="viewAjaxLoginView" />   
   接着,再定义 ajaxLoginServiceTicketAction Bean 吧,直接在 cas-servlet.xml 声明该 bean:
   <bean id="ajaxLoginServiceTicketAction" class="com.unknow.cas.server.web.AjaxLoginServiceTicketAction"/>    package com.haha.cas.server.web;import javax.servlet.http.HttpServletRequest;import org.apache.commons.lang.BooleanUtils;import org.apache.commons.lang.StringUtils;import org.jasig.cas.authentication.principal.Service;import org.jasig.cas.web.support.WebUtils;import org.springframework.webflow.action.AbstractAction;import org.springframework.webflow.execution.Event;import org.springframework.webflow.execution.RequestContext;public final class AjaxLoginServiceTicketAction extends AbstractAction {// The default call back function name.protected static final String J_CALLBACK = "feedBackUrlCallBack";    protected Event doExecute(final RequestContext context) {      HttpServletRequest request = WebUtils.getHttpServletRequest(context);      Event event = context.getCurrentEvent();      boolean isAjax = BooleanUtils.toBoolean(request.getParameter("isajax"));                if (!isAjax){// 非 ajax/iframe 方式登录,返回当前 event.      return event;      }    boolean isLoginSuccess;    // Login Successful.    if ("success".equals(event.getId())){ //是否登录成功    final Service service = WebUtils.getService(context);            final String serviceTicket = WebUtils.getServiceTicketFromRequestScope(context);            if (service != null){//设置登录成功之后 跳转的地址            request.setAttribute("service", service.getId());            }            request.setAttribute("ticket", serviceTicket);            isLoginSuccess = true;    } else { // Login Fails..    isLoginSuccess = false;    }      boolean isFrame = BooleanUtils.toBoolean(request.getParameter("isframe"));      String callback = request.getParameter("callback");      if(StringUtils.isEmpty(callback)){ // 如果未转入 callback 参数,则采用默认 callback 函数名      callback = J_CALLBACK;      }      if(isFrame){ // 如果采用了 iframe ,则 concat 其 parent 。      callback = "parent.".concat(callback);      }      request.setAttribute("isFrame", isFrame);      request.setAttribute("callback", callback);      request.setAttribute("isLogin", isLoginSuccess);                return new Event(this, "local"); // 转入 ajaxLogin.jsp 页面    }}最后,再定义一下 view 的页面地址吧,修改 default_views.properties,添加:
viewAjaxLoginView.(class)=org.springframework.web.servlet.view.JstlViewviewAjaxLoginView.url=/WEB-INF/view/jsp/custom/ui/ajaxLogin.jsp    可见,spring webflow 的可扩展性是相当的强,在 login flow 中增加一个业务逻辑,极其方便。
    OK,再是 ajaxLogin.jsp 的代码,从 request attributes 中获取到ST, Service 等参数信息:
    <%@ page contentType="text/html; charset=UTF-8"%><html><head><title>正在登录....</title></head><body><script type="text/javascript"><%Boolean isFrame = (Boolean)request.getAttribute("isFrame");Boolean isLogin = (Boolean)request.getAttribute("isLogin");// 登录成功if(isLogin){if(isFrame){%>parent.location.replace('${service}?ticket=${ticket}')<%} else{%>location.replace('${service}?ticket=${ticket}')<%}}%>// 回调${callback}({'login':${isLogin ? '"success"': '"fails"'}, 'msg': ${isLogin ? '""': '"用户名或密码错误!"'}})</script></body></html>    以上 jsp 将是在 iframe 中执行,看到这个 JSP 后,再回头看看 最上面 login 页面中 js 就很清楚了。
    OK,至此,已经完成所有工作,下面测试一把,通过使用 Firbug 看看其处理情况。


    Step 4:测试,当登录失败后,是否在 www.portal.com:8080/login 页中显示www.passport.com:8080/cas/ 中返回过来的 error message; 当登录成功后,是否能进入登录成功后跳转的地址(www.portal.com:8080/uc/index):
    进入 http://www.portal.com:8080/login页:
    http://dl.iteye.com/upload/attachment/511875/fe1e939f-f103-3538-a31b-bdca09f0ad01.png
    可以看到,马上就会去向 passport 中请求 login ticket,也就是调用上面定义的函数flushLoginTicket() :
http://dl.iteye.com/upload/attachment/511877/e70bde91-8b37-3b3b-b1c8-1ff69e538269.png
    OK,随便输入用户名密码,提交登录,测试时,我先把删除 iframe 代码注释:
   
http://dl.iteye.com/upload/attachment/511881/eca356c9-2402-38f6-980e-920d0abbe2aa.png
可以看到,该 iframe 中输入出一段 js ,用于 callbackportal/login 页中的 feedBackUrlCallBack 函数,并且将错误信息页给该函数,从而实现登录结果的传递。最终效果如下:
http://dl.iteye.com/upload/attachment/511883/becf1036-e624-39d9-9f65-d80d9416df57.png
另外,上面说到 login ticket 只能使用一次,所以当登录失败后,会马上再次获取 login ticket.

接下来,再测试一下登录OK的情况:

http://dl.iteye.com/upload/attachment/511888/636c2b53-95ff-35c3-8504-e5bba44419d5.png
可以看到,后面的 callback 实际上调用不调用已经没什么关系了,因为在之前已经进行了跳转。


相关

[*]新浪微博如何实现 SSO 的分析
[*]淘宝如何跨域获取Cookie分析
[*]CAS 之 集成RESTful API
[*]CAS 之自定义登录页实践
[*]CAS 之 实现用户注册后自动登录
[*]新浪SSO JS (未压缩版)
[*]跨域(cross-domain)访问 cookie (读取和设置)/P3P
页: [1]
查看完整版本: CAS 之 跨域 Ajax 登录实践