diff --git a/README.md b/README.md index 80bd162..a77c9b1 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ You probably want to customize your server a bit. To do so, first stop your serv - `name` The name of the server. - `description` A short description of what this server is for, or who it's run by. - `port` The port on which the server accepts client connections. +- `acceptAllNewClients` Whether to automatically accept any new client that registers to this server. Set to false by default, meaning an administrator needs to approve any pending registration before it is complete. - `chatHistoryMaxCount` The maximum amount of chat messages that a client can request from the server at any given time. Decrease this to improve performance. - `chatHistoryDefaultCount` The default number of chat messages that are provided to clients when they join a channel, if they don't explicitly request a certain amount. Decrease this to improve performance. - `maxMessageLength` The maximum length of a message. Messages longer than this will be rejected. @@ -46,11 +47,6 @@ You probably want to customize your server a bit. To do so, first stop your serv ## Server CLI -As mentioned briefly, the server supports a basic command-line-interface with some commands. You can show which commands are available via the `help` command. The following is a list of some of the most useful commands and a description of their functionality: - -- `add-channel ` Adds a new channel to the server with the given name. Channel names cannot be blank, and they cannot be duplicates of an existing channel name. -- `remove-channel ` Removes a channel. -- `list-clients` Shows a list of all connected clients. -- `stop` Stops the server, disconnecting all clients. +As mentioned briefly, the server supports a basic command-line-interface with some commands. You can show the commands that are available via the `help` command. Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information. 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/Message.java b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java index 3be10cb..2c83a71 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Message.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java @@ -8,12 +8,22 @@ package nl.andrewl.concord_core.msg; *

