一、前言
最近在进入公司实习后,也是接到了一个自己从来没有了解和接触的任务,也就是标题所说的通过配置cas客户端实现sso单点登录。
什么是单点登录?回想一下,在我们访问每一个web应用程序时都会跳转到登录页面进行登录,一直在填表单提交表单进行登录,十分繁琐臃肿,假设你是一个公司的员工,你的公司下有十个产品,如果你同时要使用这些产品难道把每个应用都登录一遍吗?显然不太现实,所以就出现了单点登录,当我们访问其中一个应用程序时,会跳转到一个统一的认证平台进行认证,如果通过认证,则认为是登录的,从而跳过自己手动登录,如果没有登录则跳转到该认证平台进行登录,但仅仅只需要这次一次登录,即一次登录,到处使用。
经过了两天的了解学习,打通了实现流程原理后,感觉也就那样,实现起来应该也是十分简单的,但事实缺给了我一巴掌,至今折磨了我长达几个礼拜的时间。为什么?由于公司是前后端分离的架构,前后端分离常见的实现方式之一就是前端通过AJAX调佣后端的RESTFUL API接口并使用JSON数据交互!!!!!!!!->这种方式也为单点登录挖了个大坑,把我自己给埋进去了。
二、实现过程原理
适配前后端分离下本人改造的思路:
- 浏览器访问子系统 A 前端页面,前端向 Client 获取用户信息
- Client 发现请求中没有会话ID,返回 401 及用于跳转页面的 Controller 的地址
- 前端发现 401,将浏览器重定向到 CAS Server 的登录页,后面带上 service 参数,service 即为 Client 回传的 Controller 地址,同时向其中传入一个 url 参数,用于返回前端页面
- CAS Server 登录,登录成功后根据 service 参数返回 Client 中的 Controller
- Client 接收 url 参数,同时将 JSESSIONID 拼在 url 最后,通过跳转回传给前端
- 前端接收 JSESSIONID 并存放在其自身域的 cookie 中,后续请求均携带 cookie
- 另一子系统 B 前端访问 Client2 时,Client2 发现无会话,同样返回 401
- 前端跳转 CAS Server 进行登录认证(携带 service 及 url 两个参数),CAS 发现已登录,直接跳转回到 service 中
- Client2 根据 url 跳转回前端 B,并在地址栏增加 JSESSIONID 参数
- 前端 B 接收 JSESSIONID 并存放在 B 域的 cookie 中,后续请求均携带 cookie
三、搭建配置CAS客户端
引入客户端依赖
<!--CAS客户端依赖-->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.6.1</version>
</dependency>
下面是yml配置文件
接下来是几个cas监听器的配置
@Configuration
public class CasConfig {
@Value("${cas.server-url-prefix}")
private String casServerUrlPrefix;
@Value("${cas.server-login-url}")
private String casServerLoginUrl;
@Value("${cas.logout-url}")
private String casLogoutUrl;
@Value("${cas.ignore-pattern}")
private String casIgnorePattern;
@Value("${cas.server-name}")
private String casServerName;
@Autowired
private SmsAuthenticationRedirectStrategy smsAuthenticationRedirectStrategy;
@Autowired
private SmsCas20ProxyReceivingTicketValidationFilter smsCas20ProxyReceivingTicketValidationFilter;
/**
* 处理CAS的单点登出功能
* @return
*/
@Bean
@Order(1)
public FilterRegistrationBean<SingleSignOutFilter> casSingleSignOutFilterRegistration() {
FilterRegistrationBean<SingleSignOutFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new SingleSignOutFilter());
registrationBean.setEnabled(true);
registrationBean.addUrlPatterns(casLogoutUrl);
registrationBean.setName("CasSingleSignOutFilter");
registrationBean.setOrder(1);
registrationBean.addInitParameter("casServerUrlPrefix", casServerUrlPrefix);
return registrationBean;
}
@Bean
@Order(2)
public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutFilterBean(){
ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> listenerRegistrationBean = new ServletListenerRegistrationBean<>();
listenerRegistrationBean.setEnabled(true);
listenerRegistrationBean.setListener(new SingleSignOutHttpSessionListener());
listenerRegistrationBean.setOrder(2);
return listenerRegistrationBean;
}
/**
* 用于进行CAS的认证过程拦截所有请求,将未携带票据与会话中无票据的请求都重定向到CAS登录地址
* @return
*/
@Bean
@Order(4)
public FilterRegistrationBean<AuthenticationFilter> casAuthenticationFilterRegistration() {
FilterRegistrationBean<AuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new AuthenticationFilter());
registrationBean.addUrlPatterns("/*");
registrationBean.setName("CasAuthenticationFilter");
registrationBean.setOrder(4);
registrationBean.addInitParameter("casServerLoginUrl", casServerLoginUrl);
registrationBean.addInitParameter("serverName", casServerName);
registrationBean.addInitParameter("ignorePattern", casIgnorePattern);
//设置自定义重定向策略
registrationBean.addInitParameter("authenticationRedirectStrategyClass","com.metastar.vip.scada.service.congfig.SmsAuthenticationRedirectStrategy");
return registrationBean;
}
/**
* 用于验证CAS Server发送的票据 拦截所有请求,使用获取的票据向CAS服务端发起校验票据请求
* @return
*/
@Bean
@Order(3)
public FilterRegistrationBean<SmsCas20ProxyReceivingTicketValidationFilter> casValidationFilterRegistration() {
FilterRegistrationBean<SmsCas20ProxyReceivingTicketValidationFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(smsCas20ProxyReceivingTicketValidationFilter);
registrationBean.addUrlPatterns("/*");
registrationBean.setName("SmsCas20ProxyReceivingTicketValidationFilter");
registrationBean.setOrder(3);
registrationBean.addInitParameter("casServerUrlPrefix", casServerUrlPrefix);
registrationBean.addInitParameter("serverName", casServerName);
return registrationBean;
}
}
在CAS单点登录中,主要涉及到以下几个配置文件:
-
application.yml
:这个文件主要用于配置CAS单点登录的相关参数,包括CAS服务器的URL前缀、登录URL、注销URL、服务URL等。这些参数是在CAS客户端和CAS服务器之间进行通信时需要用到的。 -
CasConfig
:这个类是一个Spring Configuration类,用于配置CAS客户端的相关组件。在这个类中,我们创建了几个Bean来配置CAS客户端的核心组件,包括CasAuthenticationFilter
、SingleSignOutFilter
等。这些组件的作用和意义如下:
-
CasAuthenticationFilter
:这个过滤器是CAS单点登录的核心过滤器,用于拦截用户的登录请求,将其重定向到CAS服务器进行验证。如果验证通过,CAS服务器会返回一个票据,该过滤器会将票据存储在用户的会话中,并重定向到原始请求的URL,完成登录过程。 -
SingleSignOutFilter
:这个过滤器用于监听用户的会话,当用户在其他地方退出登录或会话超时时,该过滤器会销毁用户的会话。 -
Cas20ProxyReceivingTicketValidationFilter:这个提供者用于执行票据的验证操作,当用户登录时,该提供者将会从CAS服务器获取票据并进行验证。
更详细的cas客户端配置说明可自行参考其他资料。。。
四、漫长的踩坑历程
4.1 坑1——响应重定向无效
通过cas的认证流程可以知道,当验证ticket和session都为空后,cas会响应浏览器302让前端重定向到认证平台进行验证,在我在浏览器输入url进行测试的时候没毛病,能正常跳转到认证平台页面,可当我把接口给到前端,前端进行调用的时候,问题出现了,跳转失败,通过查阅资料了解到,前端通过ajax发送后在处理响应结果时由于ajax不能处理重定向,所以出现没跳转的情况。
完了,那怎么办!
幸好,通过Debug AuthenticationFilter源码可以发现,在识别到未登录后,会调用this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);这个函数进行登录,里面默认实现就是进行重定向到认证页面。
既然重定向这个方案不可行,那就不要重定向,又想要跳转过去,又不能利用重定向,那该用什么方式呢?
经过查阅资料,通过响应给前端认证平台url和后端服务url,前端通过字符串拼接然后进行跳转,啊?好像真的可以!
怎么实现,通过自定义一个重定向策略即可,然后应用该策略取代原来的策略即可!
@Slf4j
public class SmsAuthenticationRedirectStrategy implements AuthenticationRedirectStrategy{
//重定向Controller url 并携带前端页面参数
// @Value("${cas.directControllerUrl}")
// private String directControllerUrl;
/**
* 自定义用户认证失败重定向策略
* @param httpServletRequest
* @param httpServletResponse
* @param potentialRedirectUrl
* @throws IOException
*/
@Override
public void redirect(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, String potentialRedirectUrl) throws IOException {
//设置401
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
//重定向Controller url 并携带前端页面参数
String directControllerUrl = "http://127.0.0.1:8088/scada/cas/redirect";
//响应数据
ImmutableMap<String, Object> data = new ImmutableMap.Builder<String, Object>()
.put("errorType", "NOT_LOGIN_CAS")
.put("RedirectControllerUrl",directControllerUrl)
.build();
//输出
PrintWriter out = httpServletResponse.getWriter();
out.println(JSON.toJSONString(data));
out.flush();
out.close();
}
}
@Bean
@Order(4)
public FilterRegistrationBean<AuthenticationFilter> casAuthenticationFilterRegistration() {
FilterRegistrationBean<AuthenticationFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new AuthenticationFilter());
registrationBean.addUrlPatterns("/*");
registrationBean.setName("CasAuthenticationFilter");
registrationBean.setOrder(4);
registrationBean.addInitParameter("casServerLoginUrl", casServerLoginUrl);
registrationBean.addInitParameter("serverName", casServerName);
registrationBean.addInitParameter("ignorePattern", casIgnorePattern);
//设置自定义重定向策略
registrationBean.addInitParameter("authenticationRedirectStrategyClass","com.metastar.vip.scada.service.congfig.SmsAuthenticationRedirectStrategy");
return registrationBean;
}
4.2 坑2——session-cookie共享问题
当登录认证完成后cas会创建全局会话和局部会话然后自动跳转访问后端的接口,这样局部会话生成后cookie存在的域在我们后端接口域下面,并不是在我们前端页面上,下次我们通过在前端页面与后端交互由于缺失cookie就不能成功交互了。
怎么解决?
既然要在局部会话创建后给前端页面生成cookie,那就写一个控制器放入service参数中,通过获取在局部会话生成后产生的sessionID,通过把该sessionId与前端首页url进行拼接然后进行重定向,这样就实现了再登录认证后,会跳转回前端页面并且携带sessionID到url路径上。
那cookie呢?还没存储啊!是的,坑又来了,由于浏览器同源策略影响,如果通过后端响应存储cookie,浏览器不会对该不同源的cookie进行存储,如果把cookie存储权交由前端,即使存储成功也会出现当下一次前端请求后端的时候,请求头里面没有把cookie携带上,同样造成session获取的问题。
具体cookie在跨域下请求和存储失效的问题请参考我另外一篇文章https://blog.csdn.net/weixin_55895202/article/details/132453431?spm=1001.2014.3001.5501
4.3 坑3——跨域问题
由于前后端分离下,两个不同域名进行交互的时候会受浏览器同源保护策略的影响,无法访问后端服务。这个时候要做跨域配置,它可以又前端配置也可以由后端进行配置,详细的跨域配置网上很多资料,我就不赘述,只提供我的跨域配置。
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SimpleCORSFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) servletResponse;
HttpServletRequest request = (HttpServletRequest) servletRequest;
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Headers", "*");
if(request.getMethod().equals(HttpMethod.OPTIONS.name())){
response.setStatus(HttpStatus.NO_CONTENT.value());
}else{
filterChain.doFilter(servletRequest, servletResponse);
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
我是通过后端进行配置的,由于存在过滤链,每个过滤器都有它的优先级,所以跨域配置在该这些过滤器执行之前,所以需要把该跨域配置的过滤器配置为最高优先级才可以避免跨域问题。
四、总结
通过这次踩坑,基本可以认为基于CAS的单点登录认证不适用于前后端分离架构,因为基于会话的单点登录对前后端架构天生不友好(cookie存储和请求问题)。