最近在工作上遇到CAS前后端分离接入,后端是使用的GO,前端则是Ant Design,通过Restful Api进行数据交互,最后是同域下完成接入,始终感觉不理想,打算仔细研究一下前后端分离接入CAS方案,并进行总结如下。果真问题是学习的好老师,有疑问才能去解决。
前面一些列的文章介绍CAS,具体原理我就再在这里复述了,如果读者还不太熟悉原理,可以去翻翻前面的文章——CAS单点登录(一)——初识SSO。
一、关于Session、Cookie及JSESSIONID的作用
我们知道CAS是基于Session的认证方式,即CAS是把认证信息放在了Session的attribute中(可通过request.getSession().getAttribute(“const_cas_assertion”)),这个我们在前面也讲解过。
我们知道HTTP协议是一种无状态协议,每次服务端接收到客户端的请求时都是一个全新的请求,服务器并不知道客户端的历史请求记录;
为了弥补Http的无状态特性,session应运而生。服务器可以利用session存储客户端在同一个会话期间的一些操作记录,而服务端的这个session对应到浏览器端则是名为JSESSIONID的cookie,JSESSIONID的值就是session的id。
a、服务器如何判断客户端发送过来的请求是属于同一个seesion?
用session的id来进行区分。如果id相同,那就认为是同一个会话。在Tomcat中,session的id的默认名字是JSESSIONID。对应到前端就是名为JSESSIONID的cookie。
b、session的id是在什么时候创建,又是怎样在前后端传输的?
Tomcat在第一次接收到一个请求时会创建一个session对象,同时生成一个session id,并通过响应头的Set-Cookie:"JSESSIONID=XXXXXXX"
命令,向客户端发送要求设置Cookie的响应。
前端在后续的每次请求时,都会带上所有cookie信息,自然也就包含了JSESSIONID这个cookie。然后Tomcat据此来查找到对应的session,如果指定session不存在(比如我们随手编一个JSESSIONID,那对应的session肯定不存在),那么就会创建一个新的session,其id的值就是请求中的JSESSIONID的值。
这里有一个坑,导致后面浏览器设置cookie不成功,始终无法认证成功,后面再提示。
二、cas-client默认登录验证分析
这里以java客户端3.5.1为例,进行大致的分析。我们在配置文件中,进行了CAS登录的拦截配置,在源码CasCustomConfig中。如下:
package net.anumbrella.sso.config;
import org.jasig.cas.client.authentication.AuthenticationFilter;
import org.jasig.cas.client.session.SingleSignOutFilter;
import org.jasig.cas.client.session.SingleSignOutHttpSessionListener;
import org.jasig.cas.client.util.AssertionThreadLocalFilter;
import org.jasig.cas.client.util.HttpServletRequestWrapperFilter;
import org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
/**
* @author Anumbrella
*/
@Configuration
@Component
public class CasCustomConfig {
@Autowired
SpringCasAutoconfig autoconfig;
private static boolean casEnabled = true;
public CasCustomConfig() {
}
@Bean
public SpringCasAutoconfig getSpringCasAutoconfig() {
return new SpringCasAutoconfig();
}
@Bean
public ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> singleSignOutHttpSessionListener() {
ServletListenerRegistrationBean<SingleSignOutHttpSessionListener> listener = new ServletListenerRegistrationBean<SingleSignOutHttpSessionListener>();
listener.setEnabled(casEnabled);
listener.setListener(new SingleSignOutHttpSessionListener());
listener.setOrder(1);
return listener;
}
/**
* 该过滤器用于实现单点登出功能,单点退出配置,一定要放在其他filter之前
*
* @return
*/
@Bean
public FilterRegistrationBean singleSignOutFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new SingleSignOutFilter());
filterRegistration.setEnabled(casEnabled);
if (autoconfig.getSignOutFilters().size() > 0) {
filterRegistration.setUrlPatterns(autoconfig.getSignOutFilters());
} else {
filterRegistration.addUrlPatterns("/*");
}
filterRegistration.addInitParameter("casServerUrlPrefix", autoconfig.getCasServerUrlPrefix());
filterRegistration.setOrder(3);
return filterRegistration;
}
/**
* 该过滤器负责用户的认证工作
*
* @return
*/
@Bean
public FilterRegistrationBean authenticationFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new AuthenticationFilter());
filterRegistration.setEnabled(casEnabled);
if (autoconfig.getAuthFilters().size() > 0) {
filterRegistration.setUrlPatterns(autoconfig.getAuthFilters());
} else {
filterRegistration.addUrlPatterns("/*");
}
if (autoconfig.getIgnoreFilters() != null) {
filterRegistration.addInitParameter("ignorePattern", autoconfig.getIgnoreFilters());
}
filterRegistration.addInitParameter("casServerLoginUrl", autoconfig.getCasServerLoginUrl());
filterRegistration.addInitParameter("serverName", autoconfig.getServerName());
filterRegistration.addInitParameter("useSession", autoconfig.isUseSession() ? "true" : "false");
filterRegistration.addInitParameter("redirectAfterValidation", autoconfig.isRedirectAfterValidation() ? "true" : "false");
filterRegistration.setOrder(4);
return filterRegistration;
}
/**
* 该过滤器负责对Ticket的校验工作,使用CAS 3.0协议
*
* @return
*/
@Bean
public FilterRegistrationBean cas30ProxyReceivingTicketValidationFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new Cas30ProxyReceivingTicketValidationFilter());
filterRegistration.setEnabled(casEnabled);
if (autoconfig.getValidateFilters().size() > 0) {
filterRegistration.setUrlPatterns(autoconfig.getValidateFilters());
} else {
filterRegistration.addUrlPatterns("/*");
}
filterRegistration.addInitParameter("casServerUrlPrefix", autoconfig.getCasServerUrlPrefix());
filterRegistration.addInitParameter("serverName", autoconfig.getServerName());
filterRegistration.setOrder(5);
return filterRegistration;
}
@Bean
public FilterRegistrationBean httpServletRequestWrapperFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new HttpServletRequestWrapperFilter());
filterRegistration.setEnabled(true);
if (autoconfig.getRequestWrapperFilters().size() > 0) {
filterRegistration.setUrlPatterns(autoconfig.getRequestWrapperFilters());
} else {
filterRegistration.addUrlPatterns("/*");
}
filterRegistration.setOrder(6);
return filterRegistration;
}
/**
* 该过滤器使得可以通过org.jasig.cas.client.util.AssertionHolder来获取用户的登录名。
* 比如AssertionHolder.getAssertion().getPrincipal().getName()。
* 这个类把Assertion信息放在ThreadLocal变量中,这样应用程序不在web层也能够获取到当前登录信息
*
* @return
*/
@Bean
public FilterRegistrationBean assertionThreadLocalFilter() {
FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
filterRegistration.setFilter(new AssertionThreadLocalFilter());
filterRegistration.setEnabled(true);
if (autoconfig.getAssertionFilters().size() > 0) {
filterRegistration.setUrlPatterns(autoconfig.getAssertionFilters());
} else {
filterRegistration.addUrlPatterns("/*");
}
filterRegistration.setOrder(7);
return filterRegistration;
}
}
单点登录与单点退出的配置,信息匹配认证过滤器等。比如登录验证过滤器AuthenticationFilter的doFilter,如下:
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
// 判断请求是否不需要过滤,就是我们配置spring.cas.ignore-filters属性的地方,表示
// CAS对该路由不进行拦截,直接放行
if(this.isRequestUrlExcluded(request)) {
this.logger.debug("Request is ignored.");
filterChain.doFilter(request, response);
} else {
HttpSession session = request.getSession(false);
Assertion assertion = session != null?(Assertion)session.getAttribute("_const_cas_assertion_"):null;
// 如果存在assertion,即认为这是一个已通过认证的请求,予以放行
if(assertion != null) {
filterChain.doFilter(request, response);
} else {
// 不存在 assertion,那么就来判断这个请求是否是用来校验ST的(校验通过后会将信息写入assertion)
String serviceUrl = this.constructServiceUrl(request, response);
String ticket = this.retrieveTicketFromRequest(request);
boolean wasGatewayed = this.gateway && this.gatewayStorage.hasGatewayedAlready(request, serviceUrl);
// 校验ST的请求,是否予以放行
if(!CommonUtils.isNotBlank(ticket) && !wasGatewayed) {
this.logger.debug("no ticket and no assertion found");
String modifiedServiceUrl;
if(this.gateway) {
this.logger.debug("setting gateway attribute in session");
modifiedServiceUrl = this.gatewayStorage.storeGatewayInformation(request, serviceUrl);
} else {
modifiedServiceUrl = serviceUrl;
}
this.logger.debug("Constructed service url: {}", modifiedServiceUrl);
String urlToRedirectTo = CommonUtils.constructRedirectUrl(this.casServerLoginUrl, this.getProtocol().getServiceParameterName(), modifiedServiceUrl, this.renew, this.gateway);
this.logger.debug("redirecting to \"{}\"", urlToRedirectTo);
this.authenticationRedirectStrategy.redirect(request, response, urlToRedirectTo);
} else {
filterChain.doFilter(request, response);
}
}
}
}
可以看到CAS正是通过session中是否有assertion的信息来判断一个请求是否合法。
而这个assertion信息,当我们在登陆成功后第一次重定向回客户端校验ST之后(这里的客户端指的是后台,此时重定向回客户端的请求附带有ST参数)写入session中的。
票据验证我们配置的是cas30ProxyReceivingTicketValidationFilter
,查看源码可以cas30ProxyReceivingTicketValidationFilter
继承自Cas20ProxyReceivingTicketValidationFilter
。在Cas20ProxyReceivingTicketValidationFilter
父类AbstractTicketValidationFilter
源码里,我们可以看到对票据验证和设置。
public final void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
if(this.preFilter(servletRequest, servletResponse, filterChain)) {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
String ticket = this.retrieveTicketFromRequest(request);
if(CommonUtils.isNotBlank(ticket)) {
this.logger.debug("Attempting to validate ticket: {}", ticket);
try {
// 验证票据并设置相关属性
Assertion e = this.ticketValidator.validate(ticket, this.constructServiceUrl(request, response));
this.logger.debug("Successfully authenticated user: {}", e.getPrincipal().getName());
request.setAttribute("_const_cas_assertion_", e);
if(this.useSession) {
request.getSession().setAttribute("_const_cas_assertion_", e);
}
this.onSuccessfulValidation(request, response, e);
if(this.redirectAfterValidation) {
this.logger.debug("Redirecting after successful ticket validation.");
response.sendRedirect(this.constructServiceUrl(request, response));
return;
}
} catch (TicketValidationException var8) {
this.logger.debug(var8.getMessage(), var8);
this.onFailedValidation(request, response);
if(this.exceptionOnValidationFailure) {
throw new ServletException(var8);
}
response.sendError(403, var8.getMessage());
return;
}
}
filterChain.doFilter(request, response);
}
}
上面的流程看完后,我们知道当第一次重定向回客户端的请求肯定是可以通过CAS的认证的,那么只要这个后续的请求和第一个是同一个session,那就一定可以通过CAS认证。
前面我们也说了,只要请求中的JSESSIONID是一致的,那就会被认定是同一个session。也就是我们只有保证前端JSESSIONID一致即可。
三、实战分析
讲解了那么多,我们还是来实战分析一下。这里我们有一个前后端分离的项目,前端front-demo,基于Ant Design改造,后端client-demo,源用上一次的Spring Boot代码,同理通过Restful Api进行数据交互。
我本地的IP为172.16.67.228
,front-demo前端启动8000端口,client-demo后端启动8080端口,CAS服务启动为8443端口。
这是在没有接入CAS的时候,现在我们更改client-demo,接入CAS。这里为了前端确定是否登录,这里我忽略一个用户信息接口,使得前端可以进行请求,走client-demo原来的校验逻辑,如果未登录就返回401。
spring.cas.ignore-filters=/api/user/info
private class SecurityInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
HttpSession session = request.getSession();
if (session != null) {
System.out.println("requst path " + request.getServletPath());
Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
if (assertion != null) {
System.out.println("cas user ---------> " + assertion.getPrincipal().getName());
}
User value = (User) session.getAttribute(SESSION_LOGIN);
if (value != null) {
return true;
}
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
由于这里是前后端分离,所有我们需要做一些配置。首先然后判断前端是否需要登录,所以我们在CAS忽略登录信息接口/api/user/info,当返回401时,我们进行CAS跳转登录。
if(status === 401){
window.location.href="https://sso.anumbrella.net:8443/cas/login?service=http://172.16.67.228:8080/api/user/caslogin"
}
这个/api/user/caslogin
是CAS登录成功后,后端回调接口。如下:
@RequestMapping(value = "/caslogin", method = RequestMethod.GET)
public void caslogin() throws IOException {
HttpSession session = request.getSession();
Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
if (assertion != null) {
//获取登录用户名
String username = assertion.getPrincipal().getName();
System.out.println("user ---------> " + username);
User temp = userService.findByUsername(username);
System.out.println("TEMP user ---------> " + (temp.getUsername()));
if (temp != null) {
session.setAttribute(WebSecurityConfig.SESSION_LOGIN, temp);
// 跳转到前端
response.sendRedirect("http://172.16.67.228:8000”);
}
}
}
接着我们重启服务,发现登录成功。这是因为前端后端在同一域,这里是同一个ip地址下面,前后端分离接入是没啥问题。
接下来我们进行改造,在hosts配置中添加如下:
127.0.0.1 sso.anumbrella.net
127.0.0.1 client.anumbrella.net
127.0.0.1 front.anumbrella.net
让前后端在不同的域下,现在我们更改前面的路径地址,配置为这里的域名。
if(status === 401){
window.location.href="https://sso.anumbrella.net:8443/cas/login?service=http://client.anumbrella.net:8080/api/user/caslogin"
}
// 跳转到前端
response.sendRedirect("http://front.anumbrella.net:8000”);
发现并不能登录,前端页面反复跳转。这是因为后端client.anumbrella.net第一次认证通过了,但前端发起的请求JSESSIONID不一致,认证没通过,返回给我们401,然后死循环了。
也就是说我们现在需要把后端的session的ID也就是JSESSIONID写入前端cookie中。这里提供两种解决方案:
- 前端手动写入JSESSIONID。通过重定向URL把session的ID给前端,然后让前端写入JESSIONID。
- 使用nginx代理,让前后端不跨域。用nginx将前后端反向代理到同一个域下,无论是访问前端界面还是调用后端接口还是后端cas filter中的配置都是用这个代理后的地址。
1、通过URL传递
通过URL传参,也就意味着在caslogin方法中,我们需要获取session的id,然后传递给前端。如下:
@RequestMapping(value = "/caslogin", method = RequestMethod.GET)
public void caslogin() throws IOException {
HttpSession session = request.getSession();
Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
if (assertion != null) {
//获取登录用户名
String username = assertion.getPrincipal().getName();
System.out.println("user ---------> " + username);
User temp = userService.findByUsername(username);
System.out.println("TEMP user ---------> " + (temp.getUsername()));
if (temp != null) {
session.setAttribute(WebSecurityConfig.SESSION_LOGIN, temp);
String jsessionid = session.getId();
System.out.println("jsessionid ------> " + jsessionid);
// 跳转到前端
response.sendRedirect("http://front.anumbrella.net:8000/home?jsessionid=" + jsessionid);
}
}
}
然后再更改一下前端,使得我们在每次请求前判断是否获取到jsessionid,然后写入cookie。
const jsessionid = getQueryString('jsessionid');
if (jsessionid) {
setCookie('JSESSIONID', jsessionid);
}
重启项目,然后进行登录我们发现依然失败,无法识别!!!为啥?这里就是前面所说的坑,我们浏览器的cookie和我们后端打印的完全不相同,这是为啥?说明我们写入的cookie无效,我们查看cookie可以发现。
如果cookie中设置了HttpOnly属性,那么通过js脚本将无法读取到cookie信息,这样能有效的防止XSS攻击,窃取cookie内容,这样就增加了cookie的安全性。也就是我们更新cookie无效,我们可以验证更改cookie名称,发现是可以写入的。
那怎么办?为啥前台会出现JSESSIONID,查阅资料我们知道当服务端调用request.getSession()时就会生成并传递给客户端,此次响应头会包含设置cookie的信息。
HttpSession s = request.getSession(boolean flag);
HttpSession s = request.getSession( );
包含两种方法:
- flag = true:先从请求中找找看是否有SID,没有会创建新Session对象,有SID会查找与编号对应的对象,找到匹配的对象则返回,找不到SID对应的对象时则会创建新Session对象。所以,填写true就一定会得到一个Session对象。
- flag= false:不存在SID以及按照SID找不到Session对象时都会返回null,只有根据SID找到对应的对象时会返回具体的Session对象。所以,填写false只会返回已经存在并且与SID匹配上了的Session对象。
因此当我们进行获取session时,设置默认不创建session。更改配置如下:
private class SecurityInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException {
HttpSession session = request.getSession(false);
if (session != null) {
System.out.println("requst path " + request.getServletPath());
Assertion assertion = (Assertion) session.getAttribute(AbstractCasFilter.CONST_CAS_ASSERTION);
if (assertion != null) {
System.out.println("cas user ---------> " + assertion.getPrincipal().getName());
}
User value = (User) session.getAttribute(SESSION_LOGIN);
if (value != null) {
return true;
}
}
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
}
重启服务,登录发现成功!!并且获取到用户信息。
2、通过Nginx代理
通过前面的分析我们知道原因所在就好解决问题了。主要是要前端后端的session一致即可。所以我们通过Nginx代理,直接把当前域下的赋值给另一个域,即可实现跨域完成CAS登录。
首先我们在host下配置新域名:
127.0.0.1 nginx.anumbrella.net
现在我们让前端访问、后端访问以及重定向全部跳转到nginx.anumbrella.net
域名下。
我本地nginx配置端口为81,配置前端请求走nginx代理,如下:
proxy: {
'/api/user': {
target: 'http://nginx.anumbrella.net:81',
changeOrigin: true,
// pathRewrite: { '^/server': '' },
},
},
然后我们更改前端请求401处理逻辑如下:
window.location.href = "https://sso.anumbrella.net:8443/cas/login?service=http://nginx.anumbrella.net:81/api/user/caslogin"
直接跳转到nginx代理,在代理中我们在跳转到http://client.anumbrella.net:8080/api/user/caslogin
,但是我们启动登录后在验证票据时会失败,因为这里默认将客户端更改为http://nginx.anumbrella.net:81/api/user/caslogin
了,所以在client-demo中,我们需要更改配置,服务名为:
# 使用nginx代理配置地址
spring.cas.server-name=http://nginx.anumbrella.net:81
然后配置nginx.conf文件,完成代理设置,如下:
server {
listen 81;
# server_name localhost;
#charset koi8-r;
#access_log logs/host.access.log main;
location / {
# root html;
index index.html index.htm;
proxy_pass http://front.anumbrella.net:8000;
proxy_cookie_domain front.anumbrella.net:8000 nginx.anumbrella.net:81;
proxy_pass_header Set-Cookie;
}
location /api/user {
# root html;
index index.html index.htm;
proxy_set_header Host $http_host;
proxy_pass http://client.anumbrella.net:8080;
proxy_cookie_domain client.anumbrella.net:8080 nginx.anumbrella.net:81;
proxy_pass_header Set-Cookie;
}
location /api/user/caslogin {
# root html;
index index.html index.htm;
proxy_set_header Host $http_host;
proxy_pass http://client.anumbrella.net:8080;
proxy_cookie_domain client.anumbrella.net:8080 nginx.anumbrella.net:81;
proxy_pass_header Set-Cookie;
}
......
}
重启nginx和相应服务,输入http://nginx.anumbrella.net:81
进行登录,然后可以发现登录成功!! 并有相应cookie值。
除了以上两种方式,我查阅资料还有让前端去主导CAS票据认证的解决方案,可以参考——前后端分离与CAS单点登录的结合。这个方案还没验证过,后面有空时间测试一下。
如果读者有更优的解决方案,欢迎告知一起学习!!
代码实例:Chapter12
参考