该篇博文信息参考自:https://www.imooc.com/article/4017
前面我们介绍的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。如果没有,就继续走原常规流程。如果有,说明是跨域远程登录情况。
id="mode">
expression="modeCheckAction.check(flowRequestContext)"/>
on="rlogin" to="serviceAuthorizationCheckR" />
on="normal" to="ticketGrantingTicketCheck" />
id="serviceAuthorizationCheckR">
expression="serviceAuthorizationCheck"/>
to="generateLoginTicketR"/>
id="generateLoginTicketR">
expression="generateLoginTicketAction.generate
(flowRequestContext)" />
on="generated" to="rLoginTicket" />
id="rLoginTicket" view="rLoginTicket" model="credential">
property="username" required="true" />
property="password" required="true"/>
name="viewScope.commandName" value="'credential'" />
on="submit" bind="true" validate="true"
to="realSubmitWithRLogin">
expression="authenticationViaRFormAction.doBind
(flowRequestContext, flowScope.credential)" />
id="realSubmitWithRLogin">
expression="authenticationViaRFormAction.submit(flowRequestContext,
flowScope.credential, messageContext)" />
on="success" to="sendTicketGrantingTicketR" />
id="sendTicketGrantingTicketR">
expression="sendTicketGrantingTicketAction" />
on="success" to="rLoginRes" />
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"%>
3.定义新action节点
流程中,我们定义了两个新action,modeCheckAction和authenticationViaRFormAction,分别处理远程登录流程判断和form提交处理。在cas-servlet.xml中定义:
id="modeCheckAction" class="org.jasig.cas.web.flow.ModeCheckAction" />
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:
登录form部分:
id="sec-login">
关键是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 = " ";
response.setContentType("text/html;charset=UTF-8");
try{
PrintWriter out = response.getWriter();
out.print(result);
out.flush();
out.close();
}catch(Exception e){
}
}
OK,运行效果如下: