`
denger
  • 浏览: 356545 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

CAS 之自定义登录页实践

阅读更多


1. 动机
      用过 CAS 的人都知道 CAS-Server端是单独部署的,作为一个纯粹的认证中心。在用户每次登录时,都需要进入CAS-Server的登录页填写用户名和密码登录,但是如果存在多个子应用系统时,它们可能都有相应风格的登录页面,我们希望直接在子系统中登录成功,而不是每次都要跳转到CAS的登录页去登录。

2. 开始分析问题
       其实仔细想一想,为什么不能直接在子系统中将参数提交至 cas/login 进行登录呢? 于是便找到了CAS在登录认证时主要参数说明:
              service         [OPTIONAL] 登录成功后重定向的URL地址;
              username    [REQUIRED] 登录用户名;
              password    [REQUIRED] 登录密码;
              lt                    [REQUIRED] 登录令牌;
       主要有四个参数,其中的三个参数倒好说,最关键的就是 lt , 据官方说明该参数是login ticket id, 主要是在登录前产生的一个唯一的“登录门票”,然后提交登录后会先取得"门票",确定其有效性后才进行用户名和密码的校验,否则直接重定向至 cas/login 页。
       于是,便打开CAS-Server的登录页,发现其每次刷新都会产生一个 lt, 其实就是 Spring WebFlow 中的 flowExecutionKey值。 那么问题的关键就在于在子系统中如何获取 lt 也就是登录的ticket?

3. 可能的解决方案
       一般对于获取登录ticket的解决方案可能大多数人都会提到两种方法:
   
  • AJAX:  熟悉 Ajax 的可能都知道,它的请求方式是严格按照沙箱安全模型机制的,严格情况下会存在跨域安全问题。
  • IFrames: 这也是早期的 ajax 实现方式,在页面中嵌入一个隐藏的IFrame,然后通过表单提交到该iframe来实现不刷新提交,不过使用这种方式同样会带来两个问题:
  •                    a.  登录成功之后如何摆脱登录后的IFrame呢?如果成功登录可能会导致整个页面重定向,当然你能在form中使 
                            用属性target="_parent",使之弹出,那么你如何在父页面显示错误信息呢?
                       b.  你可能会受到布局的限止(不允许或不支持iframe)
        对于以上两种方案,并非说不能实现,只是说对于一个灵活的登录系统来说仍然还是会存在一定的局限性的,我们坚信能有更好的方案来解决这个问题。

4. 通过JS重定向来获取login ticket (lt)
       当第一次进入子系统的登录页时,通过 JS 进行redirect到cas/login?get-lt=true获取login ticket,然后在该login中的 flow 中检查是否包含get-lt=true的参数,如果是的话则跳转到lt生成页,生成后,并将lt作为该redirect url 中的参数连接,如 remote-login.html?lt=e1s1,然后子系统再通过JS解析当前URL并从参数中取得该lt的值放置登录表单中,即完成 lt 的获取工作。其中进行了两次 redirect 的操作。
      
5. 开始实践
       首先,在我们的子系统中应该有一个登录页面,通过输入用户名和密码提交至cas认证中心。不过前提是先要获取到  login tickt id. 也就是说当用户第一次进入子系统的登录页面时,在该页面中会通过js跳转到 cas/login 中的获取login ticket. 在 cas/login 的 flow 中先会判断请求的参数中是否包含了 get-lt 的参数。
      在cas的 login flow 中加入 ProvideLoginTicketAction 的流,主要用于判断该请求是否是来获取 lt,在cas-server端声明获取 login ticket action 类:
com.denger.sso.web.ProvideLoginTicketAction
/**
 * Opens up the CAS web flow to allow external retrieval of a login ticket.
 * 
 * @author denger
 */
public class ProvideLoginTicketAction extends AbstractAction{

	@Override
	protected Event doExecute(RequestContext context) throws Exception {
		final HttpServletRequest request = WebUtils.getHttpServletRequest(context);

		if (request.getParameter("get-lt") != null && request.getParameter("get-lt").equalsIgnoreCase("true")) {
			return result("loginTicketRequested");
		}
		return result("continue");
	}
	
}
// 如果参数中包含 get-lt 参数,则返回 loginTicketRequested 执行流,并跳转至 loginTicket 生成页,否则 则跳过该flow,并按照原始login的流程来执行。

并且将该 action 声明在 cas-servlet.xml 中:
<bean id="provideLoginTicketAction" class="com.denger.sso.web.ProvideLoginTicketAction" />     



还需要定义 loginTicket 的生成页也就是当返回 loginTicketRequested 的 view:
viewRedirectToRequestor.jsp
<%@ page contentType="text/html; charset=UTF-8"%>
<%@ page import="com.denger.sso.util.CasUtility"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<%
	String separator = "";
        // 需要输入 login-at 参数,当生成lt后或登录失败后则重新跳转至 原登录页,并传入参数 lt 和 error_message
	String referer = request.getParameter("login-at");

	referer = CasUtility.resetUrl(referer);
	if (referer != null && referer.length() > 0) {
		separator = (referer.indexOf("?") > -1) ? "&" : "?";
%>
<html>
	<title>cas get login ticket</title>
	<head>
		<META http-equiv="Content-Type" content="text/html; charset=UTF-8">
		<script>
		var redirectURL = "<%=referer + separator%>lt=${flowExecutionKey}";
		<spring:hasBindErrors name="credentials">
			var errorMsg = '<c:forEach var="error" items="${errors.allErrors}"><spring:message code="${error.code}" text="${error.defaultMessage}" /></c:forEach>';
            redirectURL += '&error_message=' + encodeURIComponent (errorMsg);
        </spring:hasBindErrors>
         window.location.href = redirectURL;
       </script>
	</head>
	<body></body>
</html>
<%
	} else {
%>		
		<script>window.location.href = "/member/login";</script>
<%		
	}
%>

并且需要将该 jsp 声明在 default._views.properites 中:
### Redirect with login ticket view
casRedirectToRequestorView.(class)=org.springframework.web.servlet.view.JstlView
casRedirectToRequestorView.url=/WEB-INF/view/jsp/default/ui/viewRedirectToRequestor.jsp


相关 com.denger.sso.util.CasUtility 代码:
public class CasUtility {

	/**
	 * Removes the previously attached GET parameters "lt" and "error_message"
	 * to be able to send new ones.
	 * 
	 * @param casUrl
	 * @return
	 */
	public static String resetUrl(String casUrl) {
		String cleanedUrl;
		String[] paramsToBeRemoved = new String[] { "lt", "error_message", "get-lt" };
		cleanedUrl = removeHttpGetParameters(casUrl, paramsToBeRemoved);
		return cleanedUrl;
	}

	/**
	 * Removes selected HTTP GET parameters from a given URL
	 * 
	 * @param casUrl
	 * @param paramsToBeRemoved
	 * @return
	 */
	public static String removeHttpGetParameters(String casUrl,
			String[] paramsToBeRemoved) {
		String cleanedUrl = casUrl;
		if (casUrl != null) {
			// check if there is any query string at all
			if (casUrl.indexOf("?") == -1) {
				return casUrl;
			} else {
				// determine the start and end position of the parameters to be
				// removed
				int startPosition, endPosition;
				boolean containsOneOfTheUnwantedParams = false;
				for (String paramToBeErased : paramsToBeRemoved) {
					startPosition = -1;
					endPosition = -1;
					if (cleanedUrl.indexOf("?" + paramToBeErased + "=") > -1) {
						startPosition = cleanedUrl.indexOf("?"
								+ paramToBeErased + "=") + 1;
					} else if (cleanedUrl.indexOf("&" + paramToBeErased + "=") > -1) {
						startPosition = cleanedUrl.indexOf("&"
								+ paramToBeErased + "=") + 1;
					}
					if (startPosition > -1) {
						int temp = cleanedUrl.indexOf("&", startPosition);
						endPosition = (temp > -1) ? temp + 1 : cleanedUrl
								.length();
						// remove that parameter, leaving the rest untouched
						cleanedUrl = cleanedUrl.substring(0, startPosition)
								+ cleanedUrl.substring(endPosition);
						containsOneOfTheUnwantedParams = true;
					}
				}

				// wenn nur noch das Fragezeichen vom query string übrig oder am
				// schluss ein "&", dann auch dieses entfernen
				if (cleanedUrl.endsWith("?") || cleanedUrl.endsWith("&")) {
					cleanedUrl = cleanedUrl.substring(0,
							cleanedUrl.length() - 1);
				}
				// parameter mehrfach angegeben wurde...
				if (!containsOneOfTheUnwantedParams)
					return casUrl;
				else
					cleanedUrl = removeHttpGetParameters(cleanedUrl,
							paramsToBeRemoved);
			}
		}
		return cleanedUrl;
	}


还有一处需要调整的地方就是当用户名和密码验证失败后,应该重新返回至子系统登录页,也就是  login-at 参数值,此时同样需要重新生成 login ticket。 于是找到 cas 登录验证处理 action :org.jasig.cas.web.flow.AuthenticationViaFormAction  修改 submit方法 中代码下如:
try {
            WebUtils.putTicketGrantingTicketInRequestScope(context, this.centralAuthenticationService.createTicketGrantingTicket(credentials));
            putWarnCookieIfRequestParameterPresent(context);
            return "success";
        } catch (final TicketException e) {
            populateErrorsInstance(e, messageContext);
            // 当验证失败后,判断参数中是否获否 login-at 参数,如果包含的话则跳转至 login ticket 获取页
            String referer = context.getRequestParameters().get("login-at");
            if (!org.apache.commons.lang.StringUtils.isBlank(referer)) {
                return "errorForRemoteRequestor";
            }
            return "error";
        }




接下来要做的就是将该action 的处理加入到 login-webflow.xml 请求流中:
<on-start>
        <evaluate expression="initialFlowSetupAction" />
    </on-start>
   <!-- 添加如下配置 :-->
    <action-state id="provideLoginTicket">
    	<evaluate expression="provideLoginTicketAction"/>
    	<transition on="loginTicketRequested" to ="viewRedirectToRequestor" />
		<transition on="continue" to="ticketGrantingTicketExistsCheck" />
    </action-state>

	<view-state id="viewRedirectToRequestor" view="casRedirectToRequestorView" model="credentials">
		<var name="credentials" class="org.jasig.cas.authentication.principal.UsernamePasswordCredentials" />
        <binder>
            <binding property="username" />
            <binding property="password" />
        </binder>
        <on-entry>
            <set name="viewScope.commandName" value="'credentials'" />
        </on-entry>
		<transition on="submit" bind="true" validate="true" to="realSubmit">
            <set name="flowScope.credentials" value="credentials" />
            <evaluate expression="authenticationViaFormAction.doBind(flowRequestContext, flowScope.credentials)" />
        </transition>
	</view-state>
       <!---添加结束处 --->
	<decision-state id="ticketGrantingTicketExistsCheck">
		<if test="flowScope.ticketGrantingTicketId neq null" then="hasServiceCheck" else="gatewayRequestCheck" />
	</decision-state>

      <!-- ..... 省略中间代码 ...-->

<action-state id="realSubmit">
        <evaluate expression="authenticationViaFormAction.submit(flowRequestContext, flowScope.credentials, messageContext)" />
		<transition on="warn" to="warn" />
		<transition on="success" to="sendTicketGrantingTicket" />
		<transition on="error" to="viewLoginForm" />
<!--加入该transition , 当验证失败之后重新获取login ticket -->
		<transition on="errorForRemoteRequestor" to="viewRedirectToRequestor" />
	</action-state>


好了,至此,对server端的调整基本上已经大功告成了,现在开始写一个测试远程登录的 html:

<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Test remote Login using JS</title>
<script type="text/javascript">
function prepareLoginForm() {
	$('myLoginForm').action = casLoginURL;
	$("lt").value = loginTicket;
}

function checkForLoginTicket() {
	var loginTicketProvided = false;
	var query	= '';
   casLoginURL = 'http://192.168.6.1:8080/member/login';
   thisPageURL = 'http://192.168.6.1:8080/member/test-login.html';
   casLoginURL += '?login-at=' + encodeURIComponent (thisPageURL);

	query	= window.location.search;
	query	= query.substr (1);


	var param	= new Array();
	//var value	= new Array();
	var temp	= new Array();
	param	= query.split ('&');

	i = 0;
        // 开始获取当前 url 的参数,获到 lt 和 error_message。
	while (param[i]) {
		temp		= param[i].split ('=');
		if (temp[0] == 'lt') {
			loginTicket = temp[1];
			loginTicketProvided = true;
		}
		if (temp[0] == 'error_message') {
        	    error = temp[1];
        	}
		i++;
	}
        // 判断是否已经获取到 lt 参数,如果未获取到则跳转至 cas/login 页,并且带上请求参数  get-lt=true。 第一次进该页面时会进行一次跳转
	if (!loginTicketProvided) {
		location.href = casLoginURL + '&get-lt=true';
	}
}

var $ = function(id){
	return document.getElementById(id);
}


checkForLoginTicket();
onload = prepareLoginForm;
</script>
</head>
<body>
<h2>Test remote Login using JS</h2>
<form id="myLoginForm" action="" method="post">
<input type="hidden" name="_eventId" value="submit" />
<table>
<tr>
    <td id="txt_error" colspan="2">

	<script type="text/javascript" language="javascript">
	<!--
	if ( error ) {
	
		error = decodeURIComponent (error);
		
		document.write (error);
	}
	//-->
	</script>

	</td>
</tr>
<tr>
	<td>Username:</td>
	<td><input type="text" value="" name="username" ></td>
</tr>
<tr>
	<td>Password:</td>
	<td><input type="text" value="" name="password" ></td>
</tr>
<tr>
	<td>Login Ticket:</td>
	<td><input type="text" name="lt" id="lt" value=""></td>
</tr>
<tr>
	<td>Service:</td>
	<td><input type="text" name="service" value="http://www.google.com.hk"></td>
</tr>
<tr>
	<td align="right" colspan="2"><input type="submit" /></td>
</tr>
</table>
</form>
</body>
</html>


开始测试,直接访问:http://192.168.6.1:8080/member/test-login.html  发现进行了二次重定向,进入该页面 js 未发现 lt 参数,于是重定向到 http://192.168.6.1:8080/member/login?login-at=http://192.168.6.1:8080/member/test-login.html &get-lt=true ,然后又从该页重定向到 http://192.168.6.1:8080/member/test-login.html?lt=e1s1 ,可以发现,其中的  lt 就是我们所需要的 login ticket参数。


6. 不足之处
       1. 可以发现,每次用户访问 登录页面时都要进行两次重定向的操作,虽然很快,但是在有些情况仍然能看到登录页面闪了一下。 当然这也是有办法可以解决的!
       2. 可以发现,当登录失败之后,会将错误信息以参数的方式进行传递,看上去这并非专业做法。可以定义一些错误标识,比如 1 是用户名或密码错误之类的。

PS:参考:https://wiki.jasig.org/display/CAS/Using+CAS+without+the+Login+Screen  如有不足之处,欢迎指正~
  • 大小: 37.3 KB
  • 大小: 50.4 KB
  • 大小: 31.5 KB
16
1
分享到:
评论
65 楼 zz210891470 2017-02-21  
使用您的例子从服务器获取lt返回test-login 页面之后又回去取lt了,陷入了死循环页面停不下来,,,能上份完整的源码吗。。。如果可以的话 您能发一份到我邮箱吗  517418860@qq.com
64 楼 feiteyizu 2016-08-03  
WANTAWAY314 写道
这种方法适合移动端的单点登录不?大神

移动端的单点登录 有单独的 cas restful api 
63 楼 WANTAWAY314 2016-03-21  
这种方法适合移动端的单点登录不?大神
62 楼 feiteyizu 2015-10-10  
按照文章是可以配成功的,高版本cas按章原方式会直接跳回cas登陆页问题,

高版本的cas的lt是${loginTicket},可以在基础上调整下,provideLoginTicket流程里跳到生成loginTicket的流程里生成loginTicket后在跳回viewRedirectToRequestor,以下为修改处:

<action-state id="provideLoginTicket"> 
        <evaluate expression="provideLoginTicketAction"/> 
        <!-- 生成loginTicket-->
        <transition on="loginTicketRequested" to ="generateLoginTicket" /> 
        <transition on="continue" to="ticketGrantingTicketExistsCheck" /> 
</action-state> 

<action-state id="generateLoginTicket">
     <evaluate        expression="generateLoginTicketAction.generate(flowRequestContext)" />
<!--
<transition on="generated" to="viewLoginForm" />
                 生成loginTicket后跳回viewRedirectToRequestor
-->
<transition on="generated" to="viewRedirectToRequestor" />
</action-state>

viewRedirectToRequestor.jsp中获取${loginTicket}传回登陆页就可以了,供研究的朋友参考
61 楼 zhoup_1234 2015-04-23  
您是按什么版本 开发的呢? 我现在可以取到值  lt 可是post 过去就跳登录页。是什么情况呢
60 楼 denger 2015-03-21  
59 楼 黑黑yy 2015-03-17  
黑黑yy 写道
casLoginURL = 'http://192.168.6.1:8080/member/login'; 
  thisPageURL = 'http://192.168.6.1:8080/member/test-login.html';

测试页面里面配的都是服务端,请问下客户端怎么做呢,整个文章都没提到过客户端


楼主帮忙指导下,谢谢
58 楼 denger 2015-03-17  
inlhx 写道
这个太复杂了并且不容易成功,试试这个方式:http://inlhx.iteye.com/blog/2175859 直接cas ajax提交,连源码都不用改


这个方法比较暴力~  不推荐, 其中还需要 ajax 下载和解析 login 页的 html,对于稍大型 应用 来说非常不适合 。。。 如果 html 代码结构发生变更了,就歇了
57 楼 inlhx 2015-03-17  
这个太复杂了并且不容易成功,试试这个方式:http://inlhx.iteye.com/blog/2175859 直接cas ajax提交,连源码都不用改
56 楼 黑黑yy 2015-03-17  
casLoginURL = 'http://192.168.6.1:8080/member/login'; 
  thisPageURL = 'http://192.168.6.1:8080/member/test-login.html';

测试页面里面配的都是服务端,请问下客户端怎么做呢,整个文章都没提到过客户端
55 楼 denger 2015-03-03  
目前按以上方式上线的产品:
http://uc.noahedu.com/  (CAS client)
http://passport.noahedu.com  (CAS Server)


不明白的看看请求及交互吧~~别乱说误导人~
54 楼 denger 2015-03-03  
honglei0412 写道
honglei0412 写道
楼主您做的这个案例的登录页面是在cas的服务端做了一个登录页面 ,而不是在子系统中做的登录页面 ?是不?


这样做有什么意思啊 楼主?


引用
,都需要进入CAS-Server的登录页填写用户名和密码登录,但是如果存在多个子应用系统时,它们可能都有相应风格的登录页面,我们希望直接在子系统中登录成功,而不是每次都要跳转到CAS的登录页去登录。

看不明白吗? 每个子系统自定义登录页,一开始就说了。
53 楼 honglei0412 2015-03-03  
honglei0412 写道
楼主您做的这个案例的登录页面是在cas的服务端做了一个登录页面 ,而不是在子系统中做的登录页面 ?是不?


这样做有什么意思啊 楼主?
52 楼 honglei0412 2015-03-03  
楼主您做的这个案例的登录页面是在cas的服务端做了一个登录页面 ,而不是在子系统中做的登录页面 ?是不?
51 楼 honglei0412 2015-02-27  
这个viewRedirectToRequestor.jsp 页面中的 else <script>window.location.href = "/member/login";</script>  直接就调回到登录页面了 ,下来就死循环了 ,楼主不知道发现了没有?
50 楼 honglei0412 2015-02-27  
楼主您的那个案例有跑过吗? 有没有出现过登陆后直接死循环的现象?
49 楼 ruodian 2014-11-06  
楼主 请问login.jsp的页面是怎么样的?我执行到
http://192.168.6.1:8080/member/login?login-at=http://192.168.6.1:8080/member/test-login.html &get-lt=true 的时候

后台执行到
if (request.getParameter("get-lt") != null && request.getParameter("get-lt").equalsIgnoreCase("true")) {
return result("loginTicketRequested");
}
return result("continue");

取到的url是  /cas/login?service=http%3A%2F%2Flocalhost%3A8081%2Fpsbc%2Fj_spring_cas_security_check
就没get-lt了
48 楼 cjdxhjj 2014-05-04  
你好,博主,我使用的3.5.2.1,登陆需要execution 参数和lt参数,execution像你们例子的,4位如e4s1,不过lt是形如LT-3-eIDsiRQJg53tb40OEUwrii2b5TIvOP这样的,请问我怎么取到
47 楼 saivicky1314 2014-03-28  
{"failure":"true","exception.message":"org.springframework.webflow.execution.FlowExecutionException: Exception thrown in state 'viewRedirectToRequestor' of flow 'login'","exception.stacktrace":"org.springframework.webflow.execution.FlowExecutionException: Exception thrown in state 'viewRedirectToRequestor' of flow 'login'\r\n\tat总是报这个错
46 楼 saivicky1314 2014-03-28  
cas单点登录可以发一份到我的吗2848182634@qq.com

相关推荐

Global site tag (gtag.js) - Google Analytics