*/ public interface Message { + /** + * Convenience method to get the serializer for this message's type, using + * the static auto-generated set of serializers. + * @param The message type. + * @return The serializer to use to read and write messages of this type. + */ @SuppressWarnings("unchecked") - default MessageType getType() { - return MessageType.get((Class) this.getClass()); + default MessageTypeSerializer getTypeSerializer() { + return MessageTypeSerializer.get((Class) this.getClass()); } + /** + * Convenience method to determine the size of this message in bytes. + * @return The size of this message, in bytes. + */ default int byteSize() { - return getType().byteSizeFunction().apply(this); + return getTypeSerializer().byteSizeFunction().apply(this); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/MessageType.java b/core/src/main/java/nl/andrewl/concord_core/msg/MessageTypeSerializer.java similarity index 87% rename from core/src/main/java/nl/andrewl/concord_core/msg/MessageType.java rename to core/src/main/java/nl/andrewl/concord_core/msg/MessageTypeSerializer.java index c70bc14..ca961b7 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/MessageType.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/MessageTypeSerializer.java @@ -19,24 +19,24 @@ import java.util.function.Function; * @param reader A reader that can read messages from an input stream. * @param writer A writer that write messages from an input stream. */ -public record MessageType( +public record MessageTypeSerializer( Class messageClass, Function byteSizeFunction, MessageReader reader, MessageWriter writer ) { - private static final Map, MessageType> generatedMessageTypes = new HashMap<>(); + private static final Map, MessageTypeSerializer> generatedMessageTypes = new HashMap<>(); /** - * Gets the {@link MessageType} instance for a given message class, and + * Gets the {@link MessageTypeSerializer} instance for a given message class, and * generates a new implementation if none exists yet. * @param messageClass The class of the message to get a type for. * @param The type of the message. * @return The message type. */ @SuppressWarnings("unchecked") - public static MessageType get(Class messageClass) { - return (MessageType) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class) c)); + public static MessageTypeSerializer get(Class messageClass) { + return (MessageTypeSerializer) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class) c)); } /** @@ -49,7 +49,7 @@ public record MessageType( * @param The type of the message. * @return A message type instance. */ - public static MessageType generateForRecord(Class messageTypeClass) { + public static MessageTypeSerializer generateForRecord(Class messageTypeClass) { RecordComponent[] components = messageTypeClass.getRecordComponents(); Constructor constructor; try { @@ -58,7 +58,7 @@ public record MessageType( } catch (NoSuchMethodException e) { throw new IllegalArgumentException(e); } - return new MessageType<>( + return new MessageTypeSerializer<>( messageTypeClass, generateByteSizeFunction(components), generateReader(constructor), diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java index b813e14..61a3f87 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java @@ -35,10 +35,14 @@ public class MessageUtils { return size; } + public static int getByteSize(Message msg) { + return 1 + (msg == null ? 0 : msg.byteSize()); + } + public static int getByteSize(T[] items) { int count = Integer.BYTES; for (var item : items) { - count += item.byteSize(); + count += getByteSize(items); } return count; } @@ -59,7 +63,7 @@ public class MessageUtils { } else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) { return getByteSize((Message[]) o); } else if (o instanceof Message) { - return ((Message) o).byteSize(); + return getByteSize((Message) o); } else { throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName()); } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java index f7b8945..4030647 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java @@ -3,15 +3,13 @@ package nl.andrewl.concord_core.msg; import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_core.msg.types.ServerMetaData; import nl.andrewl.concord_core.msg.types.ServerUsers; +import nl.andrewl.concord_core.msg.types.UserData; import nl.andrewl.concord_core.msg.types.channel.CreateThread; 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.KeyData; -import nl.andrewl.concord_core.msg.types.client_setup.Registration; -import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome; +import nl.andrewl.concord_core.msg.types.client_setup.*; import nl.andrewl.concord_core.util.ChainedDataOutputStream; import nl.andrewl.concord_core.util.ExtendedDataInputStream; @@ -20,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -32,31 +31,36 @@ public class Serializer { * The mapping which defines each supported message type and the byte value * used to identify it when reading and writing messages. */ - private final Map> messageTypes = new HashMap<>(); + private final Map> messageTypes = new HashMap<>(); /** * An inverse of {@link Serializer#messageTypes} which is used to look up a * message's byte value when you know the class of the message. */ - private final Map, Byte> inverseMessageTypes = new HashMap<>(); + private final Map, Byte> inverseMessageTypes = new HashMap<>(); /** * Constructs a new serializer instance, with a standard set of supported * message types. */ public Serializer() { - registerType(0, Identification.class); - registerType(1, ServerWelcome.class); - registerType(2, Chat.class); - registerType(3, MoveToChannel.class); - registerType(4, ChatHistoryRequest.class); - registerType(5, ChatHistoryResponse.class); - registerType(6, Registration.class); - registerType(7, ServerUsers.class); - registerType(8, ServerMetaData.class); - registerType(9, Error.class); - registerType(10, CreateThread.class); - registerType(11, KeyData.class); + List> messageClasses = List.of( + // Utility messages. + Error.class, + UserData.class, + ServerUsers.class, + // Client setup messages. + KeyData.class, ClientRegistration.class, ClientLogin.class, ClientSessionResume.class, + RegistrationStatus.class, ServerWelcome.class, ServerMetaData.class, + // Chat messages. + Chat.class, ChatHistoryRequest.class, ChatHistoryResponse.class, + // Channel messages. + MoveToChannel.class, + CreateThread.class + ); + for (int id = 0; id < messageClasses.size(); id++) { + registerType(id, messageClasses.get(id)); + } } /** @@ -67,7 +71,7 @@ public class Serializer { * @param messageClass The type of message associated with the given id. */ private synchronized void registerType(int id, Class messageClass) { - MessageType type = MessageType.get(messageClass); + MessageTypeSerializer type = MessageTypeSerializer.get(messageClass); messageTypes.put((byte) id, type); inverseMessageTypes.put(type, (byte) id); } @@ -104,12 +108,12 @@ public class Serializer { */ public void writeMessage(Message msg, OutputStream o) throws IOException { DataOutputStream d = new DataOutputStream(o); - Byte typeId = inverseMessageTypes.get(msg.getType()); + Byte typeId = inverseMessageTypes.get(msg.getTypeSerializer()); if (typeId == null) { throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName()); } d.writeByte(typeId); - msg.getType().writer().write(msg, new ChainedDataOutputStream(d)); + msg.getTypeSerializer().writer().write(msg, new ChainedDataOutputStream(d)); d.flush(); } } 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/ClientLogin.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientLogin.java new file mode 100644 index 0000000..6455aff --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientLogin.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_core.msg.types.client_setup; + +import nl.andrewl.concord_core.msg.Message; + +/** + * This message is sent by clients to log into a server that they have already + * registered with, but don't have a valid session token for. + */ +public record ClientLogin(String username, String password) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Registration.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientRegistration.java similarity index 60% rename from core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Registration.java rename to core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientRegistration.java index 4421dc5..a8a2f74 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Registration.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientRegistration.java @@ -6,4 +6,9 @@ import nl.andrewl.concord_core.msg.Message; * The data that new users should send to a server in order to register in that * server. */ -public record Registration (String username, String password) implements Message {} +public record ClientRegistration( + String name, + String description, + String username, + String password +) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java new file mode 100644 index 0000000..ee9881e --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_core.msg.types.client_setup; + +import nl.andrewl.concord_core.msg.Message; + +/** + * This message is sent by the client to log into a server using a session token + * instead of a username/password combination. + */ +public record ClientSessionResume(String sessionToken) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java deleted file mode 100644 index ed2d237..0000000 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java +++ /dev/null @@ -1,11 +0,0 @@ -package nl.andrewl.concord_core.msg.types.client_setup; - -import nl.andrewl.concord_core.msg.Message; - -/** - * This message is sent from the client to a server, to provide identification - * information about the client to the server when the connection is started. - * - * @param nickname - */ -public record Identification(String nickname, String sessionToken) implements Message {} 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 new file mode 100644 index 0000000..43b951d --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java @@ -0,0 +1,15 @@ +package nl.andrewl.concord_core.msg.types.client_setup; + +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, String reason) implements Message { + public enum Type {PENDING, ACCEPTED, REJECTED} + + public static RegistrationStatus 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/ChainedDataOutputStream.java b/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java index 48e0687..38df084 100644 --- a/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java +++ b/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java @@ -63,13 +63,16 @@ public class ChainedDataOutputStream { public ChainedDataOutputStream writeArray(T[] array) throws IOException { this.out.writeInt(array.length); for (var item : array) { - item.getType().writer().write(item, this); + writeMessage(item); } return this; } public ChainedDataOutputStream writeMessage(Message msg) throws IOException { - msg.getType().writer().write(msg, this); + this.out.writeBoolean(msg != null); + if (msg != null) { + msg.getTypeSerializer().writer().write(msg, this); + } return this; } diff --git a/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java b/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java index aada77f..3470b36 100644 --- a/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java +++ b/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java @@ -1,7 +1,7 @@ package nl.andrewl.concord_core.util; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.MessageType; +import nl.andrewl.concord_core.msg.MessageTypeSerializer; import java.io.DataInputStream; import java.io.IOException; @@ -45,7 +45,7 @@ public class ExtendedDataInputStream extends DataInputStream { } @SuppressWarnings("unchecked") - public T[] readArray(MessageType type) throws IOException { + public T[] readArray(MessageTypeSerializer type) throws IOException { int length = super.readInt(); T[] array = (T[]) Array.newInstance(type.messageClass(), length); for (int i = 0; i < length; i++) { @@ -76,10 +76,10 @@ public class ExtendedDataInputStream extends DataInputStream { int length = this.readInt(); return this.readNBytes(length); } else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) { - var messageType = MessageType.get((Class) type.getComponentType()); + var messageType = MessageTypeSerializer.get((Class) type.getComponentType()); return this.readArray(messageType); } else if (Message.class.isAssignableFrom(type)) { - var messageType = MessageType.get((Class) type); + var messageType = MessageTypeSerializer.get((Class) type); return messageType.reader().read(this); } else { throw new IOException("Unsupported object type: " + type.getSimpleName()); 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/docs/.nojekyll b/docs/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..ea8117c --- /dev/null +++ b/docs/index.html @@ -0,0 +1,15 @@ + + + + + Concord + + + +

Concord

+

+ More content coming soon! +

+ + + \ 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/pom.xml b/server/pom.xml index 70fb7bd..c64020a 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -42,6 +42,12 @@ jackson-annotations 2.12.4 + + + at.favre.lib + bcrypt + 0.9.0 + diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 6552aac..a6eee84 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -4,6 +4,7 @@ module concord_server { requires com.fasterxml.jackson.databind; requires com.fasterxml.jackson.core; requires com.fasterxml.jackson.annotation; + requires bcrypt; requires java.base; requires java.logging; 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 9bc4e90..ebb67c2 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -85,6 +85,8 @@ public class ConcordServer implements Runnable { private final ClientManager clientManager; private final DiscoveryServerPublisher discoveryServerPublisher; + + @Getter private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); public ConcordServer() throws IOException { @@ -122,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/cli/command/ListClientsCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java index e9e668e..3f1e152 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java @@ -9,7 +9,7 @@ import nl.andrewl.concord_server.cli.ServerCliCommand; public class ListClientsCommand implements ServerCliCommand { @Override public void handle(ConcordServer server, String[] args) throws Exception { - var users = server.getClientManager().getClients(); + var users = server.getClientManager().getConnectedClients(); if (users.isEmpty()) { System.out.println("There are no connected clients."); } else { 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 new file mode 100644 index 0000000..9e5e66b --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java @@ -0,0 +1,112 @@ +package nl.andrewl.concord_server.client; + +import at.favre.lib.crypto.bcrypt.BCrypt; +import nl.andrewl.concord_core.msg.types.client_setup.ClientLogin; +import nl.andrewl.concord_core.msg.types.client_setup.ClientRegistration; +import nl.andrewl.concord_core.msg.types.client_setup.ClientSessionResume; +import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.util.CollectionUtils; +import nl.andrewl.concord_server.util.StringUtils; +import org.dizitart.no2.Document; +import org.dizitart.no2.IndexType; +import org.dizitart.no2.NitriteCollection; +import org.dizitart.no2.filters.Filters; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Map; +import java.util.UUID; + +/** + * This authentication service provides support for managing the client's + * authentication status, such as registering new clients, generating tokens, + * and logging in. + */ +public class AuthenticationService { + private final NitriteCollection userCollection; + private final NitriteCollection sessionTokenCollection; + private final ConcordServer server; + + public AuthenticationService(ConcordServer server, NitriteCollection userCollection) { + this.server = server; + this.userCollection = userCollection; + this.sessionTokenCollection = server.getDb().getCollection("session-tokens"); + CollectionUtils.ensureIndexes(this.sessionTokenCollection, Map.of( + "sessionToken", IndexType.Unique, + "userId", IndexType.NonUnique, + "expiresAt", IndexType.NonUnique + )); + } + + public ClientConnectionData registerNewClient(ClientRegistration registration) { + UUID id = this.server.getIdProvider().newId(); + String sessionToken = this.generateSessionToken(id); + String passwordHash = BCrypt.withDefaults().hashToString(12, registration.password().toCharArray()); + Document doc = new Document(Map.of( + "id", id, + "username", registration.username(), + "passwordHash", passwordHash, + "name", registration.name(), + "description", registration.description(), + "createdAt", System.currentTimeMillis(), + "pending", false + )); + this.userCollection.insert(doc); + return new ClientConnectionData(id, registration.username(), sessionToken, true); + } + + public UUID registerPendingClient(ClientRegistration registration) { + UUID id = this.server.getIdProvider().newId(); + String passwordHash = BCrypt.withDefaults().hashToString(12, registration.password().toCharArray()); + Document doc = new Document(Map.of( + "id", id, + "username", registration.username(), + "passwordHash", passwordHash, + "name", registration.name(), + "description", registration.description(), + "createdAt", System.currentTimeMillis(), + "pending", true + )); + this.userCollection.insert(doc); + return id; + } + + public Document findAndAuthenticateUser(ClientLogin login) { + Document userDoc = this.userCollection.find(Filters.eq("username", login.username())).firstOrDefault(); + if (userDoc != null) { + byte[] passwordHash = userDoc.get("passwordHash", String.class).getBytes(StandardCharsets.UTF_8); + if (BCrypt.verifyer().verify(login.password().getBytes(StandardCharsets.UTF_8), passwordHash).verified) { + return userDoc; + } + } + return null; + } + + public Document findAndAuthenticateUser(ClientSessionResume sessionResume) { + Document tokenDoc = this.sessionTokenCollection.find(Filters.and( + Filters.eq("sessionToken", sessionResume.sessionToken()), + Filters.gt("expiresAt", Instant.now().toEpochMilli()) + )).firstOrDefault(); + if (tokenDoc == null) return null; + UUID userId = tokenDoc.get("userId", UUID.class); + return this.userCollection.find(Filters.eq("id", userId)).firstOrDefault(); + } + + public String generateSessionToken(UUID userId) { + String sessionToken = StringUtils.random(128); + long expiresAt = Instant.now().plus(7, ChronoUnit.DAYS).toEpochMilli(); + Document doc = new Document(Map.of( + "sessionToken", sessionToken, + "userId", userId, + "expiresAt", expiresAt + )); + this.sessionTokenCollection.insert(doc); + return sessionToken; + } + + public void removeExpiredSessionTokens() { + long now = System.currentTimeMillis(); + this.sessionTokenCollection.remove(Filters.lt("expiresAt", now)); + } +} 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 7d4c6a7..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 @@ -1,14 +1,11 @@ package nl.andrewl.concord_server.client; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_core.msg.types.ServerUsers; import nl.andrewl.concord_core.msg.types.UserData; -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 nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.util.CollectionUtils; -import nl.andrewl.concord_server.util.StringUtils; import org.dizitart.no2.Document; import org.dizitart.no2.IndexType; import org.dizitart.no2.NitriteCollection; @@ -18,6 +15,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -27,58 +25,170 @@ import java.util.stream.Collectors; public class ClientManager { private final ConcordServer server; 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<>(); + this.pendingClients = new ConcurrentHashMap<>(); this.userCollection = server.getDb().getCollection("users"); CollectionUtils.ensureIndexes(this.userCollection, Map.of( "id", IndexType.Unique, - "sessionToken", IndexType.Unique, - "nickname", IndexType.Fulltext + "username", IndexType.Unique, + "pending", IndexType.NonUnique )); + this.authService = new AuthenticationService(server, this.userCollection); + // Start a daily scheduled removal of expired session tokens. + server.getScheduledExecutorService().scheduleAtFixedRate(this.authService::removeExpiredSessionTokens, 1, 1, TimeUnit.DAYS); } /** - * Registers a new client as connected to the server. This is done once the - * client thread has received the correct identification information from - * the client. The server will register the client in its global set of - * connected clients, and it will immediately move the client to the default - * channel. - *

- * If the client provides a session token with their identification - * message, then we should load their data from our database, otherwise - * we assume this is a new client. - *

- * @param identification The client's identification data. - * @param clientThread The client manager thread. + * 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 handleLogIn(Identification identification, ClientThread clientThread) { - ClientConnectionData data; - try { - data = identification.sessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification); - } catch (InvalidIdentificationException e) { - clientThread.sendToClient(Error.warning(e.getMessage())); - return; + 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, null)); + this.initializeClientConnection(clientData, clientThread); + } else { + var clientId = this.authService.registerPendingClient(registration); + this.initializePendingClientConnection(clientId, registration.username(), clientThread); } + } - this.clients.put(data.id, clientThread); - clientThread.setClientId(data.id); - clientThread.setClientNickname(data.nickname); + /** + * 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."); + UUID userId = userDoc.get("id", UUID.class); + String username = userDoc.get("username", String.class); + boolean pending = userDoc.get("pending", Boolean.class); + if (pending) { + this.initializePendingClientConnection(userId, username, clientThread); + } else { + String sessionToken = this.authService.generateSessionToken(userId); + 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 ClientConnectionData(userId, username, sessionToken, false), clientThread); + } + + /** + * 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) { + userDoc.put("pending", false); + this.userCollection.update(userDoc); + // 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, reason)); + String username = userDoc.get("username", String.class); + String sessionToken = this.authService.generateSessionToken(userId); + 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, reason)); + } + } + } + } + + /** + * Standard flow for initializing a connection to a client who has already + * sent their identification message, and that has been checked to be valid. + * @param clientData The data about the client that has connected. + * @param clientThread The thread managing the client's connection. + */ + private void initializeClientConnection(ClientConnectionData clientData, ClientThread clientThread) { + clientThread.setClientId(clientData.id()); + clientThread.setClientNickname(clientData.username()); var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow(); - clientThread.sendToClient(new ServerWelcome(data.id, data.sessionToken, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData())); - // It is important that we send the welcome message first. The client expects this as the initial response to their identification message. + 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); - System.out.printf( - "Client %s(%s) joined%s, and was put into %s.\n", - data.nickname, - data.id, - data.newClient ? " for the first time" : "", - defaultChannel - ); - this.broadcast(new ServerUsers(this.getClients().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) { + clientThread.setClientId(clientId); + clientThread.setClientNickname(pendingUsername); + clientThread.sendToClient(RegistrationStatus.pending()); + this.pendingClients.put(clientId, clientThread); } /** @@ -87,12 +197,16 @@ public class ClientManager { * @param clientId The id of the client to remove. */ public void handleLogOut(UUID clientId) { + var pendingClient = this.pendingClients.remove(clientId); + if (pendingClient != null) { + pendingClient.shutdown(); + } var client = this.clients.remove(clientId); if (client != null) { client.getCurrentChannel().removeClient(client); client.shutdown(); System.out.println("Client " + client + " has disconnected."); - this.broadcast(new ServerUsers(this.getClients().toArray(new UserData[0]))); + this.broadcast(new ServerUsers(this.getConnectedClients().toArray(new UserData[0]))); } } @@ -114,57 +228,48 @@ public class ClientManager { } } - public List getClients() { + /** + * @return The list of connected clients. + */ + public List getConnectedClients() { return this.clients.values().stream() .sorted(Comparator.comparing(ClientThread::getClientNickname)) .map(ClientThread::toData) .collect(Collectors.toList()); } + /** + * @return The list of connected, pending clients. + */ + public List getPendingClients() { + return this.pendingClients.values().stream() + .sorted(Comparator.comparing(ClientThread::getClientNickname)) + .map(ClientThread::toData) + .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)); } - private static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {} - - private ClientConnectionData getClientDataFromDb(Identification identification) throws InvalidIdentificationException { - var cursor = this.userCollection.find(Filters.eq("sessionToken", identification.sessionToken())); - Document doc = cursor.firstOrDefault(); - if (doc != null) { - UUID id = doc.get("id", UUID.class); - String nickname = identification.nickname(); - if (nickname != null) { - doc.put("nickname", nickname); - } else { - nickname = doc.get("nickname", String.class); - } - String sessionToken = StringUtils.random(128); - doc.put("sessionToken", sessionToken); - this.userCollection.update(doc); - return new ClientConnectionData(id, nickname, sessionToken, false); - } else { - throw new InvalidIdentificationException("Invalid session token."); - } - } - - private ClientConnectionData getNewClientData(Identification identification) throws InvalidIdentificationException { - UUID id = this.server.getIdProvider().newId(); - String nickname = identification.nickname(); - if (nickname == null) { - throw new InvalidIdentificationException("Missing nickname."); - } - String sessionToken = StringUtils.random(128); - Document doc = new Document(Map.of( - "id", id, - "nickname", nickname, - "sessionToken", sessionToken, - "createdAt", System.currentTimeMillis() - )); - this.userCollection.insert(doc); - return new ClientConnectionData(id, nickname, sessionToken, true); + /** + * 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)); } } diff --git a/server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java index 2e580c9..19763e9 100644 --- a/server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java +++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java @@ -4,8 +4,11 @@ import lombok.Getter; import lombok.Setter; import nl.andrewl.concord_core.msg.Encryption; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.types.client_setup.Identification; +import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_core.msg.types.UserData; +import nl.andrewl.concord_core.msg.types.client_setup.ClientLogin; +import nl.andrewl.concord_core.msg.types.client_setup.ClientRegistration; +import nl.andrewl.concord_core.msg.types.client_setup.ClientSessionResume; import nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.channel.Channel; @@ -135,14 +138,25 @@ public class ClientThread extends Thread { System.err.println("Could not establish end-to-end encryption with the client."); return false; } + final var clientManager = this.server.getClientManager(); int attempts = 0; while (attempts < 5) { try { var msg = this.server.getSerializer().readMessage(this.in); - if (msg instanceof Identification id) { - this.server.getClientManager().handleLogIn(id, this); + if (msg instanceof ClientRegistration cr) { + clientManager.handleRegistration(cr, this); return true; + } else if (msg instanceof ClientLogin cl) { + clientManager.handleLogin(cl, this); + return true; + } else if (msg instanceof ClientSessionResume csr) { + clientManager.handleSessionResume(csr, this); + return true; + } else { + this.sendToClient(Error.warning("Invalid identification message: " + msg.getClass().getSimpleName() + ", expected ClientRegistration, ClientLogin, or ClientSessionResume.")); } + } catch (InvalidIdentificationException e) { + this.sendToClient(Error.warning(e.getMessage())); } catch (IOException e) { e.printStackTrace(); } diff --git a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java index 684ee52..e97eb5e 100644 --- a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java +++ b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java @@ -20,6 +20,7 @@ public final class ServerConfig { private String name; private String description; private int port; + private boolean acceptAllNewClients; private int chatHistoryMaxCount; private int chatHistoryDefaultCount; private int maxMessageLength; @@ -51,6 +52,7 @@ public final class ServerConfig { "My Concord Server", "A concord server for my friends and I.", 8123, + false, 100, 50, 8192,