diff --git a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java index 75720bb..15604c8 100644 --- a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java +++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java @@ -1,6 +1,5 @@ package nl.andrewl.concord_client; -import com.fasterxml.jackson.databind.ObjectMapper; import com.googlecode.lanterna.gui2.MultiWindowTextGUI; import com.googlecode.lanterna.gui2.Window; 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.Terminal; 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.handlers.ChannelMovedHandler; 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.Message; 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.chat.Chat; 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.client_setup.Identification; -import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome; +import nl.andrewl.concord_core.msg.types.client_setup.*; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; -import java.nio.file.Files; import java.nio.file.Path; import java.security.GeneralSecurityException; -import java.util.HashMap; import java.util.List; -import java.util.Map; public class ConcordClient implements Runnable { private final Socket socket; private final InputStream in; private final OutputStream out; private final Serializer serializer; + private final ClientDataStore dataStore; @Getter - private final ClientModel model; + private ClientModel model; private final EventManager eventManager; 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.socket = new Socket(host, port); this.serializer = new Serializer(); + this.dataStore = new JsonClientDataStore(Path.of("concord-session-tokens.json")); try { var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer); this.in = streams.first(); @@ -62,8 +62,6 @@ public class ConcordClient implements Runnable { } catch (GeneralSecurityException e) { throw new IOException("Could not establish secure connection to the server.", e); } - this.model = this.initializeConnectionToServer(nickname, tokensFile); - // Add event listeners. this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler()); this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler()); @@ -72,32 +70,63 @@ public class ConcordClient implements Runnable { this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler()); } - /** - * Initializes the communication with the server by sending an {@link Identification} - * message, and waiting for a {@link ServerWelcome} response from the - * server. After that, we request some information about the channel we were - * placed in by the server. - * @param nickname The nickname to send to the server that it should know - * us by. - * @param tokensFile Path to the file where session tokens are stored. - * @return The client model that contains the server's metadata and other - * information that should be kept up-to-date at runtime. - * @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; + public static ConcordClient register(String host, int port, String username, String password) throws IOException { + var client = new ConcordClient(host, port); + client.sendMessage(new ClientRegistration(null, null, username, password)); + Message reply = client.serializer.readMessage(client.in); + if (reply instanceof RegistrationStatus status) { + if (status.type() == RegistrationStatus.Type.ACCEPTED) { + ServerWelcome welcomeData = (ServerWelcome) client.serializer.readMessage(client.in); + client.initializeClientModel(welcomeData, username); + } else if (status.type() == RegistrationStatus.Type.PENDING) { + System.out.println("Registration pending!"); + } } 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 { @@ -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 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 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 { Terminal term = new DefaultTerminalFactory().createTerminal(); Screen screen = new TerminalScreen(term); diff --git a/client/src/main/java/nl/andrewl/concord_client/data/ClientDataStore.java b/client/src/main/java/nl/andrewl/concord_client/data/ClientDataStore.java new file mode 100644 index 0000000..cc85b7f --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/data/ClientDataStore.java @@ -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 getSessionToken(String serverName) throws IOException; + void saveSessionToken(String serverName, String sessionToken) throws IOException; +} diff --git a/client/src/main/java/nl/andrewl/concord_client/data/JsonClientDataStore.java b/client/src/main/java/nl/andrewl/concord_client/data/JsonClientDataStore.java new file mode 100644 index 0000000..3ac5796 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/data/JsonClientDataStore.java @@ -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 getSessionToken(String serverName) throws IOException { + String token = null; + if (Files.exists(file)) { + ObjectMapper mapper = new ObjectMapper(); + Map 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 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); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java index 67a3a9f..c0dddc0 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java @@ -50,7 +50,7 @@ public class MainWindow extends BasicWindow { if (nickname == null) return; 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); client.getModel().addListener(chatPanel); new Thread(client).start(); diff --git a/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java b/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java index bbba707..fe9dd25 100644 --- a/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java +++ b/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java @@ -12,7 +12,7 @@ import java.util.concurrent.CopyOnWriteArrayList; @Getter public class ClientModel { - private UUID id; + private final UUID id; private String nickname; private ServerMetaData serverMetaData; diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java index cbbf0fa..1fe7dc8 100644 --- a/core/src/main/java/module-info.java +++ b/core/src/main/java/module-info.java @@ -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. + *

+ * This core module defines the message protocol that clients must use to + * communicate with any server. + *

+ */ module concord_core { requires static lombok; diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/package-info.java b/core/src/main/java/nl/andrewl/concord_core/msg/package-info.java new file mode 100644 index 0000000..15af159 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/package-info.java @@ -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; \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Error.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Error.java index 6331bb4..1605114 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Error.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Error.java @@ -5,11 +5,10 @@ import nl.andrewl.concord_core.msg.Message; /** * Error message which can be sent between either the server or client to * indicate an unsavory situation. + * @param level The severity level of the error. + * @param message A message indicating what went wrong. */ -public record Error ( - Level level, - String message -) implements Message { +public record Error (Level level, String message) implements Message { /** * 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 @@ -18,10 +17,20 @@ public record 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) { 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) { return new Error(Level.ERROR, message); } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/channel/package-info.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/channel/package-info.java new file mode 100644 index 0000000..071fc18 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/channel/package-info.java @@ -0,0 +1,4 @@ +/** + * Messages pertaining to channel interaction and updates. + */ +package nl.andrewl.concord_core.msg.types.channel; \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/Chat.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/Chat.java index f094ef7..cf172af 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/Chat.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/Chat.java @@ -8,9 +8,7 @@ import java.util.UUID; /** * This message contains information about a chat message that a user sent. */ -public record Chat ( - UUID id, UUID senderId, String senderNickname, long timestamp, String message -) implements Message { +public record Chat (UUID id, UUID senderId, String senderNickname, long timestamp, String message) implements Message { public Chat(UUID senderId, String senderNickname, long timestamp, String message) { this(null, senderId, senderNickname, timestamp, message); } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryRequest.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryRequest.java index 725b01b..b2db93a 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryRequest.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryRequest.java @@ -24,6 +24,7 @@ import java.util.stream.Collectors; *

*

* The following query parameters are supported: + *

*
    *
  • count - Fetch up to N messages. Minimum of 1, and * a server-specific maximum count, usually no higher than 1000.
  • @@ -37,7 +38,6 @@ import java.util.stream.Collectors; * is present, all others are ignored, and a list containing the single * message is returned, if it could be found, otherwise an empty list. *
- *

*

* Responses to this request are sent via {@link ChatHistoryResponse}, where * the list of messages is always sorted by the timestamp. diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryResponse.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryResponse.java index 4743bfa..629d856 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryResponse.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/ChatHistoryResponse.java @@ -7,5 +7,7 @@ import java.util.UUID; /** * The response that a server sends to a {@link ChatHistoryRequest}. The list of * 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 {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/package-info.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/package-info.java new file mode 100644 index 0000000..da1115c --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/chat/package-info.java @@ -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; \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/KeyData.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/KeyData.java index f46d18f..feca228 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/KeyData.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/KeyData.java @@ -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 * 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 {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java index 45d8159..43b951d 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java @@ -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 * 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 static RegistrationStatus pending() { - return new RegistrationStatus(Type.PENDING); + return new RegistrationStatus(Type.PENDING, null); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/package-info.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/package-info.java new file mode 100644 index 0000000..f23e1e6 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/package-info.java @@ -0,0 +1,4 @@ +/** + * Messages pertaining to the establishment of a connection with clients. + */ +package nl.andrewl.concord_core.msg.types.client_setup; \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/package-info.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/package-info.java new file mode 100644 index 0000000..f060e4f --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/package-info.java @@ -0,0 +1,10 @@ +/** + * Contains all the various message types which can be sent between the server + * and client. + *

+ * 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. + *

+ */ +package nl.andrewl.concord_core.msg.types; \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/concord_core/util/Pair.java b/core/src/main/java/nl/andrewl/concord_core/util/Pair.java index e3a14d7..4167ab2 100644 --- a/core/src/main/java/nl/andrewl/concord_core/util/Pair.java +++ b/core/src/main/java/nl/andrewl/concord_core/util/Pair.java @@ -1,3 +1,8 @@ package nl.andrewl.concord_core.util; +/** + * Simple generic pair of two objects. + * @param The first object. + * @param The second object. + */ public record Pair(A first, B second) {} diff --git a/core/src/main/java/nl/andrewl/concord_core/util/Triple.java b/core/src/main/java/nl/andrewl/concord_core/util/Triple.java index 28bed57..6cca48e 100644 --- a/core/src/main/java/nl/andrewl/concord_core/util/Triple.java +++ b/core/src/main/java/nl/andrewl/concord_core/util/Triple.java @@ -1,3 +1,9 @@ package nl.andrewl.concord_core.util; +/** + * Simple generic triple of objects. + * @param The first object. + * @param The second object. + * @param The third object. + */ public record Triple (A first, B second, C third) {} diff --git a/core/src/main/java/nl/andrewl/concord_core/util/package-info.java b/core/src/main/java/nl/andrewl/concord_core/util/package-info.java new file mode 100644 index 0000000..4fd7cd4 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/util/package-info.java @@ -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; \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8e65f56..16c95bd 100644 --- a/pom.xml +++ b/pom.xml @@ -16,9 +16,10 @@ - 16 - 16 - 16 + 17 + 17 + 17 + 17 UTF-8 @@ -47,6 +48,18 @@ + + org.apache.maven.plugins + maven-javadoc-plugin + 3.3.1 + + false + false + false + false + private + + \ No newline at end of file diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java index 6b8756a..ebb67c2 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -124,6 +124,9 @@ public class ConcordServer implements Runnable { } } + /** + * @return The server's metadata. + */ public ServerMetaData getMetaData() { return new ServerMetaData( this.config.getName(), diff --git a/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java b/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java index d46ffab..7bf4a26 100644 --- a/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java +++ b/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java @@ -33,7 +33,7 @@ public class Channel implements Comparable { /** * 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; diff --git a/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java b/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java index 7a8f341..9e5e66b 100644 --- a/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java +++ b/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java @@ -24,8 +24,6 @@ import java.util.UUID; * and logging in. */ public class AuthenticationService { - public static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {} - private final NitriteCollection userCollection; private final NitriteCollection sessionTokenCollection; private final ConcordServer server; diff --git a/server/src/main/java/nl/andrewl/concord_server/client/ClientConnectionData.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientConnectionData.java new file mode 100644 index 0000000..6097df6 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientConnectionData.java @@ -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) {} diff --git a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java index cf4bb37..18581e5 100644 --- a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java +++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java @@ -27,9 +27,12 @@ public class ClientManager { private final Map clients; private final Map pendingClients; private final NitriteCollection userCollection; - 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) { this.server = server; this.clients = new ConcurrentHashMap<>(); @@ -45,12 +48,28 @@ public class ClientManager { 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 { Document userDoc = this.userCollection.find(Filters.eq("username", registration.username())).firstOrDefault(); if (userDoc != null) throw new InvalidIdentificationException("Username is taken."); if (this.server.getConfig().isAcceptAllNewClients()) { 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); } else { 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: + *
    + *
  • 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.
  • + *
  • For non-pending (normal) users, they will be logged into the + * server and sent a {@link ServerWelcome} message.
  • + *
+ * + * @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 { Document userDoc = this.authService.findAndAuthenticateUser(login); if (userDoc == null) throw new InvalidIdentificationException("Username or password is incorrect."); @@ -68,20 +104,38 @@ public class ClientManager { this.initializePendingClientConnection(userId, username, clientThread); } else { 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 { Document userDoc = this.authService.findAndAuthenticateUser(sessionResume); if (userDoc == null) throw new InvalidIdentificationException("Invalid session. Log in to obtain a new session token."); UUID userId = userDoc.get("id", UUID.class); String username = userDoc.get("username", String.class); 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(); if (userDoc != null) { if (accepted) { @@ -90,16 +144,16 @@ public class ClientManager { // If the pending user is still connected, upgrade them to a normal connected client. var clientThread = this.pendingClients.remove(userId); 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 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 { this.userCollection.remove(userDoc); var clientThread = this.pendingClients.remove(userId); 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 clientThread The thread managing the client's connection. */ - private void initializeClientConnection(AuthenticationService.ClientConnectionData clientData, ClientThread clientThread) { - this.clients.put(clientData.id(), clientThread); + private void initializeClientConnection(ClientConnectionData clientData, ClientThread clientThread) { clientThread.setClientId(clientData.id()); - clientThread.setClientNickname(clientData.nickname()); + clientThread.setClientNickname(clientData.username()); var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow(); 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); clientThread.setCurrentChannel(defaultChannel); 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) { - this.pendingClients.put(clientId, clientThread); clientThread.setClientId(clientId); clientThread.setClientNickname(pendingUsername); clientThread.sendToClient(RegistrationStatus.pending()); + this.pendingClients.put(clientId, clientThread); } /** @@ -166,6 +228,9 @@ public class ClientManager { } } + /** + * @return The list of connected clients. + */ public List getConnectedClients() { return this.clients.values().stream() .sorted(Comparator.comparing(ClientThread::getClientNickname)) @@ -173,6 +238,9 @@ public class ClientManager { .collect(Collectors.toList()); } + /** + * @return The list of connected, pending clients. + */ public List getPendingClients() { return this.pendingClients.values().stream() .sorted(Comparator.comparing(ClientThread::getClientNickname)) @@ -180,14 +248,27 @@ public class ClientManager { .collect(Collectors.toList()); } + /** + * @return The set of ids of all connected clients. + */ public Set getConnectedIds() { 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 getClientById(UUID 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 getPendingClientById(UUID id) { return Optional.ofNullable(this.pendingClients.get(id)); }