Search code examples
spring-bootspring-securityvaadin-flowvaadin14

How to invalidate HTTP session due to user inactivity in Vaadin/Spring Boot?


I am trying to implement an inactive session expiry in my Vaadin application using OKTA for auth.

Right now, the application shows this build-in dialogue (I set the text) after the server.servlet.session.timeout is reached:

enter image description here

The issue is that the JSESSIONID (i.e. the HTTP session) does not change/get recreated after the user clicks on the window/presses escape which currently results in the user getting logged in again. That happens as the code "sees" a valid OKTA session and logs back the user automatically.

How do I make sure that the HTTP session gets terminated/recreated as well when the session expires?

Here is my SecurityConfiguration:

@EnableWebSecurity
@Configuration
@Order(99)
public class S1SecurityConfiguration extends SecurityConfiguration {
    ...
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        super.configure(http); // Apply default configurations first - it sets up anonymous-user handing

        http.oauth2Login(oauth2 - >
            oauth2
            .userInfoEndpoint(userInfo - > userInfo.oidcUserService(oidcUserService()))
            .authorizationEndpoint(authEndpoint - > authEndpoint
                .authorizationRequestResolver(
                    new ForcePromptLoginRequestResolver(
                        clientRegistrationRepository,
                        "/oauth2/authorization"
                    )
                )
            )
            .successHandler(s1authSuccessHandler)
            .failureHandler(authFailureHandler)

        );

        // Finally, enable concurrency in session management
        http.sessionManagement(sessionManagement - >
            sessionManagement
            .sessionFixation(sessionFixation - > sessionFixation.migrateSession())
            .sessionConcurrency(sessionConcurrency - >
                sessionConcurrency
                .sessionRegistry(sessionRegistry())
                .maximumSessions(1)
                .maxSessionsPreventsLogin(false) // second login is allowed, but will invalidate the first
                .expiredUrl("/session-expired") // redirect to this page if the session is expired
            )
        );

        ...
    }

    @Bean
    public SessionRegistry sessionRegistry() {
        return new SessionRegistryImpl();
    }

    @Bean
    public SessionAuthenticationStrategy sessionAuthenticationStrategy(SessionRegistry sessionRegistry) {
        // Concurrency strategy
        ConcurrentSessionControlAuthenticationStrategy concurrencyStrategy =
            new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry);
        concurrencyStrategy.setMaximumSessions(1);
        concurrencyStrategy.setExceptionIfMaximumExceeded(true); // same as maxSessionsPreventsLogin(true)

        // Combine concurrency + session fixation protection
        return new CompositeSessionAuthenticationStrategy(
            Arrays.asList(
                new ChangeSessionIdAuthenticationStrategy(), // or MigrateSession
                concurrencyStrategy
            )
        );
    }

    @Bean
    public S1AuthenticationSuccessHandler s1authFailureHandler(SessionAuthenticationStrategy sessionAuthenticationStrategy) {
        return new S1AuthenticationSuccessHandler(sessionAuthenticationStrategy);
    }

    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

    @Bean
    public OAuth2UserService < OidcUserRequest,
    OidcUser > oidcUserService() {
        final OidcUserService delegate = new OidcUserService();
        return userRequest - > {
            // Load the default OidcUser via the delegate
            OidcUser oidcUser = delegate.loadUser(userRequest);

            String principalName = oidcUser.getAttribute("email");

            // Return a SimpleOidcUser that uses only or email for equality checks (to make the concurrency check work)
            return new SimpleOidcUser(
                oidcUser.getAuthorities(),
                oidcUser.getIdToken(),
                oidcUser.getUserInfo(),
                principalName
            );
        };
    }
}

application.properties:

server.servlet.session.timeout=30m
# set closeIdleSessions to true so heartbeat/push requests do not keep resetting the above session inactivity timer
vaadin.closeIdleSessions=true

SessionExpiredMessageInitServiceListener.java

@Component
public class SessionExpiredMessageInitServiceListener implements VaadinServiceInitListener {

    @Override
    public void serviceInit(ServiceInitEvent event) {
        event.getSource().setSystemMessagesProvider(new SystemMessagesProvider() {
            @Override
            public CustomizedSystemMessages getSystemMessages(SystemMessagesInfo systemMessagesInfo) {
                CustomizedSystemMessages messages = new CustomizedSystemMessages();
                messages.setSessionExpiredCaption("Session expired");
                messages.setSessionExpiredMessage(
                        "Your session has expired. Press ESC or click anywhere in this window to continue."
                );
                // If you have a static page or route for session-expired:
                messages.setSessionExpiredURL("/envdata");
                messages.setSessionExpiredNotificationEnabled(true);
                return messages;
            }
        });
    }
}

UPDATE:

I reached out to Vaadin Expert Chat and they suggested adding server.servlet.session.cookie.max-age config parameter to my application.properties file which resolved the issue of the HTTP session/JSESSIONID not refreshing but it forces the user to login again - i.e. it's not respecting the activity of the user.


Solution

  • Here’s what I did in the end to resolve the issue of the HTTP (JSESSIONID cookie) not invalidating when the user has been idle for a set time:

    I added a Logout endpoint:

    @Controller
    public class LogoutController {
        @Value("${okta.oauth2.issuer}")
        private String oktaDomain;
    
        @Value("${okta.post.logout.redirect.uri}")
        private String oktaLogoutUrl;
    
        @GetMapping("/logout")
        public String logout(HttpServletRequest request, HttpServletResponse response) {
    
            var idTokenObj = request.getSession().getAttribute("idToken");
            if (idTokenObj == null) {
                // Redirect to OKTA logout endpoint
                return "redirect:/envdata";
            }
            var idToken = idTokenObj.toString();
    
            // Invalidate the HTTP session
            request.getSession().invalidate();
    
            // Clear the security context
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            if (auth != null) {
                new SecurityContextLogoutHandler().logout(request, response, auth);
            }
    
            String logoutUrl = oktaDomain + "/v1/logout" +
                    "?id_token_hint=" + URLEncoder.encode(idToken, StandardCharsets.UTF_8) +
                    "&post_logout_redirect_uri=" + oktaLogoutUrl;
    
            // Redirect to OKTA logout endpoint
            return "redirect:" + logoutUrl;
        }
    }
    

    Changed my SessionExpiredMessageInitServiceListener class to redirect to that endpoint - /logout after the user clicks on the “Session Expired” dialog (or presses ESC):

    @Component
    public class SessionExpiredMessageInitServiceListener implements VaadinServiceInitListener {
    
        @Override
        public void serviceInit(ServiceInitEvent event) {
            event.getSource().setSystemMessagesProvider(new SystemMessagesProvider() {
                @Override
                public CustomizedSystemMessages getSystemMessages(SystemMessagesInfo systemMessagesInfo) {
                    CustomizedSystemMessages messages = new CustomizedSystemMessages();
                    messages.setSessionExpiredCaption("Session expired");
                    messages.setSessionExpiredMessage(
                            "Your session has expired. Press ESC or click anywhere in this window to continue."
                    );
                    // If you have a static page or route for session-expired:
                    messages.setSessionExpiredURL("/logout");
                    messages.setSessionExpiredNotificationEnabled(true);
                    return messages;
                }
            });
        }
    }
    

    With the above 2 additional changes the HTTP session is successfully invalidated and and the user is required to re-login using OKTA and JSESSIONID cookie is refreshed as I wanted.