3

I've been working with Spring Security for two weeks now and it's working well except for anonymous users and session timeouts.

Use Case #1

  1. An anonymous user can visit the site and view public pages (/home, /about, /signup, etc.) for as long as they want (no session timeouts).
  2. If the user selects a protected page the login screen appears.

Use Case #2

  1. A registered user logs in and can view protected pages.

  2. If their session times out, the invalidSessionUrl page is displayed and the user is directed to log in.

I've read a ton of SO and blog posts but I can't seem to find the solution to Use Case #1. I'm using Spring 4.1.6.RELEASE, Spring Security 4.0.2 RELEASE and Tomcat8.

Here is my security config:

    protected void configure(HttpSecurity http) throws Exception {
    http
    .authorizeRequests()
        .antMatchers("/", "/home", "/about", "/login**", "/thankyou", "/user/signup**", "/errors/**").permitAll() 
        .regexMatchers("/user/signup/.*").permitAll()
        .antMatchers("/recipe/listRecipes*").hasAuthority("GUEST")
        .antMatchers("/recipe/addRecipe*").hasAuthority("AUTHOR")
        .antMatchers("/admin/**").hasAuthority("ADMIN")
        .anyRequest().authenticated()
        .expressionHandler(secExpressionHandler())
        .and()
    .formLogin()
        .loginPage("/login")
        .failureUrl("/login?err=1")
        .permitAll()
        .and()
    .logout()
        .logoutSuccessUrl("/thankyou")
        .deleteCookies( "JSESSIONID" )
        .invalidateHttpSession(false)
        .and()
    .exceptionHandling()
        .accessDeniedPage("/errors/403")
        .and()
    .rememberMe()
        .key("recipeOrganizer")
        .tokenRepository(persistentTokenRepository())
        .tokenValiditySeconds(960)      //.tokenValiditySeconds(1209600)
        .rememberMeParameter("rememberMe");

    http
    .sessionManagement()
        .sessionAuthenticationErrorUrl("/errors/402")   
        .invalidSessionUrl("/errors/invalidSession")
        .maximumSessions(1)
        .maxSessionsPreventsLogin(true)
        .expiredUrl("/errors/expiredSession")
        .and()
        .sessionFixation().migrateSession();

Any action by an anonymous user after the session expires results in an invalid session exception. The problem is in the security filter chain (see log below) the AnonymousAuthenticationFilter (#11) creates a new session, but the SessionManagementFilter (#12) retrieves the prior expired session, compares it to the new one and throws the invalidsession exception. I want this to happen for logged in users, but not for anonymous users. However, at the time the exception is thrown the security context has been destroyed so I can't know whether the prior session was from an anonymous or logged in user.

Solutions I've considered are:

  1. Set the global session timeout to 24 hours or more then adjust the timeout for registered users in a LoginSuccessHandler and RememberMeSuccessHandler.
  2. Turn off security for the public pages.
  3. Create a separate cookie to indicate the type of user (anon vs. logged in), and query that in the InvalidSessionStrategy to redirect anon users to the /home page.
  4. Create a custom filter that is executed first to identify an anon user (possible?) and simply extend the current session.
  5. Write some javascript or jquery to periodically check the session timeout and reset it for anon users.

Solution #1 may result in issues with expired sessions on the server (not sure if this is a big deal). #2 doesn't feel right to me, especially if a public page may include any info about a logged in user. #3 might work except that the anon user could click on a public link other than the home page but get redirected to the home page because I don't think there's a way to tell in the InvalidSessionStrategy which link they were trying to access? I'm not sure if #4 will work or not - haven't tried it yet. #5 might also work but it increases network traffic?

I'm hoping that someone can point me to a practical solution. This has to be something that many sites deal with but I'm going around in circles trying to solve. Thanks in advance for any advice or tips.

Here's a portion of a log to illustrate what happens. I've set the session timeout to 60 seconds for testing purposes.

Initial access to website
20:49:29.823 DEBUG: org.springframework.security.web.FilterChainProxy - / at position 11 of 14 in additional filter chain; firing Filter: 'AnonymousAuthenticationFilter'
20:49:29.839 DEBUG: org.springframework.security.web.authentication.AnonymousAuthenticationFilter - Populated SecurityContextHolder with anonymous token: 'org.springframework.security.authentication.AnonymousAuthenticationToken@9055c2bc: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS'
20:49:29.839 DEBUG: org.springframework.security.web.FilterChainProxy - / at position 12 of 14 in additional filter chain; firing Filter: 'SessionManagementFilter'
20:49:29.839 DEBUG: org.springframework.security.web.FilterChainProxy - / at position 13 of 14 in additional filter chain; firing Filter: 'ExceptionTranslationFilter'
20:49:29.839 DEBUG: org.springframework.security.web.FilterChainProxy - / at position 14 of 14 in additional filter chain; firing Filter: 'FilterSecurityInterceptor'
20:49:29.839 DEBUG: org.springframework.security.web.util.matcher.AntPathRequestMatcher - Checking match of request : '/'; against '/'
20:49:29.839 DEBUG: org.springframework.security.web.access.intercept.FilterSecurityInterceptor - Secure object: FilterInvocation: URL: /; Attributes: [permitAll]
20:49:29.854 DEBUG: org.springframework.security.web.access.intercept.FilterSecurityInterceptor - Previously Authenticated: org.springframework.security.authentication.AnonymousAuthenticationToken@9055c2bc: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS
20:49:29.871 DEBUG: org.springframework.security.access.vote.AffirmativeBased - Voter: org.springframework.security.web.access.expression.WebExpressionVoter@ff577, returned: 1
20:49:29.871 DEBUG: org.springframework.security.web.access.intercept.FilterSecurityInterceptor - Authorization successful
20:49:30.136 DEBUG: net.mycompany.myapp.config.SessionListener - AuthenticationSuccess - principal: anonymousUser
20:49:30.167 DEBUG: net.mycompany.myapp.config.SessionListener - AuthenticationSuccess - authority contains: ROLE_ANONYMOUS
20:49:30.167 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session created on: Mon Sep 21 20:49:30 CDT 2015
20:49:30.167 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session last accessed on: Mon Sep 21 20:49:30 CDT 2015
20:49:30.167 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session expires after: 60 seconds
20:49:30.167 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session ID: CA34FE74B56B8EF94181B1231A7D4FF6

Session times out after 60 seconds
20:50:46.015 INFO : net.mycompany.myapp.config.SessionDestroyedListener - destroyedEvent
20:50:46.015 INFO : net.mycompany.myapp.config.SessionDestroyedListener - object:org.springframework.security.web.session.HttpSessionDestroyedEvent[source=org.apache.catalina.session.StandardSessionFacade@17827f3e]
20:50:46.015 INFO : net.mycompany.myapp.config.SessionListener - sessionDestroyed: Session created on: Mon Sep 21 20:49:30 CDT 2015
20:50:46.015 INFO : net.mycompany.myapp.config.SessionListener - sessionDestroyed: Session last accessed on: Mon Sep 21 20:49:32 CDT 2015
20:50:46.015 INFO : net.mycompany.myapp.config.SessionListener - sessionDestroyed: Session expires after: 60 seconds
20:50:46.015 INFO : net.mycompany.myapp.config.SessionListener - sessionDestroyed: Session ID: CA34FE74B56B8EF94181B1231A7D4FF6

Click on "/home" link (unsecured)
20:50:53.927 DEBUG: org.springframework.security.web.util.matcher.AntPathRequestMatcher - Checking match of request : '/home'; against '/resources/**'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 1 of 14 in additional filter chain; firing Filter: 'WebAsyncManagerIntegrationFilter'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 2 of 14 in additional filter chain; firing Filter: 'SecurityContextPersistenceFilter'
20:50:53.927 DEBUG: org.springframework.security.web.context.HttpSessionSecurityContextRepository - No HttpSession currently exists
20:50:53.927 DEBUG: org.springframework.security.web.context.HttpSessionSecurityContextRepository - No SecurityContext was available from the HttpSession: null. A new one will be created.
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 3 of 14 in additional filter chain; firing Filter: 'HeaderWriterFilter'
20:50:53.927 DEBUG: org.springframework.security.web.header.writers.HstsHeaderWriter - Not injecting HSTS header since it did not match the requestMatcher org.springframework.security.web.header.writers.HstsHeaderWriter$SecureRequestMatcher@4c668dec
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 4 of 14 in additional filter chain; firing Filter: 'CsrfFilter'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 5 of 14 in additional filter chain; firing Filter: 'LogoutFilter'
20:50:53.927 DEBUG: org.springframework.security.web.util.matcher.AntPathRequestMatcher - Request 'GET /home' doesn't match 'POST /logout
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 6 of 14 in additional filter chain; firing Filter: 'UsernamePasswordAuthenticationFilter'
20:50:53.927 DEBUG: org.springframework.security.web.util.matcher.AntPathRequestMatcher - Request 'GET /home' doesn't match 'POST /login
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 7 of 14 in additional filter chain; firing Filter: 'ConcurrentSessionFilter'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 8 of 14 in additional filter chain; firing Filter: 'RequestCacheAwareFilter'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 9 of 14 in additional filter chain; firing Filter: 'SecurityContextHolderAwareRequestFilter'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 10 of 14 in additional filter chain; firing Filter: 'RememberMeAuthenticationFilter'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 11 of 14 in additional filter chain; firing Filter: 'AnonymousAuthenticationFilter'
20:50:53.927 DEBUG: org.springframework.security.web.authentication.AnonymousAuthenticationFilter - Populated SecurityContextHolder with anonymous token: 'org.springframework.security.authentication.AnonymousAuthenticationToken@9055c2bc: Principal: anonymousUser; Credentials: [PROTECTED]; Authenticated: true; Details: org.springframework.security.web.authentication.WebAuthenticationDetails@b364: RemoteIpAddress: 0:0:0:0:0:0:0:1; SessionId: null; Granted Authorities: ROLE_ANONYMOUS'
20:50:53.927 DEBUG: org.springframework.security.web.FilterChainProxy - /home at position 12 of 14 in additional filter chain; firing Filter: 'SessionManagementFilter'
20:50:53.927 DEBUG: org.springframework.security.web.session.SessionManagementFilter - Requested session ID CA34FE74B56B8EF94181B1231A7D4FF6 is invalid.
20:50:53.927 DEBUG: org.springframework.security.web.session.SimpleRedirectInvalidSessionStrategy - Starting new session (if required) and redirecting to '/errors/invalidSession'
20:50:53.927 DEBUG: net.mycompany.myapp.config.SessionListener - AuthenticationSuccess - principal: anonymousUser
20:50:53.927 DEBUG: net.mycompany.myapp.config.SessionListener - AuthenticationSuccess - authority contains: ROLE_ANONYMOUS
20:50:53.927 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session created on: Mon Sep 21 20:50:53 CDT 2015
20:50:53.927 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session last accessed on: Mon Sep 21 20:50:53 CDT 2015
20:50:53.942 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session expires after: 60 seconds
20:50:53.942 INFO : net.mycompany.myapp.config.SessionListener - sessionCreated: Session ID: 6F7FD6230BC075B7768648BBBC08E3F4
20:50:53.942 DEBUG: org.springframework.security.web.session.HttpSessionEventPublisher - Publishing event: org.springframework.security.web.session.HttpSessionCreatedEvent[source=org.apache.catalina.session.StandardSessionFacade@412cbe09]
20:50:53.942 DEBUG: org.springframework.security.web.DefaultRedirectStrategy - Redirecting to '/myapp/errors/invalidSession'
LWK69
  • 1,070
  • 2
  • 14
  • 27

2 Answers2

0

I posted a similar question a few days after this one: Why does anonymous user get redirected to expiredsessionurl by Spring Security, for which I've posted an answer. Solution #2 above works, but I don't like turning off all security even for public pages. What I ended up implementing was a combination of #3 and #4. See the linked question for the full answer.

Community
  • 1
  • 1
LWK69
  • 1,070
  • 2
  • 14
  • 27
0

This is a old question but I have a similar problem even now (Spring Security 5.5.1)

I found some answers but they seemed too fragile for me:

This is what works for me (using a custom filter for Spring Security)

/**
 * https://doanduyhai.wordpress.com/2012/04/21/spring-security-part-vi-session-timeout-handling-for-ajax-calls/
 * 1. detect session expiry and ajax request --> send custom error code so that
 * client side can detect and handle as necessary if not, it will be a redirect
 * to login page and with 200 response
 *
 * https://forum.primefaces.org/viewtopic.php?t=16735 (use xml partial response for redirect)
 * @author RadianceOng
 */
public class AjaxTimeoutRedirectFilter extends GenericFilterBean {

    static Logger lg = Logger.getLogger(AjaxTimeoutRedirectFilter.class);

    private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();
    private AuthenticationTrustResolver authenticationTrustResolver = new AuthenticationTrustResolverImpl();
    
    String invalidSessionUrl;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        try {
            chain.doFilter(request, response);
        } catch (IOException ex) {
            throw ex;
        } catch (Exception ex) {
            Throwable[] causeChain = throwableAnalyzer.determineCauseChain(ex);
            Exception ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class, causeChain);

            if (ase == null) {
                ase = (AccessDeniedException) throwableAnalyzer.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
            }

            if (ase != null) {
                if (ase instanceof AuthenticationException) {
                    throw (AuthenticationException) ase;
                } else if (ase instanceof AccessDeniedException) {

                    AccessDeniedException ade = (AccessDeniedException) ase;


                    if (authenticationTrustResolver.isAnonymous(SecurityContextHolder.getContext().getAuthentication())) {
                        logger.trace("User session expired or not logged in yet and trying to access unauthorized resource");

                        HttpServletRequest httpReq = (HttpServletRequest) request;
                        HttpServletResponse resp = (HttpServletResponse) response;
                        if (isAjaxRequest(httpReq)) {
                            logger.trace("Ajax call detected, redirecting");
                            
                            
                            /**
                             * Session expiry check is copied from SessionManagementFilter
                             * accessdenied check is derived from ExceptionTranslationFilter
                             * No idea what is the order... This is a workaround.
                             */
                            
                            final String redirectUrl = invalidSessionUrl;
                            sendAjaxRedirect(resp, httpReq, redirectUrl);
                        } else {
                            logger.trace("Normal call detected, redirecting");
                            new SimpleRedirectInvalidSessionStrategy(invalidSessionUrl).onInvalidSessionDetected(httpReq, resp);
                        }
                    } else {
                        throw ade;
                    }
                }
            } else {
                throw ex;
            }

        }
    }

    public String getInvalidSessionUrl() {
        return invalidSessionUrl;
    }

    public void setInvalidSessionUrl(String invalidSessionUrl) {
        this.invalidSessionUrl = invalidSessionUrl;
    }
    
    private boolean isAjaxRequest(HttpServletRequest httpReq) {
        return new AjaxRedirectUtils().isAjaxRequest(httpReq);
    }

    private void sendAjaxRedirect(HttpServletResponse resp, HttpServletRequest httpReq, final String redirectUrl) throws IOException {
        new AjaxRedirectUtils().sendAjaxRedirect(resp, httpReq, redirectUrl);
    }
    
    private static final class DefaultThrowableAnalyzer extends ThrowableAnalyzer {

        /**
         * @see
         * org.springframework.security.web.util.ThrowableAnalyzer#initExtractorMap()
         */
        protected void initExtractorMap() {
            super.initExtractorMap();

            registerExtractor(ServletException.class, new ThrowableCauseExtractor() {
                public Throwable extractCause(Throwable throwable) {
                    ThrowableAnalyzer.verifyThrowableHierarchy(throwable, ServletException.class);
                    return ((ServletException) throwable).getRootCause();
                }
            });
        }

    }
}

In the XML configuration for Spring Security, add the filter after EXCEPTION_TRANSLATION_FILTER so that it can detect access denied

<http>
<custom-filter ref="ajaxTimeoutRedirectFilter" after="EXCEPTION_TRANSLATION_FILTER"/>
</http>

Note that if you use the built-in session management, make sure you do not use the invalid-session-url or related else that will take effect instead

<http>
 <session-management>
 </session-management>
</http>

This filter also caters for Ajax redirection. I left that out as that is not relevant to the question

In summary, what this achieves is to redirect only if access denied. So non- logged-in users will not get redirected unless they access a unauthorized page. Logged-in users whose sessions expire will get redirected when they access an unauthorized page.