CAS
這篇需要搭配SSO 技術實踐: CAS整合JWT(Server篇)一起看,這篇主要會介紹關於jwt如何在client這邊驗證,以及處理關於server發過來的SLO request怎麼處理。
另外,關於此篇不管是實作還是設定,都只是基於開發練習用,實際上關於認證及驗證的方式相信會有更好的方式,此篇僅為了練習而採用簡單的方式實作。
Server使用套件(請加入在build.gradle):
Client使用套件(請加入在pom.xml):
因為整體web.xml配置有點長,會拆局部一一說明。
可參考: java cas 單點登入web.xml配置[1]
由於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>
此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>
此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>
這個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>
主要負責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>
檢查是否直接攜帶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>
這邊有做接收及處理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>
這邊主要是當作一開始進來的入口,如果是直接走到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近來的入口導到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字串驗證是在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;
}
這一份其實參考價值不大,可自由實作或者透過資料庫拿資料都能達成,但為了閱讀完整性還是放上連結。
UserList.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");
}
這邊就是單純直接把saml字串拆出來之後用第三方套件處理,要注意的是他傳過來的tag有的是saml開頭,有些是samlp的部分,檔案連結在下方。
SamlUtils.java
[1] java cas 單點登入web.xml配置
[2] jwt-validation
[3] JWT & JWE & JWS 大亂鬥!
[4] Java 程式處理網址 URL 百分比編碼與解碼教學