Georgi Koemdzhiev
Georgi Koemdzhiev

Reputation: 11931

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.

Upvotes: 0

Views: 60

Answers (1)

Georgi Koemdzhiev
Georgi Koemdzhiev

Reputation: 11931

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.

Upvotes: 0

Related Questions