记录一个最近遇到的小问题。

我们有个web应用,使用了React + Spring Boot + Spring Security + cas认证的组合。Spring Security支持cas,代码不赘述,这里讨论用户登陆超时跳转的问题。

用户超时时(默认2小时),点击浏览器的刷新按钮,此时可以重定向到cas的登陆页面。但通常用户未必意识到已经超时了,可能仍然去点击某些控件,由于页面的Js是已经加载ok了的,所以控件可以正常操作,但submit的时候,js向后端发送restful请求,仿佛石沉大海一样,没有任何动静。打开chrome的调试模式,会看到后端返回了302的应答,但chrome因为Js请求不能跨域,拒绝了这个请求。

先来看看cas登录的背景知识。

cas的认证流程

一图胜千言。

cas

cas登录超时与session超时

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的这两个配置项快速复现出来。

为什么js拿不到302请求?

我们最早的代码,是给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: