Search code examples
authenticationvaadinevent-listenervaadin-flow

Vaadin LoginForm - signaling when user passed or failed authentication


I understand that in using the Login component of Vaadin 14, I must call addLoginListener to register a listener of my own that implements ComponentEventListener<AbstractLogin.LoginEvent>. In my implementing code, I can call the LoginEvent::getUsername and LoginEvent::getPassword methods to obtain the text values entered by the user. My listener code then determines if these credentials are correct.

➥ By what mechanism does my listener code communicate back to my LoginForm the results of the authentication check?

If the authentication check succeeded, I need my LoginForm to close and let navigation continue on to the intended route. If the authentication failed, I need the LoginForm to inform the user about the failure, and ask user to re-enter username and password. How can my listener code tell the LoginForm what to do next?


Solution

  • LoginForm supports events only for its buttons

    The LoginForm can register listeners for two kinds of events:

    • User clicking the "Log in" button
    • User clicking the "Forgot Password" button
      (which may or may not be displayed)

    Neither of those is useful for us in detecting if the user successfully completed their login attempt. Whatever method that does register for the "Log in" button click will be deciding the user's success or failure. It is that method which needs to signal the successful login attempt.

    As a Component, the LoginForm has built-in support for registering listeners for other types of events. Unfortunately, that support has its scope restricted to protected. So that event-listener support cannot extend to our web-app classes, as they are not part of the Vaadin packages (out of scope).

    Not using Routing

    I tried and failed to use the Routing feature of Vaadin Flow. There seem to be some serious issues with routing behavior in Vaadin 14.0.2. Add on my ignorance of Routing, and it became a no-go for me. Instead, I am managing all the content from within my MainView using a single URL (root "").

    Subclass LoginForm

    I do not know if this is the best approach, but I chose to make a subclass of LoginForm called AuthenticateView.

    ➥ On that subclass I added a nested interface AuthenticationPassedObserver defining a single method authenticationPassed. This is my attempt at a callback, to inform my MainView of the success of the user's attempt to login. This is the specific solution to the Question of how to signal when the user passed authentication: Make the calling layout implement an interface defined on the LoginForm subclass, with a single method to be called after the user succeeds in their login attempt.

    Failure is not an option

    Notice that we do not care about a failure. We simply leave the LoginForm subclass displayed as our MainView content for as long as it takes until the user either succeeds or closes the web browser window/tab, thereby terminating the session. If you are worried about hackers trying endlessly to login, you may want your subclass of LoginForm to keep track of repeated attempts and react accordingly. But the nature of a Vaadin web app makes such an attack less likely.

    Callback

    Here is that nested interface for the synchronous callback.

        interface AuthenticationPassedObserver
        {
            void authenticationPassed ( );
        }
    

    In addition, I defined an interface Authenticator that does the work of deciding if the username & password credentials are valid.

    package work.basil.ticktock.backend.auth;
    
    import java.util.Optional;
    
    public interface Authenticator
    {
        public Optional <User> authenticate( String username , String password ) ;
    
        public void rememberUser ( User user );  // Collecting.
        public void forgetUser (  );  // Dropping user, if any, terminating their status as  authenticated.
        public boolean userIsAuthenticated () ;  // Retrieving.
        public Optional<User> fetchUser () ;  // Retrieving.
    
    }
    

    For now I have an abstract implementation of that interface to handle the chore of storing an object of the User class I defined to represent each individual's login.

    package work.basil.ticktock.backend.auth;
    
    import com.vaadin.flow.server.VaadinSession;
    
    import java.util.Objects;
    import java.util.Optional;
    
    public abstract class AuthenticatorAbstract implements Authenticator
    {
        @Override
        public void rememberUser ( User user ) {
            Objects.requireNonNull( user );
            VaadinSession.getCurrent().setAttribute( User.class, user  ) ;
        }
    
        @Override
        public void forgetUser() {
            VaadinSession.getCurrent().setAttribute( User.class, null  ) ; // Passing NULL clears the stored value.
        }
    
        @Override
        public boolean userIsAuthenticated ( )
        {
            Optional<User> optionalUser = this.fetchUser();
            return optionalUser.isPresent() ;
        }
    
        @Override
        public Optional <User> fetchUser ()
        {
    
            Object value = VaadinSession.getCurrent().getAttribute( User.class ); // Lookup into key-value store.
            return Optional.ofNullable( ( User ) value );
        }
    
    }
    

    Perhaps I will move those methods to be default on the interface. But good enough for now.

    I wrote some implementations. A couple were for initial design: one that always accepts the credentials without examination, and another that always rejects the credentials without examination. Another implementation is the real one, doing a look up in a database of users.

    The authenticator returns an Optional< User >, meaning the optional is empty if the credentials failed, and containing a User object if the credentials passed.

    Here is always-flunks:

    package work.basil.ticktock.backend.auth;
    
    import work.basil.ticktock.ui.AuthenticateView;
    
    import java.util.Optional;
    
    final public class AuthenticatorAlwaysFlunks  extends AuthenticatorAbstract
    {
        public Optional <User> authenticate( String username , String password ) {
            User user = null ;
            return Optional.ofNullable ( user  );
        }
    }
    

    …and always passes:

    package work.basil.ticktock.backend.auth;
    
    import java.util.Optional;
    import java.util.UUID;
    
    public class AuthenticatorAlwaysPasses extends AuthenticatorAbstract
    {
        public Optional <User> authenticate( String username , String password ) {
            User user = new User( UUID.randomUUID() , "username", "Bo" , "Gus");
            this.rememberUser( user );
            return Optional.ofNullable ( user  );
        }
    }
    

    By the way, I am not trying to be fancy here with my use of a callback. A couple pressure-points led me to this design.

    • I did want to keep the LoginView subclass ignorant of who is calling it. For one thing, I might some day get routing working, or implement some navigator framework, and then the larger context of who is display the LoginForm subclass may change. So I did not want to hardcode a call to method back on the MainView object that spawned this LoginForm subclass object.
    • I would have preferred to the simpler approach of passing a method reference to the constructor of my LoginForm subclass. Then I could omit the entire define-a-nested-interface thing. Something like this: AuthenticateView authView = new AuthenticateView( this::authenticationPassed , authenticator );. But I am not savvy enough with lambdas to know how to define my own method-reference argument.

    MainView - a controller for displaying and responding to LoginView subclass

    Lastly, here is my early experimental attempt at modifying the MainView class given to me by the Vaadin Starter project.

    Notice how MainView implements the nested callback interface AuthenticateView.AuthenticationPassedObserver. When the user successfully completes a login, the authenticationPassed method here on MainView is invoked. That method clears the LoginForm subclass from content display of MainView, and installs the regular app content for which the current user is authorized to view.

    Also note the annotations:

    • The @PreserveOnRefresh new to Vaadin Flow in version 14 keeps content alive despite the user clicking the browser Refresh button. That may help with the login process, thought I've not thought it through.
    • Also, note the @PWA. I do not currently need the progressive web app features. But removing that annotation in a Vaadin 14.0.2 seems to cause even more spurious replacements of the UI object that contains our web browser window/tab contents, so I am leaving that line in place.
    • While I am not making use of the Routing feature in Flow, I do not know how to disable that feature. So I leave the @Route ( "" ) in place.

    package work.basil.ticktock.ui;
    
    import com.vaadin.flow.component.orderedlayout.FlexComponent;
    import com.vaadin.flow.component.orderedlayout.VerticalLayout;
    import com.vaadin.flow.router.PageTitle;
    import com.vaadin.flow.router.PreserveOnRefresh;
    import com.vaadin.flow.router.Route;
    import com.vaadin.flow.server.PWA;
    import work.basil.ticktock.backend.auth.Authenticator;
    import work.basil.ticktock.backend.auth.AuthenticatorAlwaysPasses;
    
    import java.time.Instant;
    
    /**
     * The main view of the web app.
     */
    @PageTitle ( "TickTock" )
    @PreserveOnRefresh
    @Route ( "" )
    @PWA ( name = "Project Base for Vaadin", shortName = "Project Base" )
    public class MainView extends VerticalLayout implements AuthenticateView.AuthenticationPassedObserver
    {
    
        // Constructor
        public MainView ( )
        {
            System.out.println( "BASIL - MainView constructor. " + Instant.now() );
            this.display();
        }
    
        protected void display ( )
        {
            System.out.println( "BASIL - MainView::display. " + Instant.now() );
            this.removeAll();
    
            // If user is authenticated already, display initial view.
            Authenticator authenticator = new AuthenticatorAlwaysPasses();
            if ( authenticator.userIsAuthenticated() )
            {
                this.displayContentPanel();
            } else
            { // Else user is not yet authenticated, so prompt user for login.
                this.displayAuthenticatePanel(authenticator);
            }
        }
    
        private void displayContentPanel ( )
        {
            System.out.println( "BASIL - MainView::displayContentPanel. " + Instant.now() );
            // Widgets.
            ChronListingView view = new ChronListingView();
    
            // Arrange.
            this.removeAll();
            this.add( view );
        }
    
        private void displayAuthenticatePanel ( Authenticator authenticator )
        {
            System.out.println( "BASIL - MainView::displayAuthenticatePanel. " + Instant.now() );
            // Widgets
            AuthenticateView authView = new AuthenticateView(this, authenticator);
    
            // Arrange.
    //        this.getStyle().set( "border" , "6px dotted DarkOrange" );  // DEBUG - Visually display the  bounds of this layout.
            this.getStyle().set( "background-color" , "LightSteelBlue" );
            this.setSizeFull();
            this.setJustifyContentMode( FlexComponent.JustifyContentMode.CENTER ); // Put content in the middle horizontally.
            this.setDefaultHorizontalComponentAlignment( FlexComponent.Alignment.CENTER ); // Put content in the middle vertically.
    
            this.removeAll();
            this.add( authView );
        }
    
        // Implements AuthenticateView.AuthenticationPassedObserver
        @Override
        public void authenticationPassed ( )
        {
            System.out.println( "BASIL - MainView::authenticationPassed. " + Instant.now() );
            this.display();
        }
    }