Try   HackMD

SSO 技術實踐: CAS整合JWT(Client篇)

tags: CAS

前言:

這篇需要搭配SSO 技術實踐: CAS整合JWT(Server篇)一起看,這篇主要會介紹關於jwt如何在client這邊驗證,以及處理關於server發過來的SLO request怎麼處理。

另外,關於此篇不管是實作還是設定,都只是基於開發練習用,實際上關於認證及驗證的方式相信會有更好的方式,此篇僅為了練習而採用簡單的方式實作。


使用版本及使用套件:

Server使用套件(請加入在build.gradle):

Client使用套件(請加入在pom.xml):


CAS Client web.xml設定

因為整體web.xml配置有點長,會拆局部一一說明。
可參考: java cas 單點登入web.xml配置[1]

CAS Single Sign Out Filter:

由於filter的順序是會影響實際先流入哪個filter,因此順序非常重要,SSO Filter之所以需要擺第一個,是因為這是主要在控管sso狀態的filter。

下方的listener則是負責監聽slo request的部分,這個listener如果沒有的話,client會收不到訊息,而server則是不管發出的slo request是否有成功,都不會報異常!! 所以想知道問題出在哪,就必須把中斷點下在filter內,當初也是因為這樣才發現slo request溝通過程ssl handshake失敗

<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登入的路徑。

<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的部分,但請注意!! 原本如果只是單純的CAS流程,這邊的確是需要設定的,但因為今天我們採取的是搭配jwt的流程,變成驗證的動作是在client端執行,所以這整段都需要註解掉,不需要設定,也就是說下面這段看看就好~

<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。

<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即可,這邊設定為不管任何入口都會攔住進行檢查是否登入。

<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。

<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字串處理的部分有另一部分實作。

<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就導去驗證~

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裡面。

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[2]

關於jwt、jws、jwe三者之間的關係,這篇文章非常有幫助: JWT & JWE & JWS 大亂鬥![3]

  • 因為在CAS Server jwt有配置encryption key的關係,因此正確來說這邊不單純只是用到jws,而是應該稱jwe才對。

  • 由於signing key只是用來驗證簽名的部分,如果想為內容增加安全性,可以使用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 百分比編碼與解碼教學[4]

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

logout.java

由index.jsp連結到client logout的部分,是透過get的方式,同時也順便直接廢掉session,但是CAS Server打過來是透過POST的方式,帶了SAML格式的字串過來,因此post的部分要另外處理。

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

Reference

[1] java cas 單點登入web.xml配置
[2] jwt-validation
[3] JWT & JWE & JWS 大亂鬥!
[4] Java 程式處理網址 URL 百分比編碼與解碼教學