flow icon indicating copy to clipboard operation
flow copied to clipboard

Support authentication event notifications within the context of the associated VaadinSession

Open archiecobbs opened this issue 1 year ago • 3 comments

Describe your motivation

I'm trying to use Vaadin flow, Vaadin's LoginForm, and spring-security. The login process works fine.

But I have Spring beans that want to be notified when a user logs in. Ideally, they would use the existing mechanism for doing this as documented here in the spring-security docs:

@EventListener
public void onSuccess(AuthenticationSuccessEvent success) { ... }
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent failures) { ... }

Examples of the function of such a listener:

  • Update the display somehow, e.g., show "Logged in as fred" or "Not logged in".
  • Update the session idle timeout to be long when there is a logged-in user, but short otherwise (e.g., when some random Internet bot has successfully loaded the login screen and created a new, useless Vaadin session).

But I can't figure out how to do this, because those spring-securuty events are delivered with no VaadinSession context.

In other words, there's no way to recover the user's VaadinSession from those events, so there's no way to update his UI, change his session idle timeout, etc.

This seems like a fundamental omission in the spring-security integration provided by vaadin-spring. Or maybe I'm missing something?

Describe the solution you'd like

Provide a simple way for me to listen for authentication events with the associated session.

This could be a new event listener registration, or perhaps Vaadin could make the events delivered implement some new interface HasVaadinSession that I could cast to, or whatever.

Describe alternatives you've considered

Nothing comes to mind just yet.

Additional context

Vaadin web security is fundamentally session-based; there's no such thing as a Vaadin "login" without an associated session. So the Vaadin integration with Spring security should reflect this. The session should be closely tied to everything that happens.

archiecobbs avatar Feb 10 '24 17:02 archiecobbs

FYI, here's an exception showing the gory details of why using a plain @EventListener doesn't work.

The problem being reported is that the target bean with the @EventListener method is a @VaadinSessionScope bean, but it can't be found when the event is being delivered because there's no VaadinSession associated with the current thread and therefore no vaadin-session scope in which to look it up:

SEVERE: Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
org.springframework.beans.factory.support.ScopeNotActiveException: Error creating bean with name 'mybean':
    Scope 'vaadin-session' is not active for the current thread; consider defining a scoped proxy for this bean if you
    intend to refer to it from a singleton
	at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:373)
	at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:199)
	at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1231)
	at org.springframework.context.event.ApplicationListenerMethodAdapter.getTargetBean(ApplicationListenerMethodAdapter.java:392)
	at org.springframework.context.event.ApplicationListenerMethodAdapter.doInvoke(ApplicationListenerMethodAdapter.java:354)
	at org.springframework.context.event.ApplicationListenerMethodAdapter.processEvent(ApplicationListenerMethodAdapter.java:237)
	at org.springframework.context.event.ApplicationListenerMethodAdapter.onApplicationEvent(ApplicationListenerMethodAdapter.java:168)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.doInvokeListener(SimpleApplicationEventMulticaster.java:178)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.invokeListener(SimpleApplicationEventMulticaster.java:171)
	at org.springframework.context.event.SimpleApplicationEventMulticaster.multicastEvent(SimpleApplicationEventMulticaster.java:149)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:451)
	at org.springframework.context.support.AbstractApplicationContext.publishEvent(AbstractApplicationContext.java:384)
	at org.springframework.security.authentication.DefaultAuthenticationEventPublisher.publishAuthenticationSuccess(DefaultAuthenticationEventPublisher.java:99)
	at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:226)
	at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:201)
	at org.springframework.security.authentication.ObservationAuthenticationManager.lambda$authenticate$1(ObservationAuthenticationManager.java:54)
	at io.micrometer.observation.Observation.observe(Observation.java:565)
	at org.springframework.security.authentication.ObservationAuthenticationManager.authenticate(ObservationAuthenticationManager.java:53)
	at org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.attemptAuthentication(UsernamePasswordAuthenticationFilter.java:85)
	at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:231)
	at org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter.doFilter(AbstractAuthenticationProcessingFilter.java:221)
	at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.wrapFilter(ObservationFilterChainDecorator.java:240)
	at org.springframework.security.web.ObservationFilterChainDecorator$ObservationFilter.doFilter(ObservationFilterChainDecorator.java:227)
	at org.springframework.security.web.ObservationFilterChainDecorator$VirtualFilterChain.doFilter(ObservationFilterChainDecorator.java:137)
	at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:107)
	at org.springframework.security.web.authentication.logout.LogoutFilter.doFilter(LogoutFilter.java:93)
        ...

