CAS概念
CAS是Central Authentiction Service 的缩写,中央认证服务。CAS是Yale大学发起的开源项目,旨在为Web应用系统提供一种可靠的单点登录方法,CAS在2004年12月正式称为JA-SIG的一个项目。官网地址:https://www.apereo.org/projects/cas。特点如下:
- 开源的企业级单点登录解决方案;
- 支持多种协议(CAS,SAML, WS-Federation,OAuth2,OpenID,OpenID Connect,REST);
- CAS Client 多语言支持包括Java, .Net, PHP, Perl, Apache等;
- 支持三方授权登录(例如ADFS,Facebook,Twitter,SAML2 IdP等)的委派身份验证;
- 支持多因素身份验证(Duo Security,FIDO U2F,YubiKey,Google Authenticator,Microsoft Azure,Authy等)。
CAS架构
如图所示,CAS分为:CAS Client和CAS Server。
- CAS Client:需要接入单点登录的应用系统;
- CAS Server:统一认证中心;
CAS协议
CAS协议,是CAS项目默认的协议,是一种简单而强大的基于票证的协议。涉及到几个核心概念如下:
- TGT (Ticket Granting Ticket):俗称大令牌,存储到浏览器Cookie(Name=TGC, Value=“TGT的值”)上,可以通过TGT签发ST,具备时效性,默认2小时;
- ST (Service Ticket):俗称小令牌,CAS Client通过ST校验是否登录成功,并获取用户信息。ST只有一次有效,有效期默认10秒。
CAS协议认证流程图
上图分为三种不同场景的访问方式:
-
第一次访问某个接入CAS认证中心的应用系统(app1):
- 用户请求应用系统app1,app1判断用户未登录,跳转到CAS认证中心登录页面;
- 用户输入账号密码提交到CAS认证中心,如果认证通过则走第3步;
- CAS认证中心在它的域名下面的Cookie设置TGC,将签发的ST作为请求参数重定向到app1;
- app1通过ST校验登录是否合法,并获取用户信息,存储到本地session;
- app1跳转到开始访问的页面。
第一次访问某个接入CAS认证中心的应用系统(app1):由于app1已经登录过了,之后访问app1都不需要再跟CAS认证中心打交道了。
-
第一次访问其他接入CAS认证中心的应用系统(app2):
- 用户请求应用系统app2,app2判断用户未登录,跳转到CAS认证中心登录页面;
- CAS认证中心验证之前生成的TGT(从Cookie的TGC获取),如果该TGT有效,则签发ST重定向到app2;
- app2通过ST校验登录是否合法,并获取用户信息,存储到本地session;
- app2跳转到开始访问的页面。
CAS Server认证逻辑
上图是作者从源码梳理出来的逻辑,如有错误之处,欢迎指正~
实战手写CAS Client
根据前面学习到的CAS登录原理,我们来实战手写CAS Client端,巩固下刚刚学到的知识,详情见代码:
- 新建一个Spring Boot项目(自行实现);
- 引入包:
org.jasig.cas.client
cas-client-core
3.6.1
org.apache.httpcomponents
httpclient
4.3.3
- 配置文件
server.servlet.context-path=/client
server.port=8080
- 代码实现
定义一个过滤器,用于登录、登出逻辑认证
@Configuration
public class AppConfig {
@Bean
public FilterRegistrationBean registerAuthFilter() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new AccessFilter());
registration.addUrlPatterns("/*");
registration.setName("accessFilter");
registration.setOrder(Integer.MIN_VALUE);
return registration;
}
}
public class AccessFilter implements Filter {
private static final Logger LOGGER = LoggerFactory.getLogger(AccessFilter.class);
public static final String USER_INFO = "USER_INFO";
public static final String CAS_URL = "https://127.0.0.1:8443/cas";
public static final String SERVICE_URL = "http://127.0.0.1:8080/client/";
/** 记录登录的session,退出时,可以获取session并销毁 key-ST, value-session */
private static final Map ST_SESSION_MAP = new ConcurrentHashMap<>();
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
String ticket = request.getParameter("ticket");
// 校验ST
if (!StringUtils.isEmpty(ticket) && "GET".equals(request.getMethod())) {
this.validateTicket(ticket, request.getSession());
}
// 监听登出
String logoutRequest = request.getParameter("logoutRequest");
if (!StringUtils.isEmpty(logoutRequest) && "POST".equals(request.getMethod())) {
LOGGER.info(logoutRequest);
this.destroySession(logoutRequest);
return;
}
// 未登录?则跳转CAS登录页面
if (request.getSession().getAttribute(USER_INFO) == null) {
response.sendRedirect(CAS_URL + "/login?service=" + SERVICE_URL);
return;
}
filterChain.doFilter(request, response);
}
/**
* 校验ST
* @param ticket
* @param session
*/
private void validateTicket(String ticket, HttpSession session) {
String result = null;
try {
// 向CAS发起ST校验,并获取用户信息
result = HttpClientUtils.doGet(CAS_URL + "/serviceValidate?service=" + SERVICE_URL
+ "&ticket=" + ticket);
LOGGER.info(result);
} catch (Exception e) {
throw new RuntimeException("serviceValidate请求失败:" + e.getMessage(), e);
}
// 校验成功可以解析到user信息(XML格式)
String user = XmlUtils.getTextForElement(result, "user");
// 记录登录信息
if (!StringUtils.isEmpty(user)) {
session.setAttribute(USER_INFO, user);
ST_SESSION_MAP.put(ticket, session);
} else {
throw new RuntimeException("校验ST失败");
}
}
/**
* 销毁session
* @param logoutRequest
*/
private void destroySession(String logoutRequest) {
final String ticket = XmlUtils.getTextForElement(logoutRequest, "SessionIndex");
if (CommonUtils.isBlank(ticket)) {
return;
}
final HttpSession session = ST_SESSION_MAP.get(ticket);
if (session != null) {
session.invalidate();
}
}
}
相关工具类
public abstract class HttpClientUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(HttpClientUtils.class);
public static final String ENCODING = "UTF-8";
private static final int CONNECT_TIMEOUT = 3000;
private static final int SOCKET_TIMEOUT = 10000;
private static final int CONNECTION_REQUEST_TIMEOUT = 3000;
private static CloseableHttpClient httpClient;
static {
httpClient = createHttpClient();
}
private static CloseableHttpClient createHttpClient() {
if (httpClient != null) {
return httpClient;
}
try {
SSLContext sslContext = new SSLContextBuilder().loadTrustMaterial(null, new TrustStrategy() {
public boolean isTrusted(X509Certificate[] chain,
String authType) throws CertificateException {
return true;
}
}).build();
SSLConnectionSocketFactory sslsf = new SSLConnectionSocketFactory(sslContext,SSLConnectionSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
HttpClientBuilder httpClientBuilder = HttpClients.custom();
httpClientBuilder.setSSLSocketFactory(sslsf);
httpClientBuilder.setMaxConnPerRoute(50);
httpClientBuilder.setMaxConnTotal(150);
RequestConfig.Builder configBuilder = RequestConfig.custom();
configBuilder.setConnectTimeout(CONNECT_TIMEOUT);
configBuilder.setSocketTimeout(SOCKET_TIMEOUT);
configBuilder.setConnectionRequestTimeout(CONNECTION_REQUEST_TIMEOUT);
configBuilder.setStaleConnectionCheckEnabled(true);
httpClientBuilder.setDefaultRequestConfig(configBuilder.build());
httpClient = httpClientBuilder.build();
} catch (Exception ex) {
LOGGER.error("create https client support fail:"+ ex.getMessage(), ex);
}
return httpClient;
}
public static String doGet(String url) throws Exception {
URIBuilder uriBuilder = new URIBuilder(url);
HttpGet httpGet = new HttpGet(uriBuilder.build());
return getHttpClientResult(httpGet);
}
public static String getHttpClientResult(HttpRequestBase httpMethod) throws IOException{
CloseableHttpResponse httpResponse = null;
try {
httpResponse = httpClient.execute(httpMethod);
String content = "";
if (httpResponse != null && httpResponse.getStatusLine() != null && httpResponse.getEntity() != null) {
content = EntityUtils.toString(httpResponse.getEntity(), ENCODING);
}
return content;
} finally {
if (httpResponse != null) {
httpResponse.close();
}
}
}
}
控制层代码
@Controller
public class BaseController {
@RequestMapping("/")
public String home() {
return "redirect:/index";
}
@RequestMapping("/index")
@ResponseBody
public String index(HttpServletRequest request) {
return "登录用户:" + request.getSession().getAttribute(AccessFilter.USER_INFO);
}
@RequestMapping("/logout")
public void logout(HttpServletResponse response) throws IOException {
response.sendRedirect(AccessFilter.CAS_URL + "/logout?service=" + AccessFilter.SERVICE_URL);
}
}