记录一个最近遇到的小问题。
我们有个web应用,使用了React + Spring Boot + Spring Security + cas认证的组合。Spring Security支持cas,代码不赘述,这里讨论用户登陆超时跳转的问题。
用户超时时(默认2小时),点击浏览器的刷新按钮,此时可以重定向到cas的登陆页面。但通常用户未必意识到已经超时了,可能仍然去点击某些控件,由于页面的Js是已经加载ok了的,所以控件可以正常操作,但submit的时候,js向后端发送restful请求,仿佛石沉大海一样,没有任何动静。打开chrome的调试模式,会看到后端返回了302的应答,但chrome因为Js请求不能跨域,拒绝了这个请求。
先来看看cas登录的背景知识。
一图胜千言。
cas本身有配置超时时间,默认是7200秒。配置在 ticketExpirationPolicies.xml
( 参考 )。
<!-- TicketGrantingTicketExpirationPolicy: Default as of 3.5 --> <!-- Provides both idle and hard timeouts, for instance 2 hour sliding window with an 8 hour max lifetime --> <bean id="grantingTicketExpirationPolicy" class="org.jasig.cas.ticket.support.TicketGrantingTicketExpirationPolicy" p:maxTimeToLiveInSeconds="${tgt.maxTimeToLiveInSeconds:28800}" p:timeToKillInSeconds="${tgt.timeToKillInSeconds:7200}"/>
timeToKillInSeconds
即登陆超时的时间。
除了cas登录超时,web应用本身也可以控制会话超时,spring boot可以通过如下参数来修改默认的策略。
--server.session.timeout=10 --server.session.cookie.max-age=10
10秒用户无操作,cookie老化,需要重新登录。
开始描述的问题,可以通过spring boot的这两个配置项快速复现出来。
我们最早的代码,是给spring security直接传了 CasAuthenticationEntryPoint
,
http.exceptionHandling().authenticationEntryPoint(casAuthenticationEntryPoint()).and()... @Bean public AuthenticationEntryPoint casAuthenticationEntryPoint() { CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint(); entryPoint.setLoginUrl(loginUrl); entryPoint.setServiceProperties(serviceProperties); }
用户在登录首页时,可以重定向到cas的登录页面;但在10s后,如果有js操作,会在chrome的console里看到如下的报错。
Fetch API cannot load https://192.168.125.66:30443/cas/login?service=http%3A%2F%2F10.84.1.138%3A2222%2Flogin. Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'http://10.84.1.138:2222' is therefore not allowed access. If an opaque response serves your needs, set the request's mode to 'no-cors' to fetch the resource with CORS disabled.
同时在Network里也会看到js请求的http返回码是302。
在js里加调试信息,会发现,这个302应答,根本没到js,而是被chrome劫走了;而chrome根据302试图跳转时,发现这是一个跨域的请求,而且也没有设置允许跨域,因此报错。
到这里,解决办法就比较清晰了:后端针对这种情况不要返回302,而是返回401(未授权);前端拿到401的应答,走logout流程,让用户重新登录。
前端的处理比较简单。
if (response.status == 401) { window.location.href = "/logout"; return; }
spring security里实现AuthenticationEntryPoint接口的几个类里,只有CasAuthenticationEntryPoint的commence方法是final的,其他的像BasicAuthenticationEntryPoint的commence方法都可以被override,这样只要自己写个类继承BasicAuthenticationEntryPoint,在commence方法里针对js请求做个特殊处理就行了(类似 issue 2999 )。
public class CasAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean { public final void commence(final HttpServletRequest servletRequest, final HttpServletResponse response, final AuthenticationException authenticationException) throws IOException, ServletException { final String urlEncodedService = createServiceUrl(servletRequest, response); final String redirectUrl = createRedirectUrl(urlEncodedService); preCommence(servletRequest, response); response.sendRedirect(redirectUrl); }
如上spring security里CasAuthenticationEntryPoint的源码,由于 CasAuthenticationEntryPoint
中没有留出可以在commence中做手脚的地方,在spring security社区问了下这个 问题 ,社区建议 favor composition over inheritance ,面向对象编程的一个重要思想: 组合优先于继承 。
所以比较合适的做法是用组合,将这个类组合到自己的类里。
class CasAuthEntryPoint implements AuthenticationEntryPoint { private CasAuthenticationEntryPoint casAuthenticationEntryPoint; CasAuthEntryPoint(final String loginUrl, final ServiceProperties serviceProperties) { casAuthenticationEntryPoint = new CasAuthenticationEntryPoint(); casAuthenticationEntryPoint.setLoginUrl(loginUrl); casAuthenticationEntryPoint.setServiceProperties(serviceProperties); } @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { if ("XMLHttpRequest".equals(request.getHeader("X-Requested-With"))) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "UNAUTHORIZED"); } else { casAuthenticationEntryPoint.commence(request, response, authException); } casAuthenticationEntryPoint.commence(request, response, authException); } }
最好不要粗暴的把spring security的代码拷贝出来直接改,一个是license问题,再一个是升级问题。
Ref: