CAS单点登录(十三)——客户端前后端分离接入
CAS单点登录(十三)——客户端前后端分离接入
最近在工作上遇到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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
单点登录与单点退出的配置,信息匹配认证过滤器等。比如登录验证过滤器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);
}
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
可以看到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);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
上面的流程看完后,我们知道当第一次重定向回客户端的请求肯定是可以通过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
1
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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
由于这里是前后端分离,所有我们需要做一些配置。首先然后判断前端是否需要登录,所以我们在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"
}
1
2
3
这个/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”);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
接着我们重启服务,发现登录成功。这是因为前端后端在同一域,这里是同一个ip地址下面,前后端分离接入是没啥问题。
接下来我们进行改造,在hosts配置中添加如下:
127.0.0.1 sso.anumbrella.net
127.0.0.1 client.anumbrella.net
127.0.0.1 front.anumbrella.net
1
2
3
让前后端在不同的域下,现在我们更改前面的路径地址,配置为这里的域名。
if(status === 401){
window.location.href="https://sso.anumbrella.net:8443/cas/login?service=http://client.anumbrella.net:8080/api/user/caslogin"
}
1
2
3
// 跳转到前端
response.sendRedirect("http://front.anumbrella.net:8000”);
1
2
发现并不能登录,前端页面反复跳转。这是因为后端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);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
然后再更改一下前端,使得我们在每次请求前判断是否获取到jsessionid,然后写入cookie。
const jsessionid = getQueryString('jsessionid');
if (jsessionid) {
setCookie('JSESSIONID', jsessionid);
}
1
2
3
4
重启项目,然后进行登录我们发现依然失败,无法识别!!!为啥?这里就是前面所说的坑,我们浏览器的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( );
1
2
包含两种方法:
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;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
重启服务,登录发现成功!!并且获取到用户信息。
2、通过Nginx代理
通过前面的分析我们知道原因所在就好解决问题了。主要是要前端后端的session一致即可。所以我们通过Nginx代理,直接把当前域下的赋值给另一个域,即可实现跨域完成CAS登录。
首先我们在host下配置新域名:
127.0.0.1 nginx.anumbrella.net
1
现在我们让前端访问、后端访问以及重定向全部跳转到nginx.anumbrella.net域名下。
我本地nginx配置端口为81,配置前端请求走nginx代理,如下:
proxy: {
'/api/user': {
target: 'http://nginx.anumbrella.net:81',
changeOrigin: true,
// pathRewrite: { '^/server': '' },
},
},
1
2
3
4
5
6
7
然后我们更改前端请求401处理逻辑如下:
window.location.href = "https://sso.anumbrella.net:8443/cas/login?service=http://nginx.anumbrella.net:81/api/user/caslogin"
1
直接跳转到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
1
2
然后配置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;
}
......
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
重启nginx和相应服务,输入http://nginx.anumbrella.net:81进行登录,然后可以发现登录成功!! 并有相应cookie值。
除了以上两种方式,我查阅资料还有让前端去主导CAS票据认证的解决方案,可以参考——前后端分离与CAS单点登录的结合。这个方案还没验证过,后面有空时间测试一下。
如果读者有更优的解决方案,欢迎告知一起学习!!
代码实例:Chapter12
参考
前后端分离与CAS单点登录的结合
前后端分离的项目集成CAS
————————————————
版权声明:本文为CSDN博主「Anumbrella」的原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/Anumbrella/article/details/94859351