spring-bootspring-security

Is it possible to control which requests will reset a session's inactivity in Spring Boot?


Using Spring Boot (3.2) in an application deployed as a boot standalone WAR (not in an app server), is it possible to control which requests reset a session's inactivity?

The scenario: An app with a mixture of pages (JSPs) and API. We want session inactivity to be reset on every page request, but not for API requests. In other words, for a user sitting on a page that makes periodic API requests (polling), the user's session never times out. I want to limit the session inactivity so that it is not reset by those API requests (so the user sitting on that page does time out).

Is that possible? If so, how? Alternatively, a pointer to the servlet filter that does the inactivity reset would be useful.


Solution

  • As mentioned in the comments, Spring isn't involved in idle session tracking or timeouts, it just passes configuration on to the embedded web server (Tomcat by default).

    So to implement this, I built the following class. It works by wrapping requests to "normal" pages (requests that should reset the session timeout) so that it can observe access to the session and record that time as a separate attribute in the session. For requests that should not count towards session activity (specified by a set of URL patterns in this solution), it checks that stored attribute against the current time to see if the timeout has passed; if so, it invalidates the session and passes the request on to the chain for processing (which allows Spring to handle it like any other expired/invalid session).

    import static java.lang.System.currentTimeMillis;
    
    import java.io.IOException;
    import java.time.Duration;
    import java.util.Arrays;
    import java.util.List;
    
    import org.springframework.boot.web.servlet.FilterRegistrationBean;
    import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
    import org.springframework.web.filter.OncePerRequestFilter;
    
    import jakarta.servlet.FilterChain;
    import jakarta.servlet.ServletException;
    import jakarta.servlet.http.HttpServletRequest;
    import jakarta.servlet.http.HttpServletRequestWrapper;
    import jakarta.servlet.http.HttpServletResponse;
    import jakarta.servlet.http.HttpSession;
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j
    public class IdleSessionTrackingFilter extends OncePerRequestFilter {
    
        private static final String LAST_ACCESS_ATTRIBUTE = "lastAccessTime";
    
        private final Duration idleSessionTimeout;
        private final List<AntPathRequestMatcher> matchers;
    
    
        public IdleSessionTrackingFilter(Duration idleSessionTimeout, String... patterns) {
            this.idleSessionTimeout = idleSessionTimeout;
            this.matchers = Arrays.stream(patterns)
                                .map(AntPathRequestMatcher::antMatcher)
                                .toList();
        }
    
        public FilterRegistrationBean<IdleSessionTrackingFilter> reigstrationBean() {
            return new FilterRegistrationBean<>(this);
        }
    
        protected boolean shouldNotKeepAlive(HttpServletRequest request) throws ServletException {
            return matchers.stream().anyMatch(m -> m.matches(request));
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
    
            boolean normalRequest = !shouldNotKeepAlive(request);
    
            if (normalRequest) {
                log.debug("Non-matching request, processing as normal.");
                // Wrap the request (to observe session access) and process as normal
                request = new SessionAccessAwareRequest(request);
                chain.doFilter(request, response);
                return;
            }
    
            HttpSession session = request.getSession(false);
            if (session == null) {
                log.debug("No session in request to {} {}, processing as normal.", request.getMethod(), request.getRequestURI());
            } else {
                if (isExpired(session)) {
                    log.info("Non-renewing request to {} {} and session is old, invalidating it.", request.getMethod(), request.getRequestURI());
                    session.invalidate();
                }
            }
    
            chain.doFilter(request, response);
        }
    
        protected boolean isExpired(HttpSession session) {
            Long lastAccessTime =  (Long) session.getAttribute(LAST_ACCESS_ATTRIBUTE);
            if (lastAccessTime == null ) {
                return false;
            }
    
            long idleTime = currentTimeMillis() - lastAccessTime.longValue();
            return idleTime > idleSessionTimeout.toMillis();
        }
    
        /**
         * Request wrapper that observes session access via getSession() method and
         * stores that as our internal last-accessed-time.
         */
        private static class SessionAccessAwareRequest extends HttpServletRequestWrapper {
    
            public SessionAccessAwareRequest(HttpServletRequest request) {
                super(request);
            }
    
            @Override
            public HttpSession getSession(boolean create) {
                HttpSession session = super.getSession(create);
                if (session != null) {
                    session.setAttribute(LAST_ACCESS_ATTRIBUTE, currentTimeMillis());
                }
    
                return session;
            }
        }
    }
    

    An example of usage:

        private static final String ALL_APIS_PATTERN = "/**/api/**";
    
        @Value("${server.servlet.session.timeout}")
        private Duration sessionTimeout;
    
        // ...
    
        @Bean
        FilterRegistrationBean<IdleSessionTrackingFilter> apiRequestSessionKeepAliveFilter() {
            return new IdleSessionTrackingFilter(sessionTimeout, ALL_APIS_PATTERN)
                        .reigstrationBean();
        }
    

    This solution is loosely based on this answer to a similar question by @pavel-horal