Get up to 80 % extra points for free! More info:

Lesson 20 - Java Chat - Server - User Management

In the previous lesson, Java Chat - Client - Server Connection Part 3, we successfully established a connection between the client and the server :)

In today's Java tutorial, we're going to create a simple user management. To keep things simple, we won't use any persistent storage, so all users logged in the chat will be forgotten if the server shuts down.

Auth Plugin

We'll create a new server plugin to take care of logging the user into the chat. Using the term user management is a bit inaccurate in this case, because we won't register users nor manage them anyhow. The user will have to fill in the nickname field when connecting to the server. This nickname will be sent when the connection to the server is established. An Auth plugin will add this nickname to the collection of logged-in users. If the nickname already exists, it'll send a message to the client to change the nickname. Sounds simple, so let's code it.

The Auth Message

We'll start by creating a class representing the Auth message. We'll create the class in the share module in the message package:

package cz.stechy.chat.net.message;

public class AuthMessage implements IMessage {
    private static final long serialVersionUID = 2410714674227462122L;
    public static final String MESSAGE_TYPE = "auth";

    private final AuthAction action;
    private final boolean success;
    private final AuthMessageData data;

    public AuthMessage(AuthAction action, AuthMessageData data) {
        this(action, true, data);
    }

    public AuthMessage(AuthAction action, boolean success, AuthMessageData data) {
        this.action = action;
        this.success = success;
        this.data = data;
    }

    @Override
    public String getType() {
        return MESSAGE_TYPE;
    }

    public AuthAction getAction() {
        return action;
    }

    @Override
    public Object getData() {
        return data;
    }

    @Override
    public boolean isSuccess() {
        return success;
    }

    public enum AuthAction {
        LOGIN,
        LOGOUT
    }

    public static final class AuthMessageData implements Serializable {

        private static final long serialVersionUID = -9036266648628886210L;

        public final String id;
        public final String name;

        public AuthMessageData() {
            this("");
        }

        public AuthMessageData(String name) {
            this("", name);
        }

        public AuthMessageData(String id, String name) {
            this.id = id;
            this.name = name;
        }
    }
}

The message implements the IMessage interface so that it can be sent using our protocol. The AuthAction enumeration contains the type of the action that the message represents. Depending on the type, the message will have different variables initialized. The AuthMessageData class represents the data itself. For the sake of simplicity, only the user's ID and name will be stored. Theoretically, we could also remove the ID, but that'd be too simple.

The Plugin Structure

In the server module we'll create a new auth package in the plugins package to implement the user management. We'll begin with the AuthPlugin class. It's basic structure is available below:

@Singleton
public class AuthPlugin implements IPlugin {
    private static final String PLUGIN_NAME = "auth";
    private void authMessageHandler(IEvent event) {}

    private void clientDisconnectedHandler(IEvent event) {}

    @Override
    public String getName() {
        return PLUGIN_NAME;
    }

    @Override
    public void init() {
        System.out.println("Initializing plugin: " + getName());
    }

    @Override
    public void registerMessageHandlers(IEventBus eventBus) {
        eventBus.registerEventHandler(AuthMessage.MESSAGE_TYPE, this::authMessageHandler);
        eventBus.registerEventHandler(ClientDisconnectedEvent.EVENT_TYPE, this::clientDisconnectedHandler);
    }
}

As we can see, the class implements only the most necessary methods that the IPlugin interface requires. I also registered handlers for AuthMessage and ClientDisconnectedEvent. We'll implement the body of the authMessageHandler() and clientDisconnectedHandler() methods later. Just to be sure, we'll register the plugin in the Plugin enumeration right now by adding the following line:

AUTH(AuthPlugin.class)

Users

On the server, we'll represent the logged-in user with the User class, which we'll create in the auth package:

package cz.stechy.chat.plugins.auth;

public final class User {

    public final String id;
    public final String name;

    public User(String name) {
        this(UUID.randomUUID().toString(), name);
    }

    public User(String id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        User user = (User) o;
        return Objects.equals(id, user.id) && Objects.equals(name, user.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id, name);
    }
}

The user will only have two properties: id and name. I also overrode the equals() and hashCode() class methods so that we can find users in the collection easily in the future.

Login Events

We can say for sure that logging in is a big thing that deserves to generate a new event. So we'll create an event package that will be next to the AuthPlugin class. In this package, we'll create two new classes that will represent the login / logout events of the user.

The LoginEvent class:

package cz.stechy.chat.plugins.auth.event;

public class LoginEvent implements IEvent {

    public static final String EVENT_TYPE = "login";

    public final IClient client;
    public final User user;

    public LoginEvent(IClient client, User user) {
        this.client = client;
        this.user = user;
    }

    @Override
    public String getEventType() {
        return EVENT_TYPE;
    }
}

And the LogoutEvent class:

package cz.stechy.chat.plugins.auth.event;

public class LogoutEvent implements IEvent {

    public static final String EVENT_TYPE = "logout";

    public final User user;

    public LogoutEvent(User user) {
        this.user = user;
    }

    @Override
    public String getEventType() {
        return EVENT_TYPE;
    }
}

Auth Service

All the logic will be described using an IAuthService interface. In the auth package, we'll create a new service package, the IAuthService interface in it and also an AuthService class implementing this interface there. The interface will include a login() method to log in to the server and also a logout() method. In fact there'll be 2 logout methods. We'll call one when receiving a logout message, and the other if the client suddenly crashes.

package cz.stechy.chat.plugins.auth.service;

@ImplementedBy(AuthService.class)
public interface IAuthService {
    Optional<User> login(String username);
    Optional<User> logout(String id);
    Optional<User> logout(IClient client);
}

The login() method accepts a single username parameter and a reference to the client. In practice, we'd also require a password to authenticate users. All methods return Optional typed to the User class. If the Optional is empty, the action failed.

