Added tons of javadoc, finalized server-side registration protocol.

This commit is contained in:
Andrew Lalis 2021-09-28 23:42:54 +02:00
parent 1bb446ff5b
commit 6b7712a9fb
26 changed files with 326 additions and 105 deletions

View File

@ -1,6 +1,5 @@
package nl.andrewl.concord_client; package nl.andrewl.concord_client;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.googlecode.lanterna.gui2.MultiWindowTextGUI; import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
import com.googlecode.lanterna.gui2.Window; import com.googlecode.lanterna.gui2.Window;
import com.googlecode.lanterna.gui2.WindowBasedTextGUI; import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
@ -9,6 +8,8 @@ import com.googlecode.lanterna.screen.TerminalScreen;
import com.googlecode.lanterna.terminal.DefaultTerminalFactory; import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
import com.googlecode.lanterna.terminal.Terminal; import com.googlecode.lanterna.terminal.Terminal;
import lombok.Getter; import lombok.Getter;
import nl.andrewl.concord_client.data.ClientDataStore;
import nl.andrewl.concord_client.data.JsonClientDataStore;
import nl.andrewl.concord_client.event.EventManager; import nl.andrewl.concord_client.event.EventManager;
import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler; import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler;
import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler; import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler;
@ -19,42 +20,41 @@ import nl.andrewl.concord_client.model.ClientModel;
import nl.andrewl.concord_core.msg.Encryption; import nl.andrewl.concord_core.msg.Encryption;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
import nl.andrewl.concord_core.msg.types.*; import nl.andrewl.concord_core.msg.types.ServerMetaData;
import nl.andrewl.concord_core.msg.types.ServerUsers;
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel; import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
import nl.andrewl.concord_core.msg.types.chat.Chat; import nl.andrewl.concord_core.msg.types.chat.Chat;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse; import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
import nl.andrewl.concord_core.msg.types.client_setup.Identification; import nl.andrewl.concord_core.msg.types.client_setup.*;
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.Socket; import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
public class ConcordClient implements Runnable { public class ConcordClient implements Runnable {
private final Socket socket; private final Socket socket;
private final InputStream in; private final InputStream in;
private final OutputStream out; private final OutputStream out;
private final Serializer serializer; private final Serializer serializer;
private final ClientDataStore dataStore;
@Getter @Getter
private final ClientModel model; private ClientModel model;
private final EventManager eventManager; private final EventManager eventManager;
private volatile boolean running; private volatile boolean running;
public ConcordClient(String host, int port, String nickname, Path tokensFile) throws IOException { private ConcordClient(String host, int port) throws IOException {
this.eventManager = new EventManager(this); this.eventManager = new EventManager(this);
this.socket = new Socket(host, port); this.socket = new Socket(host, port);
this.serializer = new Serializer(); this.serializer = new Serializer();
this.dataStore = new JsonClientDataStore(Path.of("concord-session-tokens.json"));
try { try {
var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer); var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer);
this.in = streams.first(); this.in = streams.first();
@ -62,8 +62,6 @@ public class ConcordClient implements Runnable {
} catch (GeneralSecurityException e) { } catch (GeneralSecurityException e) {
throw new IOException("Could not establish secure connection to the server.", e); throw new IOException("Could not establish secure connection to the server.", e);
} }
this.model = this.initializeConnectionToServer(nickname, tokensFile);
// Add event listeners. // Add event listeners.
this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler()); this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler()); this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler());
@ -72,32 +70,63 @@ public class ConcordClient implements Runnable {
this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler()); this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler());
} }
/** public static ConcordClient register(String host, int port, String username, String password) throws IOException {
* Initializes the communication with the server by sending an {@link Identification} var client = new ConcordClient(host, port);
* message, and waiting for a {@link ServerWelcome} response from the client.sendMessage(new ClientRegistration(null, null, username, password));
* server. After that, we request some information about the channel we were Message reply = client.serializer.readMessage(client.in);
* placed in by the server. if (reply instanceof RegistrationStatus status) {
* @param nickname The nickname to send to the server that it should know if (status.type() == RegistrationStatus.Type.ACCEPTED) {
* us by. ServerWelcome welcomeData = (ServerWelcome) client.serializer.readMessage(client.in);
* @param tokensFile Path to the file where session tokens are stored. client.initializeClientModel(welcomeData, username);
* @return The client model that contains the server's metadata and other } else if (status.type() == RegistrationStatus.Type.PENDING) {
* information that should be kept up-to-date at runtime. System.out.println("Registration pending!");
* @throws IOException If an error occurs while reading or writing the }
* messages, or if the server sends an unexpected response.
*/
private ClientModel initializeConnectionToServer(String nickname, Path tokensFile) throws IOException {
String token = this.getSessionToken(tokensFile);
this.serializer.writeMessage(new Identification(nickname, token), this.out);
Message reply = this.serializer.readMessage(this.in);
if (reply instanceof ServerWelcome welcome) {
var model = new ClientModel(welcome.clientId(), nickname, welcome.currentChannelId(), welcome.currentChannelName(), welcome.metaData());
this.saveSessionToken(welcome.sessionToken(), tokensFile);
// Start fetching initial data for the channel we were initially put into.
this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
return model;
} else { } else {
throw new IOException("Unexpected response from the server after sending identification message: " + reply); System.out.println(reply);
} }
return client;
}
public static ConcordClient login(String host, int port, String username, String password) throws IOException {
var client = new ConcordClient(host, port);
client.sendMessage(new ClientLogin(username, password));
Message reply = client.serializer.readMessage(client.in);
if (reply instanceof ServerWelcome welcome) {
client.initializeClientModel(welcome, username);
} else if (reply instanceof RegistrationStatus status && status.type() == RegistrationStatus.Type.PENDING) {
System.out.println("Registration pending!");
} else {
System.out.println(reply);
}
return client;
}
public static ConcordClient loginWithToken(String host, int port) throws IOException {
var client = new ConcordClient(host, port);
var token = client.dataStore.getSessionToken(client.socket.getInetAddress().getHostName() + ":" + client.socket.getPort());
if (token.isPresent()) {
client.sendMessage(new ClientSessionResume(token.get()));
Message reply = client.serializer.readMessage(client.in);
if (reply instanceof ServerWelcome welcome) {
client.initializeClientModel(welcome, "unknown");
}
} else {
System.err.println("No session token!");
}
return client;
}
private void initializeClientModel(ServerWelcome welcomeData, String username) throws IOException {
var model = new ClientModel(
welcomeData.clientId(),
username,
welcomeData.currentChannelId(),
welcomeData.currentChannelName(),
welcomeData.metaData()
);
this.dataStore.saveSessionToken(this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort(), welcomeData.sessionToken());
// Start fetching initial data for the channel we were initially put into.
this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
} }
public void sendMessage(Message message) throws IOException { public void sendMessage(Message message) throws IOException {
@ -138,46 +167,6 @@ public class ConcordClient implements Runnable {
} }
} }
/**
* Fetches the session token that this client should use for its currently
* configured server, according to the socket address and port.
* @param tokensFile The file containing the session tokens.
* @return The session token, or null if none was found.
* @throws IOException If the tokens file could not be read.
*/
@SuppressWarnings("unchecked")
private String getSessionToken(Path tokensFile) throws IOException {
String token = null;
String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
if (Files.exists(tokensFile)) {
ObjectMapper mapper = new ObjectMapper();
Map<String, String> sessionTokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
token = sessionTokens.get(address);
}
return token;
}
/**
* Saves a session token that this client should use the next time it
* connects to the same server.
* @param token The token to save.
* @param tokensFile The file containing the session tokens.
* @throws IOException If the tokens file could not be read or written to.
*/
@SuppressWarnings("unchecked")
private void saveSessionToken(String token, Path tokensFile) throws IOException {
String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
Map<String, String> tokens = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
if (Files.exists(tokensFile)) {
tokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
}
tokens.put(address, token);
mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(tokensFile), tokens);
}
public static void main(String[] args) throws IOException { public static void main(String[] args) throws IOException {
Terminal term = new DefaultTerminalFactory().createTerminal(); Terminal term = new DefaultTerminalFactory().createTerminal();
Screen screen = new TerminalScreen(term); Screen screen = new TerminalScreen(term);

View File

@ -0,0 +1,13 @@
package nl.andrewl.concord_client.data;
import java.io.IOException;
import java.util.Optional;
/**
* A component which can store and retrieve persistent data which a client can
* use as part of its interaction with servers.
*/
public interface ClientDataStore {
Optional<String> getSessionToken(String serverName) throws IOException;
void saveSessionToken(String serverName, String sessionToken) throws IOException;
}

View File

@ -0,0 +1,42 @@
package nl.andrewl.concord_client.data;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class JsonClientDataStore implements ClientDataStore {
private final Path file;
public JsonClientDataStore(Path file) {
this.file = file;
}
@Override
@SuppressWarnings("unchecked")
public Optional<String> getSessionToken(String serverName) throws IOException {
String token = null;
if (Files.exists(file)) {
ObjectMapper mapper = new ObjectMapper();
Map<String, String> sessionTokens = mapper.readValue(Files.newBufferedReader(file), Map.class);
token = sessionTokens.get(serverName);
}
return Optional.ofNullable(token);
}
@Override
@SuppressWarnings("unchecked")
public void saveSessionToken(String serverName, String sessionToken) throws IOException {
Map<String, String> tokens = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
if (Files.exists(file)) {
tokens = mapper.readValue(Files.newBufferedReader(file), Map.class);
}
tokens.put(serverName, sessionToken);
mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(file), tokens);
}
}

View File

@ -50,7 +50,7 @@ public class MainWindow extends BasicWindow {
if (nickname == null) return; if (nickname == null) return;
try { try {
var client = new ConcordClient(host, port, nickname, Path.of("concord-session-tokens.json")); var client = ConcordClient.login(host, port, nickname, "testpass");
var chatPanel = new ServerPanel(client, this); var chatPanel = new ServerPanel(client, this);
client.getModel().addListener(chatPanel); client.getModel().addListener(chatPanel);
new Thread(client).start(); new Thread(client).start();

View File

@ -12,7 +12,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
@Getter @Getter
public class ClientModel { public class ClientModel {
private UUID id; private final UUID id;
private String nickname; private String nickname;
private ServerMetaData serverMetaData; private ServerMetaData serverMetaData;

View File

@ -1,3 +1,12 @@
/**
* The core components that are used by both the Concord server and the default
* client implementation. Includes record-based message serialization, and some
* utilities for message passing.
* <p>
* This core module defines the message protocol that clients must use to
* communicate with any server.
* </p>
*/
module concord_core { module concord_core {
requires static lombok; requires static lombok;

View File

@ -0,0 +1,7 @@
/**
* Message components which are used by the server and the default client
* implementation. Notably, the {@link nl.andrewl.concord_core.msg.Serializer}
* within this package defines the set of supported message types, and provides
* the highest-level interface to client-server communication.
*/
package nl.andrewl.concord_core.msg;

View File

@ -5,11 +5,10 @@ import nl.andrewl.concord_core.msg.Message;
/** /**
* Error message which can be sent between either the server or client to * Error message which can be sent between either the server or client to
* indicate an unsavory situation. * indicate an unsavory situation.
* @param level The severity level of the error.
* @param message A message indicating what went wrong.
*/ */
public record Error ( public record Error (Level level, String message) implements Message {
Level level,
String message
) implements Message {
/** /**
* The error level gives an indication as to the severity of the error. * The error level gives an indication as to the severity of the error.
* Warnings indicate that a user has attempted to do something which they * Warnings indicate that a user has attempted to do something which they
@ -18,10 +17,20 @@ public record Error (
*/ */
public enum Level {WARNING, ERROR} public enum Level {WARNING, ERROR}
/**
* Creates a warning message.
* @param message The message text.
* @return A warning-level error message.
*/
public static Error warning(String message) { public static Error warning(String message) {
return new Error(Level.WARNING, message); return new Error(Level.WARNING, message);
} }
/**
* Creates an error message.
* @param message The message text.
* @return An error-level error message.
*/
public static Error error(String message) { public static Error error(String message) {
return new Error(Level.ERROR, message); return new Error(Level.ERROR, message);
} }

View File

@ -0,0 +1,4 @@
/**
* Messages pertaining to channel interaction and updates.
*/
package nl.andrewl.concord_core.msg.types.channel;

View File

@ -8,9 +8,7 @@ import java.util.UUID;
/** /**
* This message contains information about a chat message that a user sent. * This message contains information about a chat message that a user sent.
*/ */
public record Chat ( public record Chat (UUID id, UUID senderId, String senderNickname, long timestamp, String message) implements Message {
UUID id, UUID senderId, String senderNickname, long timestamp, String message
) implements Message {
public Chat(UUID senderId, String senderNickname, long timestamp, String message) { public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
this(null, senderId, senderNickname, timestamp, message); this(null, senderId, senderNickname, timestamp, message);
} }

View File

@ -24,6 +24,7 @@ import java.util.stream.Collectors;
* </p> * </p>
* <p> * <p>
* The following query parameters are supported: * The following query parameters are supported:
* </p>
* <ul> * <ul>
* <li><code>count</code> - Fetch up to N messages. Minimum of 1, and * <li><code>count</code> - Fetch up to N messages. Minimum of 1, and
* a server-specific maximum count, usually no higher than 1000.</li> * a server-specific maximum count, usually no higher than 1000.</li>
@ -37,7 +38,6 @@ import java.util.stream.Collectors;
* is present, all others are ignored, and a list containing the single * is present, all others are ignored, and a list containing the single
* message is returned, if it could be found, otherwise an empty list.</li> * message is returned, if it could be found, otherwise an empty list.</li>
* </ul> * </ul>
* </p>
* <p> * <p>
* Responses to this request are sent via {@link ChatHistoryResponse}, where * Responses to this request are sent via {@link ChatHistoryResponse}, where
* the list of messages is always sorted by the timestamp. * the list of messages is always sorted by the timestamp.

View File

@ -7,5 +7,7 @@ import java.util.UUID;
/** /**
* The response that a server sends to a {@link ChatHistoryRequest}. The list of * The response that a server sends to a {@link ChatHistoryRequest}. The list of
* messages is ordered by timestamp, with the newest messages appearing first. * messages is ordered by timestamp, with the newest messages appearing first.
* @param channelId The id of the channel that the chat messages belong to.
* @param messages The list of messages that comprises the history.
*/ */
public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {} public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {}

View File

@ -0,0 +1,5 @@
/**
* Messages pertaining to chat messages and other auxiliary messages regarding
* the management of chat information.
*/
package nl.andrewl.concord_core.msg.types.chat;

View File

@ -5,5 +5,8 @@ import nl.andrewl.concord_core.msg.Message;
/** /**
* This message is sent as the first message from both the server and the client * This message is sent as the first message from both the server and the client
* to establish an end-to-end encryption via a key exchange. * to establish an end-to-end encryption via a key exchange.
* @param iv The initialization vector bytes.
* @param salt The salt bytes.
* @param publicKey The public key.
*/ */
public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {} public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {}

View File

@ -6,10 +6,10 @@ import nl.andrewl.concord_core.msg.Message;
* A response from the server which indicates the current status of the client's * A response from the server which indicates the current status of the client's
* registration request. * registration request.
*/ */
public record RegistrationStatus (Type type) implements Message { public record RegistrationStatus (Type type, String reason) implements Message {
public enum Type {PENDING, ACCEPTED, REJECTED} public enum Type {PENDING, ACCEPTED, REJECTED}
public static RegistrationStatus pending() { public static RegistrationStatus pending() {
return new RegistrationStatus(Type.PENDING); return new RegistrationStatus(Type.PENDING, null);
} }
} }

View File

@ -0,0 +1,4 @@
/**
* Messages pertaining to the establishment of a connection with clients.
*/
package nl.andrewl.concord_core.msg.types.client_setup;

View File

@ -0,0 +1,10 @@
/**
* Contains all the various message types which can be sent between the server
* and client.
* <p>
* <em>Note that not all message types defined here may be supported by the
* latest version of Concord. See {@link nl.andrewl.concord_core.msg.Serializer}
* for the definitive list.</em>
* </p>
*/
package nl.andrewl.concord_core.msg.types;

View File

@ -1,3 +1,8 @@
package nl.andrewl.concord_core.util; package nl.andrewl.concord_core.util;
/**
* Simple generic pair of two objects.
* @param <A> The first object.
* @param <B> The second object.
*/
public record Pair<A, B>(A first, B second) {} public record Pair<A, B>(A first, B second) {}

View File

@ -1,3 +1,9 @@
package nl.andrewl.concord_core.util; package nl.andrewl.concord_core.util;
/**
* Simple generic triple of objects.
* @param <A> The first object.
* @param <B> The second object.
* @param <C> The third object.
*/
public record Triple<A, B, C> (A first, B second, C third) {} public record Triple<A, B, C> (A first, B second, C third) {}

View File

@ -0,0 +1,5 @@
/**
* Contains some useful one-off utility classes that any consumer of Concord
* messages could benefit from.
*/
package nl.andrewl.concord_core.util;

19
pom.xml
View File

@ -16,9 +16,10 @@
</modules> </modules>
<properties> <properties>
<maven.compiler.source>16</maven.compiler.source> <maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>16</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<java.version>16</java.version> <maven.compiler.release>17</maven.compiler.release>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties> </properties>
@ -47,6 +48,18 @@
</annotationProcessorPaths> </annotationProcessorPaths>
</configuration> </configuration>
</plugin> </plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<detectLinks>false</detectLinks>
<detectOfflineLinks>false</detectOfflineLinks>
<failOnError>false</failOnError>
<failOnWarnings>false</failOnWarnings>
<show>private</show>
</configuration>
</plugin>
</plugins> </plugins>
</build> </build>
</project> </project>

View File

@ -124,6 +124,9 @@ public class ConcordServer implements Runnable {
} }
} }
/**
* @return The server's metadata.
*/
public ServerMetaData getMetaData() { public ServerMetaData getMetaData() {
return new ServerMetaData( return new ServerMetaData(
this.config.getName(), this.config.getName(),

View File

@ -33,7 +33,7 @@ public class Channel implements Comparable<Channel> {
/** /**
* A document collection which holds all messages created in this channel, * A document collection which holds all messages created in this channel,
* indexed on id, timestamp, message, and sender's nickname. * indexed on id, timestamp, message, and sender's username.
*/ */
private final NitriteCollection messageCollection; private final NitriteCollection messageCollection;

View File

@ -24,8 +24,6 @@ import java.util.UUID;
* and logging in. * and logging in.
*/ */
public class AuthenticationService { public class AuthenticationService {
public static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {}
private final NitriteCollection userCollection; private final NitriteCollection userCollection;
private final NitriteCollection sessionTokenCollection; private final NitriteCollection sessionTokenCollection;
private final ConcordServer server; private final ConcordServer server;

View File

@ -0,0 +1,15 @@
package nl.andrewl.concord_server.client;
import java.util.UUID;
/**
* Some common data that's used when dealing with a client who has just joined
* the server.
* @param id The user's unique id.
* @param username The user's unique username.
* @param sessionToken The user's new session token that can be used the next
* time they want to log in.
* @param newClient True if this client is connecting for the first time, or
* false otherwise.
*/
public record ClientConnectionData(UUID id, String username, String sessionToken, boolean newClient) {}

View File

@ -27,9 +27,12 @@ public class ClientManager {
private final Map<UUID, ClientThread> clients; private final Map<UUID, ClientThread> clients;
private final Map<UUID, ClientThread> pendingClients; private final Map<UUID, ClientThread> pendingClients;
private final NitriteCollection userCollection; private final NitriteCollection userCollection;
private final AuthenticationService authService; private final AuthenticationService authService;
/**
* Constructs a new client manager for the given server.
* @param server The server that the client manager is for.
*/
public ClientManager(ConcordServer server) { public ClientManager(ConcordServer server) {
this.server = server; this.server = server;
this.clients = new ConcurrentHashMap<>(); this.clients = new ConcurrentHashMap<>();
@ -45,12 +48,28 @@ public class ClientManager {
server.getScheduledExecutorService().scheduleAtFixedRate(this.authService::removeExpiredSessionTokens, 1, 1, TimeUnit.DAYS); server.getScheduledExecutorService().scheduleAtFixedRate(this.authService::removeExpiredSessionTokens, 1, 1, TimeUnit.DAYS);
} }
/**
* Handles an attempt by a new client to register as a user for this server.
* If the server is set to automatically accept all new clients, the new
* user is registered and the client is sent a {@link RegistrationStatus}
* with the {@link RegistrationStatus.Type#ACCEPTED} value, closely followed
* by a {@link ServerWelcome} message. Otherwise, the client is sent a
* {@link RegistrationStatus.Type#PENDING} response, which indicates that
* the client's registration is pending approval. The client can choose to
* remain connected and wait for approval, or disconnect and try logging in
* later.
*
* @param registration The client's registration information.
* @param clientThread The client thread.
* @throws InvalidIdentificationException If the user's registration info is
* not valid.
*/
public void handleRegistration(ClientRegistration registration, ClientThread clientThread) throws InvalidIdentificationException { public void handleRegistration(ClientRegistration registration, ClientThread clientThread) throws InvalidIdentificationException {
Document userDoc = this.userCollection.find(Filters.eq("username", registration.username())).firstOrDefault(); Document userDoc = this.userCollection.find(Filters.eq("username", registration.username())).firstOrDefault();
if (userDoc != null) throw new InvalidIdentificationException("Username is taken."); if (userDoc != null) throw new InvalidIdentificationException("Username is taken.");
if (this.server.getConfig().isAcceptAllNewClients()) { if (this.server.getConfig().isAcceptAllNewClients()) {
var clientData = this.authService.registerNewClient(registration); var clientData = this.authService.registerNewClient(registration);
clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED)); clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED, null));
this.initializeClientConnection(clientData, clientThread); this.initializeClientConnection(clientData, clientThread);
} else { } else {
var clientId = this.authService.registerPendingClient(registration); var clientId = this.authService.registerPendingClient(registration);
@ -58,6 +77,23 @@ public class ClientManager {
} }
} }
/**
* Handles an attempt by a new client to login as an existing user to the
* server. If the user's credentials are valid, then the following can
* result:
* <ul>
* <li>If the user's registration is still pending, they will be sent a
* {@link RegistrationStatus.Type#PENDING} response, to indicate that
* their registration is still pending approval.</li>
* <li>For non-pending (normal) users, they will be logged into the
* server and sent a {@link ServerWelcome} message.</li>
* </ul>
*
* @param login The client's login credentials.
* @param clientThread The client thread managing the connection.
* @throws InvalidIdentificationException If the client's credentials are
* incorrect.
*/
public void handleLogin(ClientLogin login, ClientThread clientThread) throws InvalidIdentificationException { public void handleLogin(ClientLogin login, ClientThread clientThread) throws InvalidIdentificationException {
Document userDoc = this.authService.findAndAuthenticateUser(login); Document userDoc = this.authService.findAndAuthenticateUser(login);
if (userDoc == null) throw new InvalidIdentificationException("Username or password is incorrect."); if (userDoc == null) throw new InvalidIdentificationException("Username or password is incorrect.");
@ -68,20 +104,38 @@ public class ClientManager {
this.initializePendingClientConnection(userId, username, clientThread); this.initializePendingClientConnection(userId, username, clientThread);
} else { } else {
String sessionToken = this.authService.generateSessionToken(userId); String sessionToken = this.authService.generateSessionToken(userId);
this.initializeClientConnection(new AuthenticationService.ClientConnectionData(userId, username, sessionToken, false), clientThread); this.initializeClientConnection(new ClientConnectionData(userId, username, sessionToken, false), clientThread);
} }
} }
/**
* Handles an attempt by a new client to login as an existing user to the
* server with a session token from their previous session. If the token is
* valid, the user will be logged in and sent a {@link ServerWelcome}
* response.
*
* @param sessionResume The session token data.
* @param clientThread The client thread managing the connection.
* @throws InvalidIdentificationException If the token is invalid or refers
* to a non-existent user.
*/
public void handleSessionResume(ClientSessionResume sessionResume, ClientThread clientThread) throws InvalidIdentificationException { public void handleSessionResume(ClientSessionResume sessionResume, ClientThread clientThread) throws InvalidIdentificationException {
Document userDoc = this.authService.findAndAuthenticateUser(sessionResume); Document userDoc = this.authService.findAndAuthenticateUser(sessionResume);
if (userDoc == null) throw new InvalidIdentificationException("Invalid session. Log in to obtain a new session token."); if (userDoc == null) throw new InvalidIdentificationException("Invalid session. Log in to obtain a new session token.");
UUID userId = userDoc.get("id", UUID.class); UUID userId = userDoc.get("id", UUID.class);
String username = userDoc.get("username", String.class); String username = userDoc.get("username", String.class);
String sessionToken = this.authService.generateSessionToken(userId); String sessionToken = this.authService.generateSessionToken(userId);
this.initializeClientConnection(new AuthenticationService.ClientConnectionData(userId, username, sessionToken, false), clientThread); this.initializeClientConnection(new ClientConnectionData(userId, username, sessionToken, false), clientThread);
} }
public void decidePendingUser(UUID userId, boolean accepted) { /**
* Used to accept or reject a pending user's registration. If the given user
* is not pending approval, this method does nothing.
* @param userId The id of the pending user.
* @param accepted Whether to accept or reject.
* @param reason The reason for rejection (or acceptance). This may be null.
*/
public void decidePendingUser(UUID userId, boolean accepted, String reason) {
Document userDoc = this.userCollection.find(Filters.and(Filters.eq("id", userId), Filters.eq("pending", true))).firstOrDefault(); Document userDoc = this.userCollection.find(Filters.and(Filters.eq("id", userId), Filters.eq("pending", true))).firstOrDefault();
if (userDoc != null) { if (userDoc != null) {
if (accepted) { if (accepted) {
@ -90,16 +144,16 @@ public class ClientManager {
// If the pending user is still connected, upgrade them to a normal connected client. // If the pending user is still connected, upgrade them to a normal connected client.
var clientThread = this.pendingClients.remove(userId); var clientThread = this.pendingClients.remove(userId);
if (clientThread != null) { if (clientThread != null) {
clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED)); clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED, reason));
String username = userDoc.get("username", String.class); String username = userDoc.get("username", String.class);
String sessionToken = this.authService.generateSessionToken(userId); String sessionToken = this.authService.generateSessionToken(userId);
this.initializeClientConnection(new AuthenticationService.ClientConnectionData(userId, username, sessionToken, true), clientThread); this.initializeClientConnection(new ClientConnectionData(userId, username, sessionToken, true), clientThread);
} }
} else { } else {
this.userCollection.remove(userDoc); this.userCollection.remove(userDoc);
var clientThread = this.pendingClients.remove(userId); var clientThread = this.pendingClients.remove(userId);
if (clientThread != null) { if (clientThread != null) {
clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.REJECTED)); clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.REJECTED, reason));
} }
} }
} }
@ -111,22 +165,30 @@ public class ClientManager {
* @param clientData The data about the client that has connected. * @param clientData The data about the client that has connected.
* @param clientThread The thread managing the client's connection. * @param clientThread The thread managing the client's connection.
*/ */
private void initializeClientConnection(AuthenticationService.ClientConnectionData clientData, ClientThread clientThread) { private void initializeClientConnection(ClientConnectionData clientData, ClientThread clientThread) {
this.clients.put(clientData.id(), clientThread);
clientThread.setClientId(clientData.id()); clientThread.setClientId(clientData.id());
clientThread.setClientNickname(clientData.nickname()); clientThread.setClientNickname(clientData.username());
var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow(); var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow();
clientThread.sendToClient(new ServerWelcome(clientData.id(), clientData.sessionToken(), defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData())); clientThread.sendToClient(new ServerWelcome(clientData.id(), clientData.sessionToken(), defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData()));
this.clients.put(clientData.id(), clientThread); // We only add the client after sending the welcome, to make sure that we send the welcome packet first.
defaultChannel.addClient(clientThread); defaultChannel.addClient(clientThread);
clientThread.setCurrentChannel(defaultChannel); clientThread.setCurrentChannel(defaultChannel);
this.broadcast(new ServerUsers(this.getConnectedClients().toArray(new UserData[0]))); this.broadcast(new ServerUsers(this.getConnectedClients().toArray(new UserData[0])));
} }
/**
* Initializes a connection to a client whose registration is pending, thus
* they should simply keep their connection alive, and receive a {@link RegistrationStatus.Type#PENDING}
* message, instead of a {@link ServerWelcome}.
* @param clientId The id of the client.
* @param pendingUsername The client's username.
* @param clientThread The thread managing the client's connection.
*/
private void initializePendingClientConnection(UUID clientId, String pendingUsername, ClientThread clientThread) { private void initializePendingClientConnection(UUID clientId, String pendingUsername, ClientThread clientThread) {
this.pendingClients.put(clientId, clientThread);
clientThread.setClientId(clientId); clientThread.setClientId(clientId);
clientThread.setClientNickname(pendingUsername); clientThread.setClientNickname(pendingUsername);
clientThread.sendToClient(RegistrationStatus.pending()); clientThread.sendToClient(RegistrationStatus.pending());
this.pendingClients.put(clientId, clientThread);
} }
/** /**
@ -166,6 +228,9 @@ public class ClientManager {
} }
} }
/**
* @return The list of connected clients.
*/
public List<UserData> getConnectedClients() { public List<UserData> getConnectedClients() {
return this.clients.values().stream() return this.clients.values().stream()
.sorted(Comparator.comparing(ClientThread::getClientNickname)) .sorted(Comparator.comparing(ClientThread::getClientNickname))
@ -173,6 +238,9 @@ public class ClientManager {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* @return The list of connected, pending clients.
*/
public List<UserData> getPendingClients() { public List<UserData> getPendingClients() {
return this.pendingClients.values().stream() return this.pendingClients.values().stream()
.sorted(Comparator.comparing(ClientThread::getClientNickname)) .sorted(Comparator.comparing(ClientThread::getClientNickname))
@ -180,14 +248,27 @@ public class ClientManager {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* @return The set of ids of all connected clients.
*/
public Set<UUID> getConnectedIds() { public Set<UUID> getConnectedIds() {
return this.clients.keySet(); return this.clients.keySet();
} }
/**
* Tries to find a connected client with the given id.
* @param id The id to look for.
* @return An optional client thread.
*/
public Optional<ClientThread> getClientById(UUID id) { public Optional<ClientThread> getClientById(UUID id) {
return Optional.ofNullable(this.clients.get(id)); return Optional.ofNullable(this.clients.get(id));
} }
/**
* Tries to find a pending client with the given id.
* @param id The id to look for.
* @return An optional client thread.
*/
public Optional<ClientThread> getPendingClientById(UUID id) { public Optional<ClientThread> getPendingClientById(UUID id) {
return Optional.ofNullable(this.pendingClients.get(id)); return Optional.ofNullable(this.pendingClients.get(id));
} }