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?
LoginForm
supports events only for its buttonsThe LoginForm
can register listeners for two kinds of events:
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).
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 "").
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.
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.
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.
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. 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
subclassLastly, 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:
@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. @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.@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();
}
}