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 来实现不刷新提交,不过使用这种方式同样会带来两个问题:
对于以上两种方案,并非说不能实现,只是说对于一个灵活的登录系统来说仍然还是会存在一定的局限性的,我们坚信能有更好的方案来解决这个问题。
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
Java 代码
- /**
- * 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 中:
Xml 代码
- <bean id=”provideLoginTicketAction” class=”com.denger.sso.web.ProvideLoginTicketAction” />
还需要定义 loginTicket 的生成页也就是当返回 loginTicketRequested 的 view:
viewRedirectToRequestor.jsp
Java 代码
- <%@ 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 中:
Config 代码
- ### 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 代码:
Java 代码
- 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 方法 中代码下如:
Java 代码
- 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 请求流中:
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:
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;
- queryquery = 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 如有不足之处,欢迎指正~