一、 前言
CAS是一个旨在为应用系统提供单点登录方案的企业级的开源项目,它为第三方应用提供了基于REST的操作接口。为方便公司的Web应用(及类似系统)中实现单点登录的相应功能,实现了一个Cas_Service工程,以供相关项目调用。
为后续表达准确,对相关术语作简单说明:
- Web应用系统:准备集成CAS单点登录功能的各类Web应用;
- CAS Server:本文中特指cas-server-webapp的war文件,需要独立部署,有时也称为认证系统、认证中心;
- CAS Client:本文中特指cas-client-core-3.4.1.jar,需与应用系统一起部署。
此外,集成过程中的相关条件和约束如下:
- 单点登录功能:各应用系统可统一登入/登出、JDBC认证、密码MD5存取;
- CAS版本:除非特别声明,CAS各组件的版本均为4.2.7;
- 访问方式:访问应用系统和CAS Server均使用https协议、8443端口;
- Web服务器:应用系统和CAS Server均由Tomcat提供Web服务;
- CAS Domain:即CAS Server的域名,本文假定为cas.hisign.con.cn/cas;
- 应用系统Domain:即各应用系统的域名,本文假定为app.hisign.com.cn/,后面是各自的应用系统名称。通常情况下,cas.hisign.con.cn和app.hisign.con.cn相同。
二、 CAS Server部署
严格意义上,此章节内容不属于应用系统集成的工作范围,故只做简要描述,供加深对整体工作了解之用。如需了解其中细节,请参阅其它资料。
1) 制作和配置SSL证书
用keytool工具制作所需证书。注意需要用到主机名的地方,不要用IP而是域名;如果CAS和应用系统部署在不同服务器节点上,各节点都需制作证书,并在每个应用系统的服务器节点上配置与CAS Server节点的信任关系。
然后修改Tomcat的配置文件,以正确使用SSL证书。
2) 部署CAS Server
将cas-server-webapp-4.2.7.war拷贝到Tomcat的webapps目录,改名为cas.war;启动Tomcat后,自动解压为cas目录。
将相应的数据库JDBC驱动(如Oracle的ojdbc6.jar或ojdbc7.jar)、cas支持JDBC的jar文件(cas-server-support-jdbc-4.2.7.jar)拷贝至cas/WEB-INF/lib目录。
3) 配置为JDBC认证、密码MD5加密
修改CAS Server的配置文件deployerConfigContext.xml和cas.properties:
- 修改deployerConfigContext.xml,增加对datasource的描述;
- 修改deployerConfigContext.xml的primaryAuthenticationHandler项;
- 设置cas.propeities的cas.jdbc.authn.query.sql项;
- 设置cas.propeities的cas.authn.password.encoding.alg项;
- 修改deployerConfigContext.xml,增加对passwordEncoder的描述;
- 设置cas.propeities的cas.logout.followServiceRedirects项。
三、 Cas_Service接口
为在Java类中集成CAS功能,已将一些常用CAS功能封装到cas-service包,以供Web应用系统调用。
首先需在maven工程的pom.xml文件里增加对cas_service包的依赖:
<dependency> <groupId>com.hisign.pu.abis</groupId> <artifactId>cas_service</artifactId> <version>0.0.1-SNAPSHOT</version> </dependency>
可供调用的接口如下:
1) 获取TGT
String getTicketGrantingTicket(String Server, String username, String password);
- 参数server为CAS Server的访问URL;
- 参数username为登录用户名;
- 参数password为验证用的密码;
- 返回:验证通过则返回TGT的值,否则抛出异常;
- 示例:
String tgt = casService.getTicketGrantingTicket("https://cas.hisign.com.cn:8443/cas", "casuser", "Mellon");
2) 根据TGT获取ST
String getServiceTicket(String Server, String ticketGrantingTicket, String service);
- 参数server为CAS Server的访问URL;
- 参数ticketGrantingTicket为已获得的TGT;
- 参数service为欲访问的service的URL;
- 返回:验证通过则返回ST的值,否则抛出异常;
- 示例:
String st = casService.getTicketGrantingTicket("https://cas.hisign.com.cn:8443/cas", "TGT-2-6eTFeygWirXfgbQWdOitzwAFcuIJyYfmIRNeMELaqKiLSw9zOY-cas01.example.org", "https://app.hisign.com.cn:8443/app1");
3) 判别ST是否有效
String verifySeviceTicket(String server, String serviceTicket, String service);
- 参数server为CAS Server的访问URL;
- 参数serviceTicket为已获得的ST;
- 参数service为欲访问的service的URL;
- 返回:ST有效返回登录用户名,无效返回null,若出错抛出异常;
- 示例:
boolean String = casService.verifyServiceTicket("https://cas.hisign.com.cn:8443/cas", "ST-2-5kEeqQuPsnB1b4UyUHFW-cas01.example.org", "https://app.hisign.com.cn:8443/app1");
4) 删除TGT(相当于在CAS Server端注销)
boolean deleteTicketGrantingTicket(String Server, String ticketGrantingTicket);
- 参数server为CAS Server的访问URL;
- 参数ticketGrantingTicket为已获得的TGT;
- 返回:成功返回true,否则抛出异常;
- 示例:
boolean bool = casService.deleteTicketGrantingTicket("https://cas.hisign.com.cn:8443/cas", "TGT-2-6eTFeygWirXfgbQWdOitzwAFcuIJyYfmIRNeMELaqKiLSw9zOY-cas01.example.org");
注意事项:参数server必须真实有效,可从配置文件获取;而参数service可以是虚构的、符合规范的格式。
四、 核心代码
这里修改或去掉了一些内部的比较敏感的内容,望理解。
package cas_service; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.net.URLEncoder; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.apache.commons.httpclient.HttpClient; import org.apache.commons.httpclient.HttpStatus; import org.apache.commons.httpclient.NameValuePair; import org.apache.commons.httpclient.methods.DeleteMethod; import org.apache.commons.httpclient.methods.GetMethod; import org.apache.commons.httpclient.methods.PostMethod; import org.slf4j.Logger; import org.slf4j.LoggerFactory; ... public class CasService { static private final Logger LOG = LoggerFactory.getLogger(CasService.class); private static String serverAddr; private static String serverPort; private static String serverConnString; static { String fileName = "cas-service.ini"; try { ProjProperties props = newProjProperties(); props.load(new FileInputStream(fileName)); serverAddr = props.getProperty("SSO_SVR_ADDRESS", "localhost"); serverPort = props.getProperty("SSO_SVR_PORT", "8443"); serverConnString = "https://" + serverAddr + ":" + serverPort + "/cas"; } catch (Exception e) { LOG.warn("load application server configuration ({}) failed. {}", fileName, e.getMessage()); } } //获取TGT public String getTicketGrantingTicket(String username, String password) { if (serverConnString==null || serverConnString.equals("")) throw new Exception("Invalid parameter: CAS Server"); HttpClient client = new HttpClient(); PostMethod method = new PostMethod(serverConnString + "/v1/tickets"); method.setRequestBody(new NameValuePair[] { new NameValuePair("username", username), new NameValuePair("password", password) }); try { client.executeMethod(method); String response = method.getResponseBodyAsString(); int status = method.getStatusCode(); switch (status) { case HttpStatus.SC_CREATED: // Created { Matcher matcher = Pattern.compile(".*action=".*/(.*?)".*").matcher(response); if (matcher.matches()) return matcher.group(1); break; } default: throw new Exception("Invalid Response code " + status + " from CAS Server!"); } } catch (IOException e) { LOG.error("some exception happened during apply for a TGT " + e.getMessage()); } finally { method.releaseConnection(); } return null; } //根据TGT获得ST public String getServiceTicket(String ticketGrantingTicket, String moduleName) { if (serverConnString==null || serverConnString.equals("")) throw new Exception("Invalid parameter: CAS Server"); if (moduleName==null || moduleName.equals("")) throw new Exception("Invalid parameter: no module name within request."); if (ticketGrantingTicket==null || ticketGrantingTicket.equals("")) throw new Exception("Invalid TGT."); HttpClient client = new HttpClient(); PostMethod method = new PostMethod(serverConnString + "/v1/tickets/" + ticketGrantingTicket); String service1 = buildModuleServiceName(moduleName); method.setRequestBody(new NameValuePair[] { new NameValuePair("service", service1) }); try { client.executeMethod(method); String response = method.getResponseBodyAsString(); int status = method.getStatusCode(); switch (status) { case HttpStatus.SC_OK: // Accepted return response; default: throw new Exception("Invalid Response code " + status + " from CAS Server!"); } } catch (IOException e) { LOG.error("some exception occured during apply for a service ticket. " + e.getMessage()); } finally { method.releaseConnection(); } return null; } //检验ST是否有效 public String verifyServiceTicket(String serviceTicket, String moduleName) { if (serverConnString==null || serverConnString.equals("")) throw new Exception("Invalid parameter: CAS Server"); if (moduleName==null || moduleName.equals("")) throw new Exception("Invalid parameter: module name"); if (ABISHelper.isEmpty(serviceTicket)) return null; HttpClient client = new HttpClient(); GetMethod method = null; String service1 = buildModuleServiceName(moduleName); try { method = new GetMethod(serverConnString + "/p3/serviceValidate?ticket=" + URLEncoder.encode(serviceTicket, "utf-8") + "&service=" + URLEncoder.encode(service1, "utf-8")); client.executeMethod(method); String response = method.getResponseBodyAsString(); // 对有转发的访问请求,GetMethod才返回SC_OK,PostMethod返回的是302 int status = method.getStatusLine().getStatusCode(); switch (status) { case HttpStatus.SC_OK: // Accepted int begin = response.indexOf("<cas:user>"); if (begin < 0) return null; int end = response.indexOf("</cas:user>"); return response.substring(begin + 10, end); default: throw new Exception("Invalid Response code " + status + " from CAS Server!"); } } catch (IOException e) { LOG.error("some exception occured during verify a service ticket. " + e.getMessage()); } finally { method.releaseConnection(); } return null; } //删除TGT public boolean deleteTicketGrantingTicket(String ticketGrantingTicket) { if (serverConnString==null || serverConnString.equals("")) throw new Exception("Invalid parameter: CAS Server"); if (ticketGrantingTicket==null || ticketGrantingTicket.equals("")) return false; HttpClient client = new HttpClient(); DeleteMethod method = new DeleteMethod(serverConnString + "/v1/tickets/" + ticketGrantingTicket); try { client.executeMethod(method); int status = method.getStatusCode(); switch (status) { case HttpStatus.SC_OK: return true; default: throw new Exception("Invalid Response code " + status + " from CAS Server!"); } } catch (IOException e) { LOG.error("some exception occured during verifing a service ticket" + e.getMessage()); } finally { method.releaseConnection(); } return false; } private String buildModuleServiceName(String moduleName) { return "https://" + serverAddr + ":" + serverPort + "/" + moduleName; } }
五、 TGT与ST的时效设置
TGT和ST有时效和限制,默认是TGT有2小时时效、保留8小时,而ST是 10秒时效且只能使用一次。
如需改变ST的时效和次数限制,可通过修改CAS Server的配置文件cas.propertities中的st.numberOfUses和st.timeToKillInSeconds项加以更改。如:
# Service Ticket Timeout
st.timeToKillInSeconds=10
st.numberOfUses=1
对于TGT略复杂一些,首先需通过修改配置文件deployerConfigContext.xml中的<alias name = … alias="grantingTicketExpirationPolicy"项来设置TGT的失效策略,然后再根据策略修改cas.propertities的相关项。比如:
# 默认失效策略 <alias name="ticketGrantingTicketExpirationPolicy" alias="grantingTicketExpirationPolicy" /> tgt.maxTimeToLiveInSeconds=28800 tgt.timeToKillInSeconds=7200 # 过期失效策略 <alias name="timeoutExpirationPolicy" alias="grantingTicketExpirationPolicy" /> tgt.timeout.maxTimeToLiveInSeconds=7200 # 硬时间失效策略 <alias name="hardTimeoutExpirationPolicy" alias="grantingTicketExpirationPolicy" /> tgt.timeout.hard.maxTimeToLiveInSeconds=14400 # 永不失效策略(有一定安全风险) <alias name="neverExpirationPolicy" alias="grantingTicketExpirationPolicy" /> # cas.propertities中无需设置
六、 一个典型的调用流程
以下是某个应用系统使用cas_service包接口的典型流程:
- 某用户登录应用A,因为是首次登录,需提供用户名、密码;
- 应用A根据用户名、密码,调用getTicketGrantingTicket接口获取TGT;
- TGT多次使用,需保存在session或其它存储对象中;
- 应用A使用TGT,调用getServiceTicket接口获取am服务的ST;
- 应用A可使用刚获取的ST,作为参数访问am服务;
- ST因有效期短暂且使用次数有限制,一般是一次性使用,不必保存;
- 用户欲访问应用B的bn服务,先从session或其它存储对象中查找到TGT;
- 应用A(或应用B)TGT,调用getServiceTicket接口获取bn服务的ST;
- 应用B接收ST,调用verifySeviceTicket接口,返回不为null则该ST有效;
- 验证通过后,应用B使用该ST访问bn服务;
- 应用B可调用接口getCasUserName和getCasAttributes,获取登录用户及相关属性;
- 欲根据ST查找当前登录用户,调用getUsernameSeviceTicket接口,返回值即是;
- 用户从某应用注销时,需调用deleteTicketGrantingTicket接口从Cas Server删除TGT。