Contents
概覽單點登錄主要用于多系統(tǒng)集成,即在多個系統(tǒng)中,用戶只需要到一個中央服務器登錄一次即可訪問這些系統(tǒng)中的任何一個,無須多次登錄。
本文使用開源框架Jasig CAS 來完成單點登錄。下載地址:https://www./cas/download 。在寫本文時,使用的cas server版本為4.0.1
部署服務器本文服務器使用Tomcat7,下載了cas-server-4.0.0-release.zip ,將其解壓,找到modules目錄下面的cas-server-webapp-4.0.0.war直接復制到webapps文件夾下即可。啟動Tomcat,訪問http://localhost:8080/cas-server-webapp-4.0.0,使用casuser/Mellon登錄,即可登錄成功。
Tomcat默認沒有開啟HTTPS協(xié)議,所以這里直接用了HTTP協(xié)議訪問。為了能使客戶端在HTTP協(xié)議下單點登錄成功,需要修改一下配置:
WEB-INF\spring-configuration\ticketGrantingTicketCookieGenerator.xml和WEB-INF\spring-configuration\warnCookieGenerator.xml:將p:cookieSecure="true"改為p:cookieSecure="false"
WEB-INF\deployerConfigContext.xml:<bean class="org.jasig.cas.authentication.handler.support.HttpBasedServiceCredentialsAuthenticationHandler" p:httpClient-ref="httpClient" />添加p:requireSecure="false"
至此,一個簡單的單點登錄服務器就基本部署好了。
部署客戶端客戶端需要添加對shiro-cas 和cas-client-core這兩個包的依賴。這里主要講跟CAS相關的配置。
之后配置web.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 <!-- 用于單點退出,該過濾器用于實現(xiàn)單點登出功能,可選配置。--> <listener> <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class> </listener> <!-- 該過濾器用于實現(xiàn)單點登出功能,可選配置。 --> <filter> <filter-name>CAS Single Sign Out Filter</filter-name> <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> </filter> <filter-mapping> <filter-name>CAS Single Sign Out Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
自定義Realm:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public class MyCasRealm extends CasRealm { private UserService userService; public void setUserService(UserService userService) { this.userService = userService; } @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String)principals.getPrimaryPrincipal(); SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo(); authorizationInfo.setRoles(userService.findRoles(username)); authorizationInfo.setStringPermissions(userService.findPermissions(username)); return authorizationInfo; } }
配置
1 2 3 4 5 6 7 8 9 10 11 12 13 <bean id="casRealm" class="package.for.your.MyCasRealm"> <property name="userService" ref="userService"/> <property name="cachingEnabled" value="true"/> <property name="authenticationCachingEnabled" value="true"/> <property name="authenticationCacheName" value="authenticationCache"/> <property name="authorizationCachingEnabled" value="true"/> <property name="authorizationCacheName" value="authorizationCache"/> <!--該地址為cas server地址 --> <property name="casServerUrlPrefix" value="${shiro.casServer.url}"/> <!-- 該地址為是當前應用 CAS 服務 URL,即用于接收并處理登錄成功后的 Ticket 的, 必須和loginUrl中的service參數(shù)保持一致,否則服務器會判斷service不匹配--> <property name="casService" value="${shiro.client.cas}"/> </bean>
配置CAS過濾器
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <bean id="casFilter" class="org.apache.shiro.cas.CasFilter"> <property name="failureUrl" value="/casFailure.jsp"/> </bean> <bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean"> <property name="securityManager" ref="securityManager"/> <property name="loginUrl" value="${shiro.login.url}"/> <property name="successUrl" value="${shiro.login.success.url}"/> <property name="filters"> <util:map> <entry key="cas" value-ref="casFilter"/> <entry key="logout" value-ref="logoutFilter" /> </util:map> </property> <property name="filterChainDefinitions"> <value> /casFailure.jsp = anon /cas = cas /logout = logout /** = user </value> </property> </bean>
上面登錄url我的配置的是http://localhost:8080/cas-server/login?service=http://localhost:8080/cas-client/cas,service參數(shù)是之后服務將會跳轉的地址。
/cas=cas:即/cas 地址是服務器端回調(diào)地址,使用 CasFilter 獲取 Ticket 進行登錄。
之后通過eclipse部署,訪問http://localhost:8080/cas-client 即可測試。為了看到單點登錄的效果,可以直接復制一份webapps中的client為client2,只需要修改上述配置中的地址即可。如果用戶已經(jīng)登錄,那么訪問http://localhost:8080/cas-client2發(fā)現(xiàn)不會再跳轉到登錄頁面了,用戶已經(jīng)是登錄狀態(tài)了。
還需要注意一個問題,就是cas server默認是開啟單點登出的但是這里卻沒有這樣的效果,APP1登出了,但是APP2仍能訪問,如果查看瀏覽器的cookie的話,會發(fā)現(xiàn)有兩個sessionid,一個是JSESSIONID,容器原生的,另一個是shiro中配置的:
1 2 3 4 5 6 7 <!-- 會話Cookie模板 --> <bean id="sessionIdCookie" class="org.apache.shiro.web.servlet.SimpleCookie"> SingleSignOutFilter發(fā)現(xiàn)是logoutRequest請求后,原來SingleSignOutHandler中創(chuàng)建的原生的session已經(jīng)被銷毀了,因為從a登出的,a的shiro session也會銷毀, 但是b的shiro的session還沒有被銷毀,于是再訪問b還是能訪問,單點登出就有問題了--> <constructor-arg value="JSESSIONID"/> <property name="httpOnly" value="true"/> <property name="maxAge" value="-1"/>
如果我們把sid改為JSESSIONID會怎么樣,答案是如果改為JSESSIONID會導致重定向循環(huán),原因是當?shù)卿洉r,shiro發(fā)現(xiàn)瀏覽器發(fā)出的請求中的JSESSIONID沒有或已經(jīng)過期,于是生成一個JSESSIONID給瀏覽器,同時鏈接被重定向到服務器進行認證,認證成功后返回到客戶端服務器的cas service url,并且?guī)в幸粋€ticket參數(shù)。因為有SingleSignOutFilter,當發(fā)現(xiàn)這是一個tocken請求時,SingleSignOutHandler會調(diào)用request.getSession()獲取的是原生Session,如果沒有原生session的話,又會創(chuàng)建并將JSESSIONID保存到瀏覽器cookie中,當客戶端服務器向cas服務器驗證ticket之后,客戶端服務器重定向到之前的頁面,這時shiro發(fā)現(xiàn)JSESSIONID是SingleSignOutHandler中生成的,在自己維護的session中查不到,又會重新生成新的session,然后login,然后又會重定向到cas服務器認證,然后再重定向到客戶端服務器的cas service url,不同的是SingleSignOutHandler中這次調(diào)用session.getSession(true)不會新創(chuàng)建一個了,之后就如此循環(huán)。如果使用sid又會導致當單點登出時候,如果有a、b兩個客戶端服務器,從a登出,會跳轉到cas服務器登出,cas服務器會對所有通過它認證的service調(diào)用銷毀session的方法,但是b的shiro的session還沒有被銷毀,于是再訪問b還是能訪問,單點登出就有問題了
之所以這樣是因為我設置shiro的session管理器為DefaultWebSessionManager,這個管理器直接拋棄了容器的session管理器,自己來維護session,所以就會出現(xiàn)上述描述的問題了。如果我們不做設置,那么shiro將使用默認的session管理器ServletContainerSessionManager:Web 環(huán)境,其直接使用 Servlet 容器的會話。這樣單點登出就可以正常使用了。
此外如果我們非要使用DefaultWebSessionManager的話,我們就要重寫一個SingleSignOutFilter、SingleSignOutHandler和SessionMappingStorage了。
如果沒有使用Spring框架,則可以參考如下配置web.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 <?xml version="1.0" encoding="UTF-8"?> <web-app xmlns:xsi="http://www./2001/XMLSchema-instance" xmlns="http://java./xml/ns/javaee" xmlns:web="http://java./xml/ns/javaee/web-app_2_5.xsd" xsi:schemaLocation="http://java./xml/ns/javaee http://java./xml/ns/javaee/web-app_2_5.xsd" id="WebApp_ID" version="2.5"> <display-name>YPshop Authority Manage</display-name> <context-param> <param-name>webAppRootKey</param-name> <param-value>authority.root</param-value> </context-param> <!-- ======================== 單點登錄開始 ======================== --> <!-- 說明:這種客戶端的配置方式是不需要Spring支持的 --> <!-- 參考資料:http://blog.csdn.net/yaoweijq/article/details/6003187 --> <listener> <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class> </listener> <filter> <filter-name>CAS Single Sign Out Filter</filter-name> <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> </filter> <filter-mapping> <filter-name>CAS Single Sign Out Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>CAS Authentication Filter</filter-name> <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class> <init-param> <param-name>casServerLoginUrl</param-name> <param-value>https://localhost:8443/cas-server/login</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>https://localhost:8443</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Authentication Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>CAS Validation Filter</filter-name> <filter-class>org.jasig.cas.client.validation.Cas20ProxyReceivingTicketValidationFilter</filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>https://localhost:8443/cas-server</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>https://localhost:8443</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Validation Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 該過濾器使得開發(fā)者可以通過org.jasig.cas.client.util.AssertionHolder來獲取用戶的登錄名。 比如AssertionHolder.getAssertion().getPrincipal().getName()。 --> <filter> <filter-name>CAS Assertion Thread Local Filter</filter-name> <filter-class>org.jasig.cas.client.util.AssertionThreadLocalFilter</filter-class> </filter> <filter-mapping> <filter-name>CAS Assertion Thread Local Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- ======================== 單點登錄結束 ======================== --> <welcome-file-list> <welcome-file>index.html</welcome-file> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <distributable /> </web-app>
進階 使用HTTPS協(xié)議首先我們需要生成數(shù)字證書
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 keytool -genkey -keystore "D:\localhost.keystore" -alias localhost -keyalg RSA 輸入密鑰庫口令: 再次輸入新口令: 您的名字與姓氏是什么? [Unknown]: localhost 您的組織單位名稱是什么? [Unknown]: xa 您的組織名稱是什么? [Unknown]: xa 您所在的城市或區(qū)域名稱是什么? [Unknown]: xi'an 您所在的省/市/自治區(qū)名稱是什么? [Unknown]: xi'an 該單位的雙字母國家/地區(qū)代碼是什么? [Unknown]: cn CN=localhost, OU=xa, O=xa, L=xi'an, ST=xi'an, C=cn 是否正確 [否]: y 輸入 <localhost> 的密鑰口令 (如果和密鑰庫口令相同, 按回車):
需要注意的是 “您的名字與姓氏是什么?”這個地方不能隨便填的,如果運行過程中提示“Caused by: java.security.cert.CertificateException: No name matching localhost found”那么就是因為這里設置錯了,當然除了localhost也可以寫其他的,如helloworld.com,但是需要能解析出來,可以直接在hosts中加127.0.0.1 helloworld.com
然后,由于Tomcat默認沒有開HTTPS,所以我們需要在server.xml文件中找到8443出現(xiàn)的地方。然后修改如下
1 2 3 4 <Connector port="8443" protocol="HTTP/1.1" SSLEnabled="true" maxThreads="150" scheme="https" secure="true" clientAuth="false" sslProtocol="TLS" keystoreFile="D:\localhost.keystore" keystorePass="123456"/>
keystorePass 就是生成 keystore 時設置的密碼。
如果出現(xiàn)下面的問題,修改server.xml中的protocol為org.apache.coyote.http11.Http11Protocol
Failed to initialize end point associated with ProtocolHandler [“http-apr-8443”] java.lang.Exception: Connector attribute SSLCertificateFile must be defined when using SSL with APR
因為 CAS client 需要使用該證書進行驗證,所以我們要使用 localhost.keystore 導出數(shù)字證書(公鑰)到 D:\localhost.cer。再將將證書導入到 JDK 中。
1 2 3 keytool -export -alias localhost -file D:\localhost.cer -keystore D:\localhost.keystore cd D:\jdk1.7.0_21\jre\lib\security keytool -import -alias localhost -file D:\localhost.cer -noprompt -trustcacerts -storetype jks -keystore cacerts -storepass 123456
如果導入失敗,可以先把 security 目錄下的 cacerts 刪掉
搞定證書之后,我們需要將之前client中配置的地址修改一下。然后還可以添加ssl過濾器。
如果遇到以下異常,一般是證書導入錯誤造成的,請嘗試重新導入,如果還是不行,有可能是運行應用的 JDK 和安裝數(shù)字證書的 JDK 不是同一個造成的:
Caused by: sun.security.validator.ValidatorException: PKIX path building failed: sun.security.provider.certpath.SunCertPathBuilderException: unable to find valid certification path to requested target
單點登出重定向客戶端中配置logout過濾器
1 2 3 <bean id="logoutFilter" class="org.apache.shiro.web.filter.authc.LogoutFilter"> <property name="redirectUrl" value="${shiro.logout.url}"/> </bean>
WEB-INF/cas-servlet.xml中將 cas.logout.followServiceRedirects修改為true即可在登出后重定向到service參數(shù)提供的地址
單點登出單點登出重定向是很好解決了,但是在客戶端與shiro集成過程中,如客戶端部署部分所述,如果shiro沒有使用 ServletContainerSessionManager 管理session,單點登出就會有問題了。最簡單奏效的辦法就是改用 ServletContainerSessionManager 了,但是我們偏要用 DefaultWebSessionManager 呢,那就應該要參考org.jasig.cas.client.session這個包中的幾個類,重新實現(xiàn)單點登出了。我的思路是,添加一個shiro過濾器,繼承自AdviceFilter在preHandle方法中實現(xiàn)邏輯:如果請求中包含了ticket參數(shù),記錄ticket和sessionID的映射;如果請求中包含logoutRequest參數(shù),標記session為無效;如果session不為空,且被標記為無效,則登出。如果請求中包含了logoutRequest參數(shù),那么這個請求是從cas服務器發(fā)出的,所以這里不能直接用subject.logout(),因為subject跟線程綁定,客戶端對cas服務器端的請求會創(chuàng)建一個新的subject。
那么CAS單點登出是怎么實現(xiàn)的呢,下面是我對CAS單點登出的簡單理解:
在TicketGrantingTicketImpl有一個HashMap services字段,以id和通過認證的客戶端service為鍵值對。當我們要登出時LogoutManagerImpl通過for (final String ticketId : services.keySet())向每個service發(fā)送一個POST請求,請求中包含一個logoutRequest參數(shù),參數(shù)的值由SamlCompliantLogoutMessageCreator創(chuàng)建。客戶端的 SingleSignOutFilter會判斷請求中是否包含了logoutRequest參數(shù),如果包含,那么銷毀session。SingleSignOutHttpSessionListener實現(xiàn)了javax.servlet.http.HttpSessionListener接口,用于監(jiān)聽session銷毀事件。
我在配置的過程中發(fā)現(xiàn)單點登出有問題,首先在服務端打開 debug log,cas 服務器默認是打開單點登出功能的,所以正常的話日志中會記錄<Sending logout request for: [https://localhost:8443/cas-client1/cas]>之類的內(nèi)容,有日志記錄發(fā)送了請求,一般服務器應該不會有什么問題了。那么有可能會是客戶端的問題,我重新配置了一個客戶端,這個客戶端沒有使用spring也沒有使用shiro,只用了在部署客戶端中提到的無spring的web.xml文件,發(fā)現(xiàn)從其他客戶端登出,這個客戶端也是登出的,所以這個配置是沒有什么問題。后來在瀏覽器打開控制臺才發(fā)現(xiàn)有兩個SESSIONID一個是sid是在shiro中配置的,另一個是JSESSIONID,應該是容器原生的。再然后就下了3.2.2版本的cas-client-core,通過maven構建,導入eclipse中,開始調(diào)試。我們的cas-client要依賴這個cas-client-core工程,怎么設置可以參考eclipse小技巧 。然后調(diào)試,一定要保證在cas-client的propertie 設置中的Deployment Assembly中已經(jīng)沒有之前的版本的cas-client-core的jar包了。調(diào)試的過程中才發(fā)現(xiàn),SingleSignOutFilter銷毀的是容器原生的session,但是shiro的session還在,所以如果是從其他客戶端登出的,那這個客戶端還是能夠登錄。
通過數(shù)據(jù)庫中的用戶密碼認證服務器端需要添加cas-server-support-jdbc和mysql-connector-java依賴。
cas-server-support-jdbc提供了org.jasig.cas.adaptors.jdbc.QueryDatabaseAuthenticationHandler、org.jasig.cas.adaptors.jdbc.SearchModeSearchDatabaseAuthenticationHandler 和org.jasig.cas.adaptors.jdbc.QueryAndEncodeDatabaseAuthenticationHandler。他們都繼承自AbstractJdbcUsernamePasswordAuthenticationHandler 能夠通過配置sql語句驗證用戶憑證,后者更復雜些,能夠配置鹽,散列函數(shù)迭代次數(shù)。
下面說一下配置QueryDatabaseAuthenticationHandler,配置/src/main/webapp/WEB-INF/deployerConfigContext.xml,先注釋掉原先的primaryAuthenticationHandler然后添加下面配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <!-- 自定義數(shù)據(jù)庫鑒權 --> <bean id="primaryAuthenticationHandler" class="org.jasig.cas.adaptors.jdbc.QueryDatabaseAuthenticationHandler"> <property name="dataSource" ref="dataSource"/> <property name="sql" value="${auth.sql}"/> <property name="passwordEncoder" ref="MD5PasswordEncoder"/> </bean> <!-- 數(shù)據(jù)源 --> <bean id="dataSource" class="org.springframework.jdbc.datasource.DriverManagerDataSource"> <property name="driverClassName" value="${dataSource.driver}"></property> <property name="url" value="${dataSource.url}"/> <property name="username" value="${dataSource.username}"/> <property name="password" value="${dataSource.password}"/> </bean> <!-- MD5加密 --> <bean id="MD5PasswordEncoder" class="org.jasig.cas.authentication.handler.DefaultPasswordEncoder"> <constructor-arg value="MD5"/> </bean>
加密算法可以自定義。
添加驗證碼驗證碼的實現(xiàn)使用了kaptcha,所以需要添加其依賴。
web.xml添加如下配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <servlet> <servlet-name>Kaptcha</servlet-name> <servlet-class>com.google.code.kaptcha.servlet.KaptchaServlet</servlet-class> <!-- 設定寬度 --> <init-param> <param-name>kaptcha.image.width</param-name> <param-value>100</param-value> </init-param> <!-- 設定高度 --> <init-param> <param-name>kaptcha.image.height</param-name> <param-value>50</param-value> </init-param> <!-- 如果需要全部是數(shù)字 --> <init-param> <param-name>kaptcha.textproducer.char.string</param-name> <param-value>0123456789</param-value> </init-param> <!-- 去掉干擾線 --> <init-param> <param-name>kaptcha.noise.impl</param-name> <param-value>com.google.code.kaptcha.impl.NoNoise </param-value> </init-param> </servlet> <servlet-mapping> <servlet-name>Kaptcha</servlet-name> <url-pattern>/captcha.jpg</url-pattern> </servlet-mapping>
在login-webflow.xml中找到viewLoginForm,在binder節(jié)點下面添加<binding property="captcha" />,對應我們頁面提交的驗證碼參數(shù)
然后我們還要實現(xiàn)一個UsernamePasswordCaptchaCredential 類,繼承UsernamePasswordCredential 在其中添加了captcha字段和相應setter和getter方法。
1 2 3 4 5 6 7 8 public class UsernamePasswordCaptchaCredential extends UsernamePasswordCredential { private static final long serialVersionUID = -2988130322912201986L; @NotNull @Size(min=1,message = "required.captcha") private String captcha; //set、get方法 }
接著回到 login-webflow.xml ,找到credential的聲明處,將org.jasig.cas.authentication.UsernamePasswordCredential修改為剛剛實現(xiàn)的類全路徑名。viewLoginForm 也需要修改
1 2 3 <transition on="submit" bind="true" validate="true" to="validatorCaptcha"> <evaluate ="authenticationViaFormAction.doBind(flowRequestContext, flowScope.credential)" /> </transition>
再添加如下配置
1 2 3 4 5 6 <!-- 添加一個 validatorCaptcha 校驗驗證碼的操作 --> <action-state id="validatorCaptcha"> <evaluate ="authenticationViaFormAction.validatorCaptcha(flowRequestContext, flowScope.credential, messageContext)"></evaluate> <transition on="error" to="generateLoginTicket" /> <transition on="success" to="realSubmit" /> </action-state>
我們在配置中添加了一個 validatorCaptcha 的操作,同時可以看到 是 authenticationViaFormAction.validatorCaptcha(…),所以我們需要在 authenticationViaFormAction 中添加一個校驗驗證碼的方法 validatorCaptcha()。authenticationViaFormAction 這個bean是配置在 cas-servlet.xml 中的:
1 2 3 4 <bean id="authenticationViaFormAction" class="org.jasig.cas.web.flow.AuthenticationViaFormAction" p:centralAuthenticationService-ref="centralAuthenticationService" p:warnCookieGenerator-ref="warnCookieGenerator" p:ticketRegistry-ref="ticketRegistry"/>
我們可以看看 org.jasig.cas.web.flow.AuthenticationViaFormAction 的源代碼,里面有一個 submit 方法,這個就是我們提交表單時的方法了。繼承AuthenticationViaFormAction實現(xiàn)一個新類
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class MyAuthenticationViaFormAction extends AuthenticationViaFormAction{ public final String validatorCaptcha(final RequestContext context, final Credential credential, final MessageContext messageContext){ final HttpServletRequest request = WebUtils.getHttpServletRequest(context); HttpSession session = request.getSession(); String captcha = (String)session.getAttribute(com.google.code.kaptcha.Constants.KAPTCHA_SESSION_KEY); session.removeAttribute(com.google.code.kaptcha.Constants.KAPTCHA_SESSION_KEY); UsernamePasswordCaptchaCredential upc = (UsernamePasswordCaptchaCredential)credential; String submitAuthcodeCaptcha =upc.getCaptcha(); if(!StringUtils.hasText(submitAuthcodeCaptcha) || !StringUtils.hasText(submitAuthcodeCaptcha)){ messageContext.addMessage(new MessageBuilder().code("required.captcha").build()); return "error"; } if(submitAuthcodeCaptcha.equals(captcha)){ return "success"; } messageContext.addMessage(new MessageBuilder().code("error.authentication.captcha.bad").build()); return "error"; } }
這邊有拋出兩個異常,這兩個異常信息 required.captcha、error.authentication.captcha.bad 需要在 messages_zh_CN.properties 文件下添加
1 2 required.captcha=必須輸入驗證碼。 error.authentication.captcha.bad=您輸入的驗證碼有誤。
然后把 authenticationViaFormAction 這個Bean路徑修改為我們新添加的類的全路徑名。
當然最后,我們的頁面也需要修改,找到casLoginView.jsp添加
1 2 3 4 5 6 <section class="row"> <spring:message code="screen.welcome.label.captcha.accesskey" var="captchaAccessKey" /> <spring:message code="screen.welcome.label.captcha" var="captchaHolder" /> <form:input cssClass="required" cssErrorClass="error" id="captcha" size="10" tabindex="3" path="captcha" placeholder="${captchaHolder }" accesskey="${captchaAccessKey}" autocomplete="off" htmlEscape="true" /> <img alt="${captchaHolder }" src="captcha.jpg" onclick="this.src='captcha.jpg?'+Math.random();"> </section>
以上添加驗證碼參考http://www.cnblogs.com/vhua/p/cas_3.html
添加記住密碼可以參考http://jasig./cas/development/installation/Configuring-LongTerm-Authentication.html
在cas.properties中添加如下配置
1 2 # Long term authentication session length in seconds rememberMeDuration=1209600
spring-configuration文件夾下找到 ticketExpirationPolicies.xml 和 ticketGrantingTicketCookieGenerator.xml 需要在這兩個配置文件中定義長期有效的session
在 ticketExpirationPolicies.xml文件中更新如下配置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <bean id="standardSessionTGTExpirationPolicy" class="org.jasig.cas.ticket.support.TicketGrantingTicketExpirationPolicy" p:maxTimeToLiveInSeconds="${tgt.maxTimeToLiveInSeconds:28800}" p:timeToKillInSeconds="${tgt.timeToKillInSeconds:7200}"/> <!-- | The following policy applies to long term CAS SSO sessions. | Default duration is two weeks (1209600s). --> <bean id="longTermSessionTGTExpirationPolicy" class="org.jasig.cas.ticket.support.TimeoutExpirationPolicy" c:timeToKillInMilliSeconds="#{ ${rememberMeDuration:1209600} * 1000 }" /> <bean id="grantingTicketExpirationPolicy" class="org.jasig.cas.ticket.support.RememberMeDelegatingExpirationPolicy" p:sessionExpirationPolicy-ref="standardSessionTGTExpirationPolicy" p:rememberMeExpirationPolicy-ref="longTermSessionTGTExpirationPolicy" />
更新ticketGrantingTicketCookieGenerator.xml
1 2 3 4 5 6 <bean id="ticketGrantingTicketCookieGenerator" class="org.jasig.cas.web.support.CookieRetrievingCookieGenerator" p:cookieSecure="true" p:cookieMaxAge="-1" p:rememberMeMaxAge="${rememberMeDuration:1209600}" p:cookieName="CASTGC" p:cookiePath="/cas" />
在 deployerConfigContext.xml 中找到 PolicyBasedAuthenticationManager 使其包含RememberMeAuthenticationMetaDataPopulator組件
1 2 3 4 5 6 7 8 <property name="authenticationMetaDataPopulators"> <list> <bean class="org.jasig.cas.authentication.SuccessfulHandlerMetaDataPopulator" /> <bean class="org.jasig.cas.authentication.principal.RememberMeAuthenticationMetaDataPopulator" /> </list> </property>
和添加驗證碼類似的,我們還需要修改login-webflow.xml
找到credential 的聲明修改如下
1 <var name="credential" class="org.jasig.cas.authentication.RememberMeUsernamePasswordCredential" />
由于之前已經(jīng)實現(xiàn)了驗證碼,所以這里不需要修改了,只需讓 UsernamePasswordCaptchaCredential繼承RememberMeUsernamePasswordCredential即可
找到viewLoginForm 在binder節(jié)點下添加<binding property="rememberMe" />
更新 casLoginView.jsp
1 2 3 4 <section class="row check"> <input id="rememberMe" name="rememberMe" value="false" tabindex="4" accesskey="<spring:message code="screen.welcome.label.rememberMe.accesskey" />" type="checkbox" /> <label for="rememberMe"><spring:message code="screen.welcome.label.rememberMe" /></label> </section>
自定義primaryAuthenticationHandler雖然已經(jīng)有QueryDatabaseAuthenticationHandler和QueryAndEncodeDatabaseAuthenticationHandler兩個類,能夠通過配置sql語句驗證用戶憑證,后者還能配置鹽,散列函數(shù)迭代次數(shù)。但是我們可能還需要判斷用戶是否被鎖定或被禁用了,我們可以參考QueryAndEncodeDatabaseAuthenticationHandler自定義一個AuthenticationHandler,繼承AbstractJdbcUsernamePasswordAuthenticationHandler。添加兩個字段名lockedFieldName和disabledFieldName通過這兩個字段判斷用戶是否被鎖定或被禁用,關鍵代碼如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 public class ValidUserQueryDBAuthenticationHandler extends AbstractJdbcUsernamePasswordAuthenticationHandler{ ...... private static final String DEFAULT_LOCKED_FIELD = "locked"; private static final String DEFAULT_DISABLED_FIELD = "disabled"; ...... @NotNull protected String disabledFieldName = DEFAULT_DISABLED_FIELD; @NotNull protected String lockedFieldName = DEFAULT_LOCKED_FIELD; ...... public ValidUserQueryDBAuthenticationHandler(final DataSource datasource, final String sql, final String algorithmName) { super(); setDataSource(datasource); this.sql = sql; this.algorithmName = algorithmName; } @Override protected final HandlerResult authenticateUsernamePasswordInternal(final UsernamePasswordCredential transformedCredential) throws GeneralSecurityException, PreventedException { final String username = getPrincipalNameTransformer().transform(transformedCredential.getUsername()); try { final Map<String, Object> values = getJdbcTemplate().queryForMap(this.sql, username); if (Boolean.TRUE.equals(values.get(this.disabledFieldName))) { throw new AccountDisabledException(username + " has been disabled."); } if (Boolean.TRUE.equals(values.get(this.lockedFieldName))) { throw new AccountLockedException(username + " has been locked."); } final String digestedPassword = digestEncodedPassword(transformedCredential.getPassword(), values); if (!values.get(this.passwordFieldName).equals(digestedPassword)) { throw new FailedLoginException("Password does not match value on record."); } return createHandlerResult(transformedCredential, new SimplePrincipal(username), null); } catch (final IncorrectResultSizeDataAccessException e) { if (e.getActualSize() == 0) { throw new AccountNotFoundException(username + " not found with SQL query"); } else { throw new FailedLoginException("Multiple records found for " + username); } } catch (final DataAccessException e) { throw new PreventedException("SQL exception while executing query for " + username, e); } } protected String digestEncodedPassword(final String encodedPassword, final Map<String, Object> values) { final ConfigurableHashService hashService = new DefaultHashService(); if (StringUtils.isNotBlank(this.staticSalt)) { hashService.setPrivateSalt(ByteSource.Util.bytes(this.staticSalt)); } hashService.setHashAlgorithmName(this.algorithmName); Long numOfIterations = this.numberOfIterations; if (values.containsKey(this.numberOfIterationsFieldName)) { final String longAsStr = values.get(this.numberOfIterationsFieldName).toString(); numOfIterations = Long.valueOf(longAsStr); } hashService.setHashIterations(numOfIterations.intValue()); if (!values.containsKey(this.saltFieldName)) { throw new RuntimeException("Specified field name for salt does not exist in the results"); } final String dynaSalt = values.get(this.saltFieldName)==null?"":values.get(this.saltFieldName).toString(); final HashRequest request = new HashRequest.Builder() .setSalt(dynaSalt) .setSource(encodedPassword) .build(); return hashService.computeHash(request).toHex(); } public final void setDisabledFieldName(final String disabledFieldName) { this.disabledFieldName = disabledFieldName; } public final void setLockedFieldName(final String lockedFieldName) { this.lockedFieldName = lockedFieldName; } }
然后更新配置deployerConfigContext.xml
1 2 3 4 5 <bean id="primaryAuthenticationHandler" class="io.github.howiefh.cas.authentication.ValidUserQueryDBAuthenticationHandler"> <constructor-arg ref="dataSource" index="0"></constructor-arg> <constructor-arg value="${auth.sql}" index="1"></constructor-arg> <constructor-arg value="MD5" index="2"></constructor-arg> </bean>
自定義登錄頁面
在cas.properties 修改 cas.viewResolver.basename 值為 custom_view ,那樣系統(tǒng)就會自動會查找 custom_view.properties 這個配置文件
直接復制原來的 default_views.properties 就行了,重命名為custom_view.properties
把 custom_view.properties 中的WEB-INF\view\jsp\default全部替換把這地址替換成 WEB-INF\view\jsp\custom
接下來把 cas\WEB-INF\view\jsp\default 下面的所有文件復制,然后重命名為我們需要的名稱,cas\WEB-INF\view\jsp\custom
主要修改casLoginView.jsp和cas.css即可
布局時遇到一個問題,就是將頁腳固定在頁面底部??梢詤⒖?a href="http://www./css/css-sticky-foot-at-bottom-of-the-page" target="_blank" rel="external">如何將頁腳固定在頁面底部
其它【SSO單點系列】(4):CAS4.0 SERVER登錄后用戶信息的返回 在多點環(huán)境下使用cas實現(xiàn)單點登陸及登出 關于單點登錄中的用戶信息存儲問題的探討
原理從結構來看,CAS主要分為Server和Client。Server主要負責對用戶的認證工作;Client負責處理客戶端受保護資源的訪問請求,登錄時,重定向到Server進行認證。
基礎模式的SSO訪問流程步驟:
訪問服務:客戶端發(fā)送請求訪問應用系統(tǒng)提供的服務資源。
定向認證:客戶端重定向用戶請求到中心認證服務器。
用戶認證:用戶進行身份認證
發(fā)放票據(jù):服務器會產(chǎn)生一個隨機的 Service Ticket 。
驗證票據(jù): SSO 服務器驗證票據(jù) Service Ticket 的合法性,驗證通過后,允許客戶端訪問服務。
傳輸用戶信息: SSO 服務器驗證票據(jù)通過后,傳輸用戶認證結果信息給客戶端。
CAS最基本的協(xié)議過程:
CAS 最基本的協(xié)議過程
如上圖: CAS Client 與受保護的客戶端應用部署在一起,以 Filter 方式保護 Web 應用的受保護資源,過濾從客戶端過來的每一個 Web 請求,同時, CAS Client 會分析 HTTP 請求中是否包含請求 Service Ticket( ST 上圖中的 Ticket) ,如果沒有,則說明該用戶是沒有經(jīng)過認證的;于是 CAS Client 會重定向用戶請求到 CAS Server ( Step 2 ),并傳遞 Service (要訪問的目的資源地址)。 Step 3 是用戶認證過程,如果用戶提供了正確的 Credentials , CAS Server 隨機產(chǎn)生一個相當長度、唯一、不可偽造的 Service Ticket ,并緩存以待將來驗證,并且重定向用戶到 Service 所在地址(附帶剛才產(chǎn)生的 Service Ticket ) , 并為客戶端瀏覽器設置一個 Ticket Granted Cookie ( TGC ) ; CAS Client 在拿到 Service 和新產(chǎn)生的 Ticket 過后,在 Step 5 和 Step6 中與 CAS Server 進行身份核實,以確保 Service Ticket 的合法性。
在該協(xié)議中,所有與 CAS Server 的交互均采用 SSL 協(xié)議,以確保 ST 和 TGC 的安全性。協(xié)議工作過程中會有兩次重定向的過程。但是 CAS Client 與 CAS Server 之間進行 Ticket 驗證的過程對于用戶是透明的(使用 HttpsURLConnection )。
相關概念TGT、ST、PGT、PGTIOU、PT,其中TGT、ST是CAS1.0協(xié)議中就有的票據(jù),PGT、PGTIOU、PT是CAS2.0協(xié)議中有的票據(jù)。
CAS為用戶簽發(fā)登錄票據(jù),CAS認證成功后,將TGT對象放入自己的緩存,CAS生成cookie即TGC,自后登錄時如果有TGC的話,則說明用戶之前登錄過,如果沒有,則用戶需要重新登錄。
TGC (Ticket-granting cookie):存放用戶身份認證憑證的cookie,在瀏覽器和CAS Server用來明確用戶身份的憑證。
ST(Service Ticket):CAS服務器通過瀏覽器分發(fā)給客戶端服務器的票據(jù)。一個特定服務只能有一個唯一的ST。
PGT(Proxy Granting Ticket):由 CAS Server 頒發(fā)給擁有 ST 憑證的服務, PGT 綁定一個用戶的特定服務,使其擁有向 CAS Server 申請,獲得 PT 的能力。
PGTIOU(全稱 Proxy Granting Ticket I Owe You):作用是將通過憑證校驗時的應答信息由 CAS Server 返回給 CAS Client ,同時,與該 PGTIOU 對應的 PGT 將通過回調(diào)鏈接傳給 Web 應用。 Web 應用負責維護 PGTIOU 與 PGT 之間映射關系的內(nèi)容表。PGTIOU是CAS的serviceValidate接口驗證ST成功后,CAS會生成驗證ST成功的xml消息,返回給Proxy Service,xml消息中含有PGTIOU,proxy service收到Xml消息后,會從中解析出PGTIOU的值,然后以其為key,在map中找出PGT的值,賦值給代表用戶信息的Assertion對象的pgtId,同時在map中將其刪除。
PT(Proxy Ticket):是應用程序代理用戶身份對目標程序進行訪問的憑證;
CAS 基本流程圖(沒有使用PROXY代理)
CAS 基本流程圖(沒有使用PROXY代理)
對于客戶端來說會通過客戶端session判斷用戶是否已認證,沒有的話跳轉到服務器認證,對于服務器,通過SSO session判斷用戶是否認證,沒有的話跳到登錄頁面。
CAS 基本流程圖(使用PROXY代理)
CAS 基本流程圖(使用PROXY代理)
這一節(jié)參考:
【SSO單點系列】(6):CAS4.0 單點流程序列圖(中文版)以及相關術語解釋(TGT、ST、PGT、PT、PGTIOU) CAS實現(xiàn)SSO單點登錄原理
代碼:github