<style> .red { color: red; } </style> # SSO 技術實踐: CAS整合JWT(Client篇) ###### tags: `CAS` ## 前言: 這篇需要搭配[SSO 技術實踐: CAS整合JWT(Server篇)](/328QeSlORYeg8tsmPDfLIQ)一起看,這篇主要會介紹關於jwt如何在client這邊驗證,以及處理關於server發過來的SLO request怎麼處理。 另外,關於此篇不管是實作還是設定,都只是基於開發練習用,實際上關於認證及驗證的方式相信會有更好的方式,此篇僅為了練習而採用簡單的方式實作。 -------------------------------------------------- ## 使用版本及使用套件: * CAS Server相關設定傳送門: [SSO 技術實踐: CAS整合JWT(Server篇)](/328QeSlORYeg8tsmPDfLIQ) * CAS Server v6.0: https://github.com/apereo/cas-overlay-template/tree/6.0 * CAS Client: https://github.com/cas-projects/cas-sample-java-webapp * Tomcat Server: CAS Server * 1、CAS Client * 2 CAS Protocol v3.0 Server使用套件(請加入在build.gradle): * [JWT as Service Ticket](https://apereo.github.io/cas/6.6.x/installation/Configure-ServiceTicket-JWT.html) * [LDAP Authentication](https://apereo.github.io/cas/6.6.x/installation/LDAP-Authentication.html#ldap-authentication) Client使用套件(請加入在pom.xml): * [JOSE4j v0.7.0](https://mvnrepository.com/artifact/org.bitbucket.b_c/jose4j/0.7.0) :解析和驗證jwt字串 * [JSON](https://mvnrepository.com/artifact/org.json/json/20190722) * [OPENSAML J v2.6.4](https://mvnrepository.com/artifact/org.opensaml/opensaml/2.6.4) :接收Server發送的SLO request時需要解析saml字串 * [Tomcat util v8.5.23](https://mvnrepository.com/artifact/org.apache.tomcat/tomcat-util/8.5.23) -------------------------------------------------- ## CAS Client web.xml設定 因為整體web.xml配置有點長,會拆局部一一說明。 可參考: [java cas 單點登入web.xml配置](https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/617059/)[1] #### CAS Single Sign Out Filter: 由於filter的順序是會影響實際先流入哪個filter,因此順序非常重要,SSO Filter之所以需要擺第一個,是因為這是主要在控管sso狀態的filter。 下方的listener則是負責監聽slo request的部分,<span class="red">這個listener如果沒有的話,client會收不到訊息,而server則是不管發出的slo request是否有成功,都不會報異常!!</span> 所以想知道問題出在哪,就必須把中斷點下在filter內,當初也是因為這樣才發現slo request溝通過程ssl handshake失敗... ```xml= <filter> <filter-name>CAS Single Sign Out Filter</filter-name> <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>https://CAS Server的域名/cas</param-value> </init-param> </filter> <listener> <listener-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class> </listener> <filter-mapping> <filter-name>CAS Single Sign Out Filter</filter-name> <url-pattern>/servlet/login</url-pattern> </filter-mapping> ``` #### CAS Authentication Filter: 此filter負責導向CAS Server做登入認證,如果是剛從git抓下來解壓後,他應該是會寫成`/*`的pattern,但因為我們要讓只有經過登入頁面的request才導去cas server,因此url pattern需要設定成client登入的路徑。 ```xml= <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://CAS Server的域名/cas/login</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>https://當前Client的域名/cas-sample-java-webapp1/index.jsp</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Authentication Filter</filter-name> <url-pattern>/servlet/login</url-pattern> </filter-mapping> ``` #### CAS Validation Filter: 此filter是用來驗證ticket的部分,<span class="red">但請注意!!</span> 原本如果只是單純的CAS流程,這邊的確是需要設定的,但因為今天我們採取的是<span class="red">搭配jwt的流程,變成驗證的動作是在client端執行</span>,所以這整段都需要註解掉,不需要設定,也就是說下面這段看看就好~ ```xml= <filter> <filter-name>CAS Validation Filter</filter-name> <filter-class>org.jasig.cas.client.validation.Saml11TicketValidationFilter</filter-class> <filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter</filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>https://CAS Server的路徑/cas</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>https://當前Client的域名/cas-sample-java-webapp1/index.jsp</param-value> </init-param> <init-param> <param-name>redirectAfterValidation</param-name> <param-value>true</param-value> </init-param> <init-param> <param-name>useSession</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>CAS Validation Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> ``` #### CAS HttpServletRequest Wrapper Filter 這個filter底層實現了能透過sso中已登錄的session取得principleUser的資料,如果是透過CAS Server驗證成功的話,會塞principle資料到session中,但因為我們沒透過CAS Server做驗證,所以基本上想取得資料,都是透過parse jwt string,如果直接使用session.getRemoteUser()會得到null。 ```xml= <filter> <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> <filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class> </filter> <filter-mapping> <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> ``` #### loginFilter 主要負責request流進來後的走向,這屬於自己實作的部分,不用完全參照,只要登入流程可以work即可,這邊設定為不管任何入口都會攔住進行檢查是否登入。 ```xml= <filter> <display-name>loginFilter</display-name> <filter-name>loginFilter</filter-name> <filter-class>filter.loginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> ``` #### Servlet: login 檢查是否直接攜帶ticket,有的話直接導入到jwtValidate。 ```xml= <servlet> <display-name>login</display-name> <servlet-name>login</servlet-name> <servlet-class>servlet.login</servlet-class> </servlet> <servlet-mapping> <servlet-name>login</servlet-name> <url-pattern>/servlet/login</url-pattern> </servlet-mapping> ``` #### Servlet: logout 這邊有做接收及處理server發送過來的slo request,saml字串處理的部分有另一部分實作。 ```xml= <servlet> <display-name>logout</display-name> <servlet-name>logout</servlet-name> <servlet-class>servlet.logout</servlet-class> </servlet> <servlet-mapping> <servlet-name>logout</servlet-name> <url-pattern>/logout</url-pattern> </servlet-mapping> ``` ## loginFilter實作 這邊主要是當作一開始進來的入口,如果是直接走到login或logout的入口,直接往下流即可,因為下面會有`CAS Authentication Filter`接住後導向CAS Server進行登入。 如果是request其他路徑,就檢查有沒有帶jwt屬性而且驗證過沒有,都沒有的話就導去登入入口,有jwt就導去驗證~ ```java= public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; Enumeration<String> headerNames = httpRequest.getHeaderNames(); while (headerNames.hasMoreElements()) { System.out.println("header:" + httpRequest.getHeader(headerNames.nextElement())); } if(httpRequest.getServletPath().equals("/servlet/login") || httpRequest.getServletPath().equals("/logout")) { chain.doFilter(request, response); } else if(httpRequest.getSession().getAttribute("jwt") == null) { httpResponse.sendRedirect(httpRequest.getContextPath() + "/servlet/login"); } else { chain.doFilter(request, response); } } ``` ## login.java 這邊單純將login近來的入口導到index.jsp裡面。 ```java= protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { if(jwtValidate.jwtvalid(request) && UserList.contains(request.getSession().getId())) { response.sendRedirect(request.getContextPath() + "/index.jsp"); } } ``` -------------------------------------------------- ## JWT驗證處理: jwtValidate.java 由於jwt字串驗證是在client端實作,這邊先提供cas社群的參考資料: [jwt-validation](https://apereo.github.io/cas/6.6.x/installation/Configure-ServiceTicket-JWT.html#jwt-validation---aes)[2] > 關於jwt、jws、jwe三者之間的關係,這篇文章非常有幫助: [JWT & JWE & JWS 大亂鬥!](https://blog.cssuen.tw/jwt-jwe-jws-%E5%A4%A7%E4%BA%82%E9%AC%A5-5ca68bebe426)[3] * 因為在CAS Server jwt有配置encryption key的關係,因此正確來說這邊不單純只是用到jws,而是應該稱<span class="red">jwe</span>才對。 * 由於signing key只是用來<span class="red">驗證簽名</span>的部分,如果想為內容增加安全性,可以使用encryption key把jwt當中的payload再加密一次,成為jwe之後丟給client處理,所以在client也需要配兩把key,但對於jwt的設定來說,encryption key為非必要,只需要設置signing key即可。 * 為了讓client端知道使用者的登入情形,所以我有寫了一個UserList去裝當登入成功後的使用者sessio、id、Service Ticket等等資料,這樣當CAS Server發slo request帶來的Service Ticket時,才能找到要廢除哪個session,但這邊只是為了實驗方便,其實應該是要搭配資料庫之類的才會比較符合實際使用情形。 * 要注意的是由於RFC規定的關係,傳輸的字串都會經過Base64 URLEncode的關係,一開始我完全不知道這件事,導致有時jwt拿去verifySignature()就會fail,找了很久後來才發現把%3D之類的換回=就成功了... 關於這部分的處理可參考: [Java 程式處理網址 URL 百分比編碼與解碼教學](https://blog.gtwang.org/programming/url-percent-encoding-and-decoding-using-java/)[4] ```java public static boolean jwtvalid(HttpServletRequest request) { final String signingKey = "將CAS Server中配置給client1的singingKey放在這"; final String encryptionKey = "將CAS Server中配置給client1的encryptionKey放在這"; final Key key = new AesKey(signingKey.getBytes(StandardCharsets.UTF_8)); final byte[] decodedByte; final String decodedPayload; final JsonWebEncryption jwe; final JsonWebKey jsonWebKey; final JsonWebSignature jws = new JsonWebSignature(); try { String jwt = ""; String queryString = URLDecoder.decode(request.getQueryString(), "UTF-8"); if(request.getSession().getAttribute("jwt") != null) { jwt = request.getSession().getAttribute("jwt").toString(); } else if(queryString.contains("ticket=")) { jwt = queryString.split("ticket=")[1]; } jws.setCompactSerialization(jwt); jws.setKey(key); if(!jws.verifySignature()) { throw new Exception("jwt signature verification failed"); } // jwe處理 decodedByte = Base64.decodeBase64(jws.getEncodedPayload().getBytes(StandardCharsets.UTF_8)); decodedPayload = new String(decodedByte, StandardCharsets.UTF_8); jwe = new JsonWebEncryption(); // kty參數設定請參考RFC 7518: Section 6.1 // oct: Octet sequence (used to represent symmetric keys) JSONObject jwejson = new JSONObject(); jwejson.put("kty", "oct"); jwejson.put("k", encryptionKey); jsonWebKey = JsonWebKey.Factory.newJwk(jwejson.toString()); jwe.setCompactSerialization(decodedPayload); jwe.setKey(new AesKey(jsonWebKey.getKey().getEncoded())); if(jwe.getPlaintextString().isEmpty() || jwe.getPlaintextString() == null) { System.out.println("decoded fail"); return false; } JSONObject jwspayload = new JSONObject(jwe.getPlaintextString()); for(Object claim: jwspayload.keySet()) { System.out.println("claim key: " + claim.toString()); System.out.println("claim value: " + jwspayload.get(claim.toString())); request.getSession().setAttribute(claim.toString(), jwspayload.get(claim.toString())); } long current = System.currentTimeMillis() / 1000L; System.out.println("now time: " + System.currentTimeMillis()/1000L); if(jwspayload.getLong("exp") < current) { request.getSession().removeAttribute("jwt"); System.out.println("token expired!"); return false; } else if(jwspayload.has("successfulAuthenticationHandlers")) { // 驗證成功,加入UserList裡面、session塞入jwt request.getSession().setMaxInactiveInterval(60 * 10); request.getSession().setAttribute("jwt", jwt); User user = new User(); user.setUsername(jwspayload.getString("sub")); user.setSessionid(request.getSession().getId()); user.setServiceticket(jwspayload.getString("jti")); user.setSession(request.getSession()); UserList.userlist.add(user); return true; } } catch (UnsupportedEncodingException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (JSONException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (JoseException e) { // TODO Auto-generated catch block e.printStackTrace(); } catch (Exception e) { // TODO Auto-generated catch block e.printStackTrace(); } return false; } ``` -------------------------------------------------- ## UseerList.java 這一份其實參考價值不大,可自由實作或者透過資料庫拿資料都能達成,但為了閱讀完整性還是放上連結。 [UserList.java](/q1roFqbfQtG7eSp4Kg4G8Q) ## logout.java 由index.jsp連結到client logout的部分,是透過get的方式,同時也順便直接廢掉session,但是CAS Server打過來是透過<span class="red">POST的方式,帶了SAML格式的字串過來</span>,因此post的部分要另外處理。 ```java= protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // TODO Auto-generated method stub // 透過本地logout.jsp登出 if(UserList.contains(request.getSession().getId())) { HttpSession session = UserList.getSessionById(request.getSession().getId()); UserList.removeUserById(request.getSession().getId()); session.invalidate(); } response.sendRedirect("https://sso.server.com:8443/cas/logout?service=https://sso.client1.com:18443/cas-sample-java-webapp1/web1/servlet/login"); } /** * @see HttpServlet#doPost(HttpServletRequest request, HttpServletResponse response) */ protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // TODO Auto-generated method stub // 透過cas server發出logout request登出 String body = request.getReader().lines().collect(Collectors.joining()); System.out.println("body: " + URLDecoder.decode(body, "UTF-8")); String samlString = URLDecoder.decode(body, "UTF-8").split("logoutRequest=")[1]; HashMap<String, String> attributes = SamlUtils.getAttributes(samlString); HttpSession session = UserList.getSessionByST(attributes.get("SessionIndex")); if(session != null) { UserList.removeUserByST(attributes.get("SessionIndex")); session.invalidate(); } response.sendRedirect("https://sso.server.com:8443/cas/logout?service=https://sso.client1.com:18443/cas-sample-java-webapp1/web1/servlet/login"); } ``` -------------------------------------------------- ## SLO Request SAML字串處理: SamlUtils.java 這邊就是單純直接把saml字串拆出來之後用第三方套件處理,要注意的是他傳過來的tag有的是saml開頭,有些是samlp的部分,檔案連結在下方。 [SamlUtils.java](/wLkPFttuQn6F26YO3UydAQ) ## Reference [1] [java cas 單點登入web.xml配置](https://codertw.com/%E7%A8%8B%E5%BC%8F%E8%AA%9E%E8%A8%80/617059/) [2] [jwt-validation](https://apereo.github.io/cas/6.6.x/installation/Configure-ServiceTicket-JWT.html#jwt-validation---aes) [3] [JWT & JWE & JWS 大亂鬥!](https://blog.cssuen.tw/jwt-jwe-jws-%E5%A4%A7%E4%BA%82%E9%AC%A5-5ca68bebe426) [4] [Java 程式處理網址 URL 百分比編碼與解碼教學](https://blog.gtwang.org/programming/url-percent-encoding-and-decoding-using-java/)