Implementing the Auth Service

We'll put the following code into the AuthService class:

package cz.stechy.chat.plugins.auth.service;

@Singleton
class AuthService implements IAuthService {

    private final Map <IClient, User> users = new HashMap<>();

    @Override
    public Optional<User> login(String username, IClient client) {
        final Optional<User> optionalUser = users.values().stream()
        .filter(user -> Objects.equals(username, user.name))
        .findFirst();

        if (optionalUser.isPresent()) {
            return Optional.empty();
        }

        final User user = new User(username);
        users.put(client, user);
        return Optional.of(user);
    }

    @Override
    public Optional <User> logout(String id) {
        IClient client = null;
        for (Entry <IClient, User> userEntry: users.entrySet()) {
            if (Objects.equals(id, userEntry.getValue().id)) {
            client = userEntry.getKey();
            break;
        }
    }

    if (client != null) {
        return logout(client);
    }

    return Optional.empty();
}

@Override
public Optional <User> logout(IClient client) {
    final User user = users.get(client);
    users.remove(client);

    return Optional.of(user);
}

The class contains a users instance constant with a map of logged-in users. The login() method first determines whether a user already exists with the specified name. If we find such a user, we return an empty result using the empty() method. This indicates that the login failed. If there's no such user, we create a new entry, store it to the user map, and finally return the filled Optional. The logout() method removes the user entry from the logged-in user map. We return this entry packed as an Optional class instance.

Processing Received Auth Messages

Now we'll add the body of the authMessageHandler() and clientDisconnectedHandler() methods in the AuthPlugin class:

private void authMessageHandler(IEvent event) {
    assert event instanceof MessageReceivedEvent;
    final MessageReceivedEvent messageReceivedEvent = (MessageReceivedEvent) event;
    final AuthMessage authMessage = (AuthMessage) messageReceivedEvent.getReceivedMessage();
    final AuthMessageData data = (AuthMessageData) authMessage.getData();

    switch (authMessage.getAction()) {
        case LOGIN:
            final IClient client = messageReceivedEvent.getClient();
            final Optional < User > optionalUser = authService.login(data.name, client);
            final boolean success = optionalUser.isPresent();

            client.sendMessageAsync(authMessage.getResponce(success, success ? optionalUser.get().id : null));
            if (success) {
                eventBus.publishEvent(new LoginEvent(client, optionalUser.get()));
            }
            break;
        case LOGOUT:
            authService.logout(data.id).ifPresent(user -> eventBus.publishEvent(new LogoutEvent(user)));
            break;
        default:
            throw new RuntimeException("Invalid parameter");
    }
}

On the first lines of the method, we "unbox" the received data till we get to the AuthMessageData class instance, which contains the data we need. This is followed by a switch to decide what to do. If the action is a login, we call the login() method on our service and pass it the nickname parameter. The method returns an empty Optional if the user already exists, so the login fails. Otherwise, it sends the user a response informing that the login was successful. The user id is added to the response. If the login succeeds, we publish a new LoginEvent event using the publishEvent() method. This will let the plug-ins registered to the "log in" event know that a new user has logged in. For the logout() action, we call the logout() method and pass the parameter with the user id to be logged out. We generate an event as well so that other plugins can remove any allocated resources for the user who's logged out.

When we receive a "client's connection failed" event, we log out that client from the server and create a new event. This will also make the nickname available gain:

private void clientDisconnectedHandler(IEvent event) {
    final ClientDisconnectedEvent disconnectedEvent = (ClientDisconnectedEvent) event;
    final Client disconnectedClient = disconnectedEvent.getClient();
    authService.logout(disconnectedClient).ifPresent(user -> eventBus.publishEvent(new LogoutEvent(user)));
}

By doing this, we've finished the server part of the user management. Now let's move on to the client.

Client Login

We'll test the login directly in the client. Let's move to the ConnectController to edit the connect() method:

this.communicator.connect(host, port)
    .exceptionally(throwable -> {
        Alert alert = new Alert(AlertType.ERROR);
        alert.setHeaderText("Error");
        alert.setContentText("Unable to connect to the server.");
        alert.showAndWait();

        throw new RuntimeException(throwable);
    })
    .thenCompose(ignored ->
        this.communicator.sendMessageFuture(
        new AuthMessage(AuthAction.LOGIN, new AuthMessageData(username)))
    .thenAcceptAsync(responce -> {
        if (!responce.isSuccess()) {
            Alert alert = new Alert(AlertType.ERROR);
            alert.setHeaderText("Error");
            alert.setContentText("Unable to connect to the server.");
            alert.showAndWait();
            this.communicator.disconnect();
    } else {
        Alert alert = new Alert(AlertType.INFORMATION);
        alert.setHeaderText("Success");
        alert.setContentText("Connection successful.");
        alert.showAndWait();
    }
}, ThreadPool.JAVAFX_EXECUTOR));

¨

In the method, we modified the response to a successful connection. Now, instead of displaying a dialog, we send a message to the server that we want to log the user in. We've already called the thenCompose() method, but just to be sure, let me repeat what happens. This method allows us to call another "future" and return its result. In this way, it's possible to chain calls to more "futures" in a row. After receiving the response, we look whether we were successful or not. In both cases, we display a dialog with the result, whether we have logged in or not. If we haven't, we disconnect from the server.

In the next lesson, Java Chat - Client - Chat Service, we'll start implementing chat functionality :)


 

Previous article
Java Chat - Client - Server Connection Part 3
All articles in this section
Server for Client Applications in Java
Skip article
(not recommended)
Java Chat - Client - Chat Service
Article has been written for you by Petr Štechmüller
Avatar
User rating:
No one has rated this quite yet, be the first one!
Activities