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