archiecobbs avatar Feb 10 '24 17:02 archiecobbs

Two immediate thoughts on this topic:

  1. Vaadin integration for Spring Security is request-based by default, see AccessAnnotationChecker codes:
        return hasAccess(cls, request.getUserPrincipal(),
                request::isUserInRole);
  1. The stack-trace above shows no calls to Vaadin. Thus, open question is how Vaadin can intercept the event listener and how properly wrap or get the session.

mshabarov avatar Feb 13 '24 11:02 mshabarov

Updates on this...

First, when the login event is fired, the VaadinSession doesn't exist yet, and when the logout event is fired, the VaadinSession is already being closed, so what I thought was needed (adjusting idle time) is not really needed. Not sure if this behavior is documented or not but if not it should be.

Secondly, for the more general problem of how to track down a VaadinSession when you're within a HTTP request that has a session associated with it, but outside of the Vaadin servlet, I've come up with this:

import com.google.common.base.Preconditions;
import com.vaadin.flow.server.VaadinSession;
import com.vaadin.flow.server.VaadinSessionState;

import jakarta.servlet.http.HttpServletRequest;

import java.util.Optional;

import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;

public final class VaadinSessionFinder {

    private VaadinSessionFinder() {
    }

    /**
     * Find the {@link VaadinSession} associated with the current HTTP request.
     *
     * <p>
     * The session is found by directly inspecting the current HTTP session, so this will work
     * even if the current thread is not executing within the Vaadin servlet.
     *
     * <p>
     * This method relies on Spring's {@link RequestContextHolder} to locate the current HTTP request.
     *
     * @return the {@link VaadinSession} associated with the current HTTP request, if any
     */
    public static Optional<VaadinSession> find() {

        // Get the current HTTP request
        final HttpServletRequest request = (HttpServletRequest)RequestContextHolder.currentRequestAttributes()
          .resolveReference(RequestAttributes.REFERENCE_REQUEST);

        // Find the VaadinSession in the HTTP session (this logic follows VaadinService.java)
        final String servletName = request.getHttpServletMapping().getServletName();
        final String attributeName = String.format("%s.%s", VaadinSession.class.getName(), servletName);
        return Optional.ofNullable(request.getSession(false))
          .map(session -> session.getAttribute(attributeName))
          .map(VaadinSession.class::cast);
    }

    /**
     * Invoke the given action in the context of the {@link VaadinSession} associated with the current HTTP request.
     *
     * @param action the action to perform
     * @return true if successfully dispatched, false if {@code session} is not in state {@link VaadinSessionState#OPEN}
     * @throws IllegalStateException if there is no current HTTP request or {@link VaadinSession} associated with it
     * @throws IllegalArgumentException if {@code action} is null
     */
    public static boolean access(Runnable action) {
        Preconditions.checkArgument(action != null, "null action");
        final VaadinSession session = VaadinSessionFinder.find()
          .orElseThrow(() -> new IllegalStateException("no VaadinSession found"));
        if (!VaadinSessionState.OPEN.equals(session.getState()))
            return false;
        session.access(action::run);
        return true;
    }
}

archiecobbs avatar Feb 13 '24 20:02 archiecobbs