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