Merge pull request #7 from andrewlalis/userRegistration
User registration
This commit is contained in:
commit
108cc556d7
|
@ -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.
|
- `name` The name of the server.
|
||||||
- `description` A short description of what this server is for, or who it's run by.
|
- `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.
|
- `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.
|
- `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.
|
- `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.
|
- `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
|
## 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:
|
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.
|
||||||
|
|
||||||
- `add-channel <name>` 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 <name>` Removes a channel.
|
|
||||||
- `list-clients` Shows a list of all connected clients.
|
|
||||||
- `stop` Stops the server, disconnecting all clients.
|
|
||||||
|
|
||||||
Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information.
|
Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information.
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
package nl.andrewl.concord_client;
|
package nl.andrewl.concord_client;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
||||||
import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
|
import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
|
||||||
import com.googlecode.lanterna.gui2.Window;
|
import com.googlecode.lanterna.gui2.Window;
|
||||||
import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
|
import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
|
||||||
|
@ -9,6 +8,8 @@ import com.googlecode.lanterna.screen.TerminalScreen;
|
||||||
import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
|
import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
|
||||||
import com.googlecode.lanterna.terminal.Terminal;
|
import com.googlecode.lanterna.terminal.Terminal;
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
import nl.andrewl.concord_client.data.ClientDataStore;
|
||||||
|
import nl.andrewl.concord_client.data.JsonClientDataStore;
|
||||||
import nl.andrewl.concord_client.event.EventManager;
|
import nl.andrewl.concord_client.event.EventManager;
|
||||||
import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler;
|
import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler;
|
||||||
import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler;
|
import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler;
|
||||||
|
@ -19,42 +20,41 @@ import nl.andrewl.concord_client.model.ClientModel;
|
||||||
import nl.andrewl.concord_core.msg.Encryption;
|
import nl.andrewl.concord_core.msg.Encryption;
|
||||||
import nl.andrewl.concord_core.msg.Message;
|
import nl.andrewl.concord_core.msg.Message;
|
||||||
import nl.andrewl.concord_core.msg.Serializer;
|
import nl.andrewl.concord_core.msg.Serializer;
|
||||||
import nl.andrewl.concord_core.msg.types.*;
|
import nl.andrewl.concord_core.msg.types.ServerMetaData;
|
||||||
|
import nl.andrewl.concord_core.msg.types.ServerUsers;
|
||||||
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
|
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
|
||||||
import nl.andrewl.concord_core.msg.types.chat.Chat;
|
import nl.andrewl.concord_core.msg.types.chat.Chat;
|
||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
|
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
|
||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
|
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
|
||||||
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
|
import nl.andrewl.concord_core.msg.types.client_setup.*;
|
||||||
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.security.GeneralSecurityException;
|
import java.security.GeneralSecurityException;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
public class ConcordClient implements Runnable {
|
public class ConcordClient implements Runnable {
|
||||||
private final Socket socket;
|
private final Socket socket;
|
||||||
private final InputStream in;
|
private final InputStream in;
|
||||||
private final OutputStream out;
|
private final OutputStream out;
|
||||||
private final Serializer serializer;
|
private final Serializer serializer;
|
||||||
|
private final ClientDataStore dataStore;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
private final ClientModel model;
|
private ClientModel model;
|
||||||
|
|
||||||
private final EventManager eventManager;
|
private final EventManager eventManager;
|
||||||
|
|
||||||
private volatile boolean running;
|
private volatile boolean running;
|
||||||
|
|
||||||
public ConcordClient(String host, int port, String nickname, Path tokensFile) throws IOException {
|
private ConcordClient(String host, int port) throws IOException {
|
||||||
this.eventManager = new EventManager(this);
|
this.eventManager = new EventManager(this);
|
||||||
this.socket = new Socket(host, port);
|
this.socket = new Socket(host, port);
|
||||||
this.serializer = new Serializer();
|
this.serializer = new Serializer();
|
||||||
|
this.dataStore = new JsonClientDataStore(Path.of("concord-session-tokens.json"));
|
||||||
try {
|
try {
|
||||||
var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer);
|
var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer);
|
||||||
this.in = streams.first();
|
this.in = streams.first();
|
||||||
|
@ -62,8 +62,6 @@ public class ConcordClient implements Runnable {
|
||||||
} catch (GeneralSecurityException e) {
|
} catch (GeneralSecurityException e) {
|
||||||
throw new IOException("Could not establish secure connection to the server.", e);
|
throw new IOException("Could not establish secure connection to the server.", e);
|
||||||
}
|
}
|
||||||
this.model = this.initializeConnectionToServer(nickname, tokensFile);
|
|
||||||
|
|
||||||
// Add event listeners.
|
// Add event listeners.
|
||||||
this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
|
this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
|
||||||
this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler());
|
this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler());
|
||||||
|
@ -72,32 +70,63 @@ public class ConcordClient implements Runnable {
|
||||||
this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler());
|
this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public static ConcordClient register(String host, int port, String username, String password) throws IOException {
|
||||||
* Initializes the communication with the server by sending an {@link Identification}
|
var client = new ConcordClient(host, port);
|
||||||
* message, and waiting for a {@link ServerWelcome} response from the
|
client.sendMessage(new ClientRegistration(null, null, username, password));
|
||||||
* server. After that, we request some information about the channel we were
|
Message reply = client.serializer.readMessage(client.in);
|
||||||
* placed in by the server.
|
if (reply instanceof RegistrationStatus status) {
|
||||||
* @param nickname The nickname to send to the server that it should know
|
if (status.type() == RegistrationStatus.Type.ACCEPTED) {
|
||||||
* us by.
|
ServerWelcome welcomeData = (ServerWelcome) client.serializer.readMessage(client.in);
|
||||||
* @param tokensFile Path to the file where session tokens are stored.
|
client.initializeClientModel(welcomeData, username);
|
||||||
* @return The client model that contains the server's metadata and other
|
} else if (status.type() == RegistrationStatus.Type.PENDING) {
|
||||||
* information that should be kept up-to-date at runtime.
|
System.out.println("Registration pending!");
|
||||||
* @throws IOException If an error occurs while reading or writing the
|
}
|
||||||
* messages, or if the server sends an unexpected response.
|
} else {
|
||||||
*/
|
System.out.println(reply);
|
||||||
private ClientModel initializeConnectionToServer(String nickname, Path tokensFile) throws IOException {
|
}
|
||||||
String token = this.getSessionToken(tokensFile);
|
return client;
|
||||||
this.serializer.writeMessage(new Identification(nickname, token), this.out);
|
}
|
||||||
Message reply = this.serializer.readMessage(this.in);
|
|
||||||
|
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) {
|
if (reply instanceof ServerWelcome welcome) {
|
||||||
var model = new ClientModel(welcome.clientId(), nickname, welcome.currentChannelId(), welcome.currentChannelName(), welcome.metaData());
|
client.initializeClientModel(welcome, username);
|
||||||
this.saveSessionToken(welcome.sessionToken(), tokensFile);
|
} 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.
|
// Start fetching initial data for the channel we were initially put into.
|
||||||
this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
|
this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
|
||||||
return model;
|
|
||||||
} else {
|
|
||||||
throw new IOException("Unexpected response from the server after sending identification message: " + reply);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendMessage(Message message) throws IOException {
|
public void sendMessage(Message message) throws IOException {
|
||||||
|
@ -138,46 +167,6 @@ public class ConcordClient implements Runnable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the session token that this client should use for its currently
|
|
||||||
* configured server, according to the socket address and port.
|
|
||||||
* @param tokensFile The file containing the session tokens.
|
|
||||||
* @return The session token, or null if none was found.
|
|
||||||
* @throws IOException If the tokens file could not be read.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private String getSessionToken(Path tokensFile) throws IOException {
|
|
||||||
String token = null;
|
|
||||||
String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
|
|
||||||
if (Files.exists(tokensFile)) {
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
Map<String, String> sessionTokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
|
|
||||||
token = sessionTokens.get(address);
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves a session token that this client should use the next time it
|
|
||||||
* connects to the same server.
|
|
||||||
* @param token The token to save.
|
|
||||||
* @param tokensFile The file containing the session tokens.
|
|
||||||
* @throws IOException If the tokens file could not be read or written to.
|
|
||||||
*/
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
private void saveSessionToken(String token, Path tokensFile) throws IOException {
|
|
||||||
String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
|
|
||||||
Map<String, String> tokens = new HashMap<>();
|
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
|
||||||
if (Files.exists(tokensFile)) {
|
|
||||||
tokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
|
|
||||||
}
|
|
||||||
tokens.put(address, token);
|
|
||||||
mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(tokensFile), tokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public static void main(String[] args) throws IOException {
|
public static void main(String[] args) throws IOException {
|
||||||
Terminal term = new DefaultTerminalFactory().createTerminal();
|
Terminal term = new DefaultTerminalFactory().createTerminal();
|
||||||
Screen screen = new TerminalScreen(term);
|
Screen screen = new TerminalScreen(term);
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
package nl.andrewl.concord_client.data;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A component which can store and retrieve persistent data which a client can
|
||||||
|
* use as part of its interaction with servers.
|
||||||
|
*/
|
||||||
|
public interface ClientDataStore {
|
||||||
|
Optional<String> getSessionToken(String serverName) throws IOException;
|
||||||
|
void saveSessionToken(String serverName, String sessionToken) throws IOException;
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
package nl.andrewl.concord_client.data;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public class JsonClientDataStore implements ClientDataStore {
|
||||||
|
private final Path file;
|
||||||
|
|
||||||
|
public JsonClientDataStore(Path file) {
|
||||||
|
this.file = file;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public Optional<String> getSessionToken(String serverName) throws IOException {
|
||||||
|
String token = null;
|
||||||
|
if (Files.exists(file)) {
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
Map<String, String> sessionTokens = mapper.readValue(Files.newBufferedReader(file), Map.class);
|
||||||
|
token = sessionTokens.get(serverName);
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
public void saveSessionToken(String serverName, String sessionToken) throws IOException {
|
||||||
|
Map<String, String> tokens = new HashMap<>();
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
if (Files.exists(file)) {
|
||||||
|
tokens = mapper.readValue(Files.newBufferedReader(file), Map.class);
|
||||||
|
}
|
||||||
|
tokens.put(serverName, sessionToken);
|
||||||
|
mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(file), tokens);
|
||||||
|
}
|
||||||
|
}
|
|
@ -50,7 +50,7 @@ public class MainWindow extends BasicWindow {
|
||||||
if (nickname == null) return;
|
if (nickname == null) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var client = new ConcordClient(host, port, nickname, Path.of("concord-session-tokens.json"));
|
var client = ConcordClient.login(host, port, nickname, "testpass");
|
||||||
var chatPanel = new ServerPanel(client, this);
|
var chatPanel = new ServerPanel(client, this);
|
||||||
client.getModel().addListener(chatPanel);
|
client.getModel().addListener(chatPanel);
|
||||||
new Thread(client).start();
|
new Thread(client).start();
|
||||||
|
|
|
@ -12,7 +12,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
public class ClientModel {
|
public class ClientModel {
|
||||||
private UUID id;
|
private final UUID id;
|
||||||
private String nickname;
|
private String nickname;
|
||||||
private ServerMetaData serverMetaData;
|
private ServerMetaData serverMetaData;
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
/**
|
||||||
|
* The core components that are used by both the Concord server and the default
|
||||||
|
* client implementation. Includes record-based message serialization, and some
|
||||||
|
* utilities for message passing.
|
||||||
|
* <p>
|
||||||
|
* This core module defines the message protocol that clients must use to
|
||||||
|
* communicate with any server.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
module concord_core {
|
module concord_core {
|
||||||
requires static lombok;
|
requires static lombok;
|
||||||
|
|
||||||
|
|
|
@ -8,12 +8,22 @@ package nl.andrewl.concord_core.msg;
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public interface Message {
|
public interface Message {
|
||||||
|
/**
|
||||||
|
* Convenience method to get the serializer for this message's type, using
|
||||||
|
* the static auto-generated set of serializers.
|
||||||
|
* @param <T> The message type.
|
||||||
|
* @return The serializer to use to read and write messages of this type.
|
||||||
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
default <T extends Message> MessageType<T> getType() {
|
default <T extends Message> MessageTypeSerializer<T> getTypeSerializer() {
|
||||||
return MessageType.get((Class<T>) this.getClass());
|
return MessageTypeSerializer.get((Class<T>) this.getClass());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method to determine the size of this message in bytes.
|
||||||
|
* @return The size of this message, in bytes.
|
||||||
|
*/
|
||||||
default int byteSize() {
|
default int byteSize() {
|
||||||
return getType().byteSizeFunction().apply(this);
|
return getTypeSerializer().byteSizeFunction().apply(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,24 +19,24 @@ import java.util.function.Function;
|
||||||
* @param reader A reader that can read messages from an input stream.
|
* @param reader A reader that can read messages from an input stream.
|
||||||
* @param writer A writer that write messages from an input stream.
|
* @param writer A writer that write messages from an input stream.
|
||||||
*/
|
*/
|
||||||
public record MessageType<T extends Message>(
|
public record MessageTypeSerializer<T extends Message>(
|
||||||
Class<T> messageClass,
|
Class<T> messageClass,
|
||||||
Function<T, Integer> byteSizeFunction,
|
Function<T, Integer> byteSizeFunction,
|
||||||
MessageReader<T> reader,
|
MessageReader<T> reader,
|
||||||
MessageWriter<T> writer
|
MessageWriter<T> writer
|
||||||
) {
|
) {
|
||||||
private static final Map<Class<?>, MessageType<?>> generatedMessageTypes = new HashMap<>();
|
private static final Map<Class<?>, 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.
|
* generates a new implementation if none exists yet.
|
||||||
* @param messageClass The class of the message to get a type for.
|
* @param messageClass The class of the message to get a type for.
|
||||||
* @param <T> The type of the message.
|
* @param <T> The type of the message.
|
||||||
* @return The message type.
|
* @return The message type.
|
||||||
*/
|
*/
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public static <T extends Message> MessageType<T> get(Class<T> messageClass) {
|
public static <T extends Message> MessageTypeSerializer<T> get(Class<T> messageClass) {
|
||||||
return (MessageType<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
|
return (MessageTypeSerializer<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -49,7 +49,7 @@ public record MessageType<T extends Message>(
|
||||||
* @param <T> The type of the message.
|
* @param <T> The type of the message.
|
||||||
* @return A message type instance.
|
* @return A message type instance.
|
||||||
*/
|
*/
|
||||||
public static <T extends Message> MessageType<T> generateForRecord(Class<T> messageTypeClass) {
|
public static <T extends Message> MessageTypeSerializer<T> generateForRecord(Class<T> messageTypeClass) {
|
||||||
RecordComponent[] components = messageTypeClass.getRecordComponents();
|
RecordComponent[] components = messageTypeClass.getRecordComponents();
|
||||||
Constructor<T> constructor;
|
Constructor<T> constructor;
|
||||||
try {
|
try {
|
||||||
|
@ -58,7 +58,7 @@ public record MessageType<T extends Message>(
|
||||||
} catch (NoSuchMethodException e) {
|
} catch (NoSuchMethodException e) {
|
||||||
throw new IllegalArgumentException(e);
|
throw new IllegalArgumentException(e);
|
||||||
}
|
}
|
||||||
return new MessageType<>(
|
return new MessageTypeSerializer<>(
|
||||||
messageTypeClass,
|
messageTypeClass,
|
||||||
generateByteSizeFunction(components),
|
generateByteSizeFunction(components),
|
||||||
generateReader(constructor),
|
generateReader(constructor),
|
|
@ -35,10 +35,14 @@ public class MessageUtils {
|
||||||
return size;
|
return size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int getByteSize(Message msg) {
|
||||||
|
return 1 + (msg == null ? 0 : msg.byteSize());
|
||||||
|
}
|
||||||
|
|
||||||
public static <T extends Message> int getByteSize(T[] items) {
|
public static <T extends Message> int getByteSize(T[] items) {
|
||||||
int count = Integer.BYTES;
|
int count = Integer.BYTES;
|
||||||
for (var item : items) {
|
for (var item : items) {
|
||||||
count += item.byteSize();
|
count += getByteSize(items);
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
@ -59,7 +63,7 @@ public class MessageUtils {
|
||||||
} else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) {
|
} else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) {
|
||||||
return getByteSize((Message[]) o);
|
return getByteSize((Message[]) o);
|
||||||
} else if (o instanceof Message) {
|
} else if (o instanceof Message) {
|
||||||
return ((Message) o).byteSize();
|
return getByteSize((Message) o);
|
||||||
} else {
|
} else {
|
||||||
throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName());
|
throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.Error;
|
||||||
import nl.andrewl.concord_core.msg.types.ServerMetaData;
|
import nl.andrewl.concord_core.msg.types.ServerMetaData;
|
||||||
import nl.andrewl.concord_core.msg.types.ServerUsers;
|
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.CreateThread;
|
||||||
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
|
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
|
||||||
import nl.andrewl.concord_core.msg.types.chat.Chat;
|
import nl.andrewl.concord_core.msg.types.chat.Chat;
|
||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
|
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
|
||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
|
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
|
||||||
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
|
import nl.andrewl.concord_core.msg.types.client_setup.*;
|
||||||
import nl.andrewl.concord_core.msg.types.client_setup.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.util.ChainedDataOutputStream;
|
import nl.andrewl.concord_core.util.ChainedDataOutputStream;
|
||||||
import nl.andrewl.concord_core.util.ExtendedDataInputStream;
|
import nl.andrewl.concord_core.util.ExtendedDataInputStream;
|
||||||
|
|
||||||
|
@ -20,6 +18,7 @@ import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -32,31 +31,36 @@ public class Serializer {
|
||||||
* The mapping which defines each supported message type and the byte value
|
* The mapping which defines each supported message type and the byte value
|
||||||
* used to identify it when reading and writing messages.
|
* used to identify it when reading and writing messages.
|
||||||
*/
|
*/
|
||||||
private final Map<Byte, MessageType<?>> messageTypes = new HashMap<>();
|
private final Map<Byte, MessageTypeSerializer<?>> messageTypes = new HashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An inverse of {@link Serializer#messageTypes} which is used to look up a
|
* 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.
|
* message's byte value when you know the class of the message.
|
||||||
*/
|
*/
|
||||||
private final Map<MessageType<?>, Byte> inverseMessageTypes = new HashMap<>();
|
private final Map<MessageTypeSerializer<?>, Byte> inverseMessageTypes = new HashMap<>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a new serializer instance, with a standard set of supported
|
* Constructs a new serializer instance, with a standard set of supported
|
||||||
* message types.
|
* message types.
|
||||||
*/
|
*/
|
||||||
public Serializer() {
|
public Serializer() {
|
||||||
registerType(0, Identification.class);
|
List<Class<? extends Message>> messageClasses = List.of(
|
||||||
registerType(1, ServerWelcome.class);
|
// Utility messages.
|
||||||
registerType(2, Chat.class);
|
Error.class,
|
||||||
registerType(3, MoveToChannel.class);
|
UserData.class,
|
||||||
registerType(4, ChatHistoryRequest.class);
|
ServerUsers.class,
|
||||||
registerType(5, ChatHistoryResponse.class);
|
// Client setup messages.
|
||||||
registerType(6, Registration.class);
|
KeyData.class, ClientRegistration.class, ClientLogin.class, ClientSessionResume.class,
|
||||||
registerType(7, ServerUsers.class);
|
RegistrationStatus.class, ServerWelcome.class, ServerMetaData.class,
|
||||||
registerType(8, ServerMetaData.class);
|
// Chat messages.
|
||||||
registerType(9, Error.class);
|
Chat.class, ChatHistoryRequest.class, ChatHistoryResponse.class,
|
||||||
registerType(10, CreateThread.class);
|
// Channel messages.
|
||||||
registerType(11, KeyData.class);
|
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.
|
* @param messageClass The type of message associated with the given id.
|
||||||
*/
|
*/
|
||||||
private synchronized <T extends Message> void registerType(int id, Class<T> messageClass) {
|
private synchronized <T extends Message> void registerType(int id, Class<T> messageClass) {
|
||||||
MessageType<T> type = MessageType.get(messageClass);
|
MessageTypeSerializer<T> type = MessageTypeSerializer.get(messageClass);
|
||||||
messageTypes.put((byte) id, type);
|
messageTypes.put((byte) id, type);
|
||||||
inverseMessageTypes.put(type, (byte) id);
|
inverseMessageTypes.put(type, (byte) id);
|
||||||
}
|
}
|
||||||
|
@ -104,12 +108,12 @@ public class Serializer {
|
||||||
*/
|
*/
|
||||||
public <T extends Message> void writeMessage(Message msg, OutputStream o) throws IOException {
|
public <T extends Message> void writeMessage(Message msg, OutputStream o) throws IOException {
|
||||||
DataOutputStream d = new DataOutputStream(o);
|
DataOutputStream d = new DataOutputStream(o);
|
||||||
Byte typeId = inverseMessageTypes.get(msg.getType());
|
Byte typeId = inverseMessageTypes.get(msg.getTypeSerializer());
|
||||||
if (typeId == null) {
|
if (typeId == null) {
|
||||||
throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
|
throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
|
||||||
}
|
}
|
||||||
d.writeByte(typeId);
|
d.writeByte(typeId);
|
||||||
msg.getType().writer().write(msg, new ChainedDataOutputStream(d));
|
msg.getTypeSerializer().writer().write(msg, new ChainedDataOutputStream(d));
|
||||||
d.flush();
|
d.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -5,11 +5,10 @@ import nl.andrewl.concord_core.msg.Message;
|
||||||
/**
|
/**
|
||||||
* Error message which can be sent between either the server or client to
|
* Error message which can be sent between either the server or client to
|
||||||
* indicate an unsavory situation.
|
* indicate an unsavory situation.
|
||||||
|
* @param level The severity level of the error.
|
||||||
|
* @param message A message indicating what went wrong.
|
||||||
*/
|
*/
|
||||||
public record Error (
|
public record Error (Level level, String message) implements Message {
|
||||||
Level level,
|
|
||||||
String message
|
|
||||||
) implements Message {
|
|
||||||
/**
|
/**
|
||||||
* The error level gives an indication as to the severity of the error.
|
* The error level gives an indication as to the severity of the error.
|
||||||
* Warnings indicate that a user has attempted to do something which they
|
* Warnings indicate that a user has attempted to do something which they
|
||||||
|
@ -18,10 +17,20 @@ public record Error (
|
||||||
*/
|
*/
|
||||||
public enum Level {WARNING, ERROR}
|
public enum Level {WARNING, ERROR}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a warning message.
|
||||||
|
* @param message The message text.
|
||||||
|
* @return A warning-level error message.
|
||||||
|
*/
|
||||||
public static Error warning(String message) {
|
public static Error warning(String message) {
|
||||||
return new Error(Level.WARNING, message);
|
return new Error(Level.WARNING, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an error message.
|
||||||
|
* @param message The message text.
|
||||||
|
* @return An error-level error message.
|
||||||
|
*/
|
||||||
public static Error error(String message) {
|
public static Error error(String message) {
|
||||||
return new Error(Level.ERROR, message);
|
return new Error(Level.ERROR, message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Messages pertaining to channel interaction and updates.
|
||||||
|
*/
|
||||||
|
package nl.andrewl.concord_core.msg.types.channel;
|
|
@ -8,9 +8,7 @@ import java.util.UUID;
|
||||||
/**
|
/**
|
||||||
* This message contains information about a chat message that a user sent.
|
* This message contains information about a chat message that a user sent.
|
||||||
*/
|
*/
|
||||||
public record Chat (
|
public record Chat (UUID id, UUID senderId, String senderNickname, long timestamp, String message) implements Message {
|
||||||
UUID id, UUID senderId, String senderNickname, long timestamp, String message
|
|
||||||
) implements Message {
|
|
||||||
public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
|
public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
|
||||||
this(null, senderId, senderNickname, timestamp, message);
|
this(null, senderId, senderNickname, timestamp, message);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import java.util.stream.Collectors;
|
||||||
* </p>
|
* </p>
|
||||||
* <p>
|
* <p>
|
||||||
* The following query parameters are supported:
|
* The following query parameters are supported:
|
||||||
|
* </p>
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li><code>count</code> - Fetch up to N messages. Minimum of 1, and
|
* <li><code>count</code> - Fetch up to N messages. Minimum of 1, and
|
||||||
* a server-specific maximum count, usually no higher than 1000.</li>
|
* a server-specific maximum count, usually no higher than 1000.</li>
|
||||||
|
@ -37,7 +38,6 @@ import java.util.stream.Collectors;
|
||||||
* is present, all others are ignored, and a list containing the single
|
* is present, all others are ignored, and a list containing the single
|
||||||
* message is returned, if it could be found, otherwise an empty list.</li>
|
* message is returned, if it could be found, otherwise an empty list.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* </p>
|
|
||||||
* <p>
|
* <p>
|
||||||
* Responses to this request are sent via {@link ChatHistoryResponse}, where
|
* Responses to this request are sent via {@link ChatHistoryResponse}, where
|
||||||
* the list of messages is always sorted by the timestamp.
|
* the list of messages is always sorted by the timestamp.
|
||||||
|
|
|
@ -7,5 +7,7 @@ import java.util.UUID;
|
||||||
/**
|
/**
|
||||||
* The response that a server sends to a {@link ChatHistoryRequest}. The list of
|
* The response that a server sends to a {@link ChatHistoryRequest}. The list of
|
||||||
* messages is ordered by timestamp, with the newest messages appearing first.
|
* messages is ordered by timestamp, with the newest messages appearing first.
|
||||||
|
* @param channelId The id of the channel that the chat messages belong to.
|
||||||
|
* @param messages The list of messages that comprises the history.
|
||||||
*/
|
*/
|
||||||
public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {}
|
public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {}
|
||||||
|
|
|
@ -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;
|
|
@ -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 {}
|
|
@ -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
|
* The data that new users should send to a server in order to register in that
|
||||||
* server.
|
* server.
|
||||||
*/
|
*/
|
||||||
public record Registration (String username, String password) implements Message {}
|
public record ClientRegistration(
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String username,
|
||||||
|
String password
|
||||||
|
) implements Message {}
|
|
@ -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 {}
|
|
@ -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 {}
|
|
|
@ -5,5 +5,8 @@ import nl.andrewl.concord_core.msg.Message;
|
||||||
/**
|
/**
|
||||||
* This message is sent as the first message from both the server and the client
|
* This message is sent as the first message from both the server and the client
|
||||||
* to establish an end-to-end encryption via a key exchange.
|
* to establish an end-to-end encryption via a key exchange.
|
||||||
|
* @param iv The initialization vector bytes.
|
||||||
|
* @param salt The salt bytes.
|
||||||
|
* @param publicKey The public key.
|
||||||
*/
|
*/
|
||||||
public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {}
|
public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* Messages pertaining to the establishment of a connection with clients.
|
||||||
|
*/
|
||||||
|
package nl.andrewl.concord_core.msg.types.client_setup;
|
|
@ -0,0 +1,10 @@
|
||||||
|
/**
|
||||||
|
* Contains all the various message types which can be sent between the server
|
||||||
|
* and client.
|
||||||
|
* <p>
|
||||||
|
* <em>Note that not all message types defined here may be supported by the
|
||||||
|
* latest version of Concord. See {@link nl.andrewl.concord_core.msg.Serializer}
|
||||||
|
* for the definitive list.</em>
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
package nl.andrewl.concord_core.msg.types;
|
|
@ -63,13 +63,16 @@ public class ChainedDataOutputStream {
|
||||||
public <T extends Message> ChainedDataOutputStream writeArray(T[] array) throws IOException {
|
public <T extends Message> ChainedDataOutputStream writeArray(T[] array) throws IOException {
|
||||||
this.out.writeInt(array.length);
|
this.out.writeInt(array.length);
|
||||||
for (var item : array) {
|
for (var item : array) {
|
||||||
item.getType().writer().write(item, this);
|
writeMessage(item);
|
||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public <T extends Message> ChainedDataOutputStream writeMessage(Message msg) throws IOException {
|
public <T extends Message> 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;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package nl.andrewl.concord_core.util;
|
package nl.andrewl.concord_core.util;
|
||||||
|
|
||||||
import nl.andrewl.concord_core.msg.Message;
|
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.DataInputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
@ -45,7 +45,7 @@ public class ExtendedDataInputStream extends DataInputStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
public <T extends Message> T[] readArray(MessageType<T> type) throws IOException {
|
public <T extends Message> T[] readArray(MessageTypeSerializer<T> type) throws IOException {
|
||||||
int length = super.readInt();
|
int length = super.readInt();
|
||||||
T[] array = (T[]) Array.newInstance(type.messageClass(), length);
|
T[] array = (T[]) Array.newInstance(type.messageClass(), length);
|
||||||
for (int i = 0; i < length; i++) {
|
for (int i = 0; i < length; i++) {
|
||||||
|
@ -76,10 +76,10 @@ public class ExtendedDataInputStream extends DataInputStream {
|
||||||
int length = this.readInt();
|
int length = this.readInt();
|
||||||
return this.readNBytes(length);
|
return this.readNBytes(length);
|
||||||
} else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
|
} else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
|
||||||
var messageType = MessageType.get((Class<? extends Message>) type.getComponentType());
|
var messageType = MessageTypeSerializer.get((Class<? extends Message>) type.getComponentType());
|
||||||
return this.readArray(messageType);
|
return this.readArray(messageType);
|
||||||
} else if (Message.class.isAssignableFrom(type)) {
|
} else if (Message.class.isAssignableFrom(type)) {
|
||||||
var messageType = MessageType.get((Class<? extends Message>) type);
|
var messageType = MessageTypeSerializer.get((Class<? extends Message>) type);
|
||||||
return messageType.reader().read(this);
|
return messageType.reader().read(this);
|
||||||
} else {
|
} else {
|
||||||
throw new IOException("Unsupported object type: " + type.getSimpleName());
|
throw new IOException("Unsupported object type: " + type.getSimpleName());
|
||||||
|
|
|
@ -1,3 +1,8 @@
|
||||||
package nl.andrewl.concord_core.util;
|
package nl.andrewl.concord_core.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple generic pair of two objects.
|
||||||
|
* @param <A> The first object.
|
||||||
|
* @param <B> The second object.
|
||||||
|
*/
|
||||||
public record Pair<A, B>(A first, B second) {}
|
public record Pair<A, B>(A first, B second) {}
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
package nl.andrewl.concord_core.util;
|
package nl.andrewl.concord_core.util;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple generic triple of objects.
|
||||||
|
* @param <A> The first object.
|
||||||
|
* @param <B> The second object.
|
||||||
|
* @param <C> The third object.
|
||||||
|
*/
|
||||||
public record Triple<A, B, C> (A first, B second, C third) {}
|
public record Triple<A, B, C> (A first, B second, C third) {}
|
||||||
|
|
|
@ -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;
|
|
@ -0,0 +1,15 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Concord</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h1>Concord</h1>
|
||||||
|
<p>
|
||||||
|
More content coming soon!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
19
pom.xml
19
pom.xml
|
@ -16,9 +16,10 @@
|
||||||
</modules>
|
</modules>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<maven.compiler.source>16</maven.compiler.source>
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
<maven.compiler.target>16</maven.compiler.target>
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
<java.version>16</java.version>
|
<maven.compiler.release>17</maven.compiler.release>
|
||||||
|
<java.version>17</java.version>
|
||||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
|
@ -47,6 +48,18 @@
|
||||||
</annotationProcessorPaths>
|
</annotationProcessorPaths>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-javadoc-plugin</artifactId>
|
||||||
|
<version>3.3.1</version>
|
||||||
|
<configuration>
|
||||||
|
<detectLinks>false</detectLinks>
|
||||||
|
<detectOfflineLinks>false</detectOfflineLinks>
|
||||||
|
<failOnError>false</failOnError>
|
||||||
|
<failOnWarnings>false</failOnWarnings>
|
||||||
|
<show>private</show>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
</project>
|
</project>
|
|
@ -42,6 +42,12 @@
|
||||||
<artifactId>jackson-annotations</artifactId>
|
<artifactId>jackson-annotations</artifactId>
|
||||||
<version>2.12.4</version>
|
<version>2.12.4</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- BCrypt implementation for password hashing. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>at.favre.lib</groupId>
|
||||||
|
<artifactId>bcrypt</artifactId>
|
||||||
|
<version>0.9.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ module concord_server {
|
||||||
requires com.fasterxml.jackson.databind;
|
requires com.fasterxml.jackson.databind;
|
||||||
requires com.fasterxml.jackson.core;
|
requires com.fasterxml.jackson.core;
|
||||||
requires com.fasterxml.jackson.annotation;
|
requires com.fasterxml.jackson.annotation;
|
||||||
|
requires bcrypt;
|
||||||
|
|
||||||
requires java.base;
|
requires java.base;
|
||||||
requires java.logging;
|
requires java.logging;
|
||||||
|
|
|
@ -85,6 +85,8 @@ public class ConcordServer implements Runnable {
|
||||||
private final ClientManager clientManager;
|
private final ClientManager clientManager;
|
||||||
|
|
||||||
private final DiscoveryServerPublisher discoveryServerPublisher;
|
private final DiscoveryServerPublisher discoveryServerPublisher;
|
||||||
|
|
||||||
|
@Getter
|
||||||
private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
|
private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
public ConcordServer() throws IOException {
|
public ConcordServer() throws IOException {
|
||||||
|
@ -122,6 +124,9 @@ public class ConcordServer implements Runnable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The server's metadata.
|
||||||
|
*/
|
||||||
public ServerMetaData getMetaData() {
|
public ServerMetaData getMetaData() {
|
||||||
return new ServerMetaData(
|
return new ServerMetaData(
|
||||||
this.config.getName(),
|
this.config.getName(),
|
||||||
|
|
|
@ -33,7 +33,7 @@ public class Channel implements Comparable<Channel> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A document collection which holds all messages created in this channel,
|
* A document collection which holds all messages created in this channel,
|
||||||
* indexed on id, timestamp, message, and sender's nickname.
|
* indexed on id, timestamp, message, and sender's username.
|
||||||
*/
|
*/
|
||||||
private final NitriteCollection messageCollection;
|
private final NitriteCollection messageCollection;
|
||||||
|
|
||||||
|
|
|
@ -9,7 +9,7 @@ import nl.andrewl.concord_server.cli.ServerCliCommand;
|
||||||
public class ListClientsCommand implements ServerCliCommand {
|
public class ListClientsCommand implements ServerCliCommand {
|
||||||
@Override
|
@Override
|
||||||
public void handle(ConcordServer server, String[] args) throws Exception {
|
public void handle(ConcordServer server, String[] args) throws Exception {
|
||||||
var users = server.getClientManager().getClients();
|
var users = server.getClientManager().getConnectedClients();
|
||||||
if (users.isEmpty()) {
|
if (users.isEmpty()) {
|
||||||
System.out.println("There are no connected clients.");
|
System.out.println("There are no connected clients.");
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {}
|
|
@ -1,14 +1,11 @@
|
||||||
package nl.andrewl.concord_server.client;
|
package nl.andrewl.concord_server.client;
|
||||||
|
|
||||||
import nl.andrewl.concord_core.msg.Message;
|
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.ServerUsers;
|
||||||
import nl.andrewl.concord_core.msg.types.UserData;
|
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.*;
|
||||||
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
|
|
||||||
import nl.andrewl.concord_server.ConcordServer;
|
import nl.andrewl.concord_server.ConcordServer;
|
||||||
import nl.andrewl.concord_server.util.CollectionUtils;
|
import nl.andrewl.concord_server.util.CollectionUtils;
|
||||||
import nl.andrewl.concord_server.util.StringUtils;
|
|
||||||
import org.dizitart.no2.Document;
|
import org.dizitart.no2.Document;
|
||||||
import org.dizitart.no2.IndexType;
|
import org.dizitart.no2.IndexType;
|
||||||
import org.dizitart.no2.NitriteCollection;
|
import org.dizitart.no2.NitriteCollection;
|
||||||
|
@ -18,6 +15,7 @@ import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -27,58 +25,170 @@ import java.util.stream.Collectors;
|
||||||
public class ClientManager {
|
public class ClientManager {
|
||||||
private final ConcordServer server;
|
private final ConcordServer server;
|
||||||
private final Map<UUID, ClientThread> clients;
|
private final Map<UUID, ClientThread> clients;
|
||||||
|
private final Map<UUID, ClientThread> pendingClients;
|
||||||
private final NitriteCollection userCollection;
|
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) {
|
public ClientManager(ConcordServer server) {
|
||||||
this.server = server;
|
this.server = server;
|
||||||
this.clients = new ConcurrentHashMap<>();
|
this.clients = new ConcurrentHashMap<>();
|
||||||
|
this.pendingClients = new ConcurrentHashMap<>();
|
||||||
this.userCollection = server.getDb().getCollection("users");
|
this.userCollection = server.getDb().getCollection("users");
|
||||||
CollectionUtils.ensureIndexes(this.userCollection, Map.of(
|
CollectionUtils.ensureIndexes(this.userCollection, Map.of(
|
||||||
"id", IndexType.Unique,
|
"id", IndexType.Unique,
|
||||||
"sessionToken", IndexType.Unique,
|
"username", IndexType.Unique,
|
||||||
"nickname", IndexType.Fulltext
|
"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
|
* Handles an attempt by a new client to register as a user for this server.
|
||||||
* client thread has received the correct identification information from
|
* If the server is set to automatically accept all new clients, the new
|
||||||
* the client. The server will register the client in its global set of
|
* user is registered and the client is sent a {@link RegistrationStatus}
|
||||||
* connected clients, and it will immediately move the client to the default
|
* with the {@link RegistrationStatus.Type#ACCEPTED} value, closely followed
|
||||||
* channel.
|
* by a {@link ServerWelcome} message. Otherwise, the client is sent a
|
||||||
* <p>
|
* {@link RegistrationStatus.Type#PENDING} response, which indicates that
|
||||||
* If the client provides a session token with their identification
|
* the client's registration is pending approval. The client can choose to
|
||||||
* message, then we should load their data from our database, otherwise
|
* remain connected and wait for approval, or disconnect and try logging in
|
||||||
* we assume this is a new client.
|
* later.
|
||||||
* </p>
|
*
|
||||||
* @param identification The client's identification data.
|
* @param registration The client's registration information.
|
||||||
* @param clientThread The client manager thread.
|
* @param clientThread The client thread.
|
||||||
|
* @throws InvalidIdentificationException If the user's registration info is
|
||||||
|
* not valid.
|
||||||
*/
|
*/
|
||||||
public void handleLogIn(Identification identification, ClientThread clientThread) {
|
public void handleRegistration(ClientRegistration registration, ClientThread clientThread) throws InvalidIdentificationException {
|
||||||
ClientConnectionData data;
|
Document userDoc = this.userCollection.find(Filters.eq("username", registration.username())).firstOrDefault();
|
||||||
try {
|
if (userDoc != null) throw new InvalidIdentificationException("Username is taken.");
|
||||||
data = identification.sessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification);
|
if (this.server.getConfig().isAcceptAllNewClients()) {
|
||||||
} catch (InvalidIdentificationException e) {
|
var clientData = this.authService.registerNewClient(registration);
|
||||||
clientThread.sendToClient(Error.warning(e.getMessage()));
|
clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED, null));
|
||||||
return;
|
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);
|
* Handles an attempt by a new client to login as an existing user to the
|
||||||
clientThread.setClientNickname(data.nickname);
|
* server. If the user's credentials are valid, then the following can
|
||||||
|
* result:
|
||||||
|
* <ul>
|
||||||
|
* <li>If the user's registration is still pending, they will be sent a
|
||||||
|
* {@link RegistrationStatus.Type#PENDING} response, to indicate that
|
||||||
|
* their registration is still pending approval.</li>
|
||||||
|
* <li>For non-pending (normal) users, they will be logged into the
|
||||||
|
* server and sent a {@link ServerWelcome} message.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* @param login The client's login credentials.
|
||||||
|
* @param clientThread The client thread managing the connection.
|
||||||
|
* @throws InvalidIdentificationException If the client's credentials are
|
||||||
|
* incorrect.
|
||||||
|
*/
|
||||||
|
public void handleLogin(ClientLogin login, ClientThread clientThread) throws InvalidIdentificationException {
|
||||||
|
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();
|
var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow();
|
||||||
clientThread.sendToClient(new ServerWelcome(data.id, data.sessionToken, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData()));
|
clientThread.sendToClient(new ServerWelcome(clientData.id(), clientData.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.
|
this.clients.put(clientData.id(), clientThread); // We only add the client after sending the welcome, to make sure that we send the welcome packet first.
|
||||||
defaultChannel.addClient(clientThread);
|
defaultChannel.addClient(clientThread);
|
||||||
clientThread.setCurrentChannel(defaultChannel);
|
clientThread.setCurrentChannel(defaultChannel);
|
||||||
System.out.printf(
|
this.broadcast(new ServerUsers(this.getConnectedClients().toArray(new UserData[0])));
|
||||||
"Client %s(%s) joined%s, and was put into %s.\n",
|
}
|
||||||
data.nickname,
|
|
||||||
data.id,
|
/**
|
||||||
data.newClient ? " for the first time" : "",
|
* Initializes a connection to a client whose registration is pending, thus
|
||||||
defaultChannel
|
* they should simply keep their connection alive, and receive a {@link RegistrationStatus.Type#PENDING}
|
||||||
);
|
* message, instead of a {@link ServerWelcome}.
|
||||||
this.broadcast(new ServerUsers(this.getClients().toArray(new UserData[0])));
|
* @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.
|
* @param clientId The id of the client to remove.
|
||||||
*/
|
*/
|
||||||
public void handleLogOut(UUID clientId) {
|
public void handleLogOut(UUID clientId) {
|
||||||
|
var pendingClient = this.pendingClients.remove(clientId);
|
||||||
|
if (pendingClient != null) {
|
||||||
|
pendingClient.shutdown();
|
||||||
|
}
|
||||||
var client = this.clients.remove(clientId);
|
var client = this.clients.remove(clientId);
|
||||||
if (client != null) {
|
if (client != null) {
|
||||||
client.getCurrentChannel().removeClient(client);
|
client.getCurrentChannel().removeClient(client);
|
||||||
client.shutdown();
|
client.shutdown();
|
||||||
System.out.println("Client " + client + " has disconnected.");
|
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<UserData> getClients() {
|
/**
|
||||||
|
* @return The list of connected clients.
|
||||||
|
*/
|
||||||
|
public List<UserData> getConnectedClients() {
|
||||||
return this.clients.values().stream()
|
return this.clients.values().stream()
|
||||||
.sorted(Comparator.comparing(ClientThread::getClientNickname))
|
.sorted(Comparator.comparing(ClientThread::getClientNickname))
|
||||||
.map(ClientThread::toData)
|
.map(ClientThread::toData)
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return The list of connected, pending clients.
|
||||||
|
*/
|
||||||
|
public List<UserData> 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<UUID> getConnectedIds() {
|
public Set<UUID> getConnectedIds() {
|
||||||
return this.clients.keySet();
|
return this.clients.keySet();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to find a connected client with the given id.
|
||||||
|
* @param id The id to look for.
|
||||||
|
* @return An optional client thread.
|
||||||
|
*/
|
||||||
public Optional<ClientThread> getClientById(UUID id) {
|
public Optional<ClientThread> getClientById(UUID id) {
|
||||||
return Optional.ofNullable(this.clients.get(id));
|
return Optional.ofNullable(this.clients.get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {}
|
/**
|
||||||
|
* Tries to find a pending client with the given id.
|
||||||
private ClientConnectionData getClientDataFromDb(Identification identification) throws InvalidIdentificationException {
|
* @param id The id to look for.
|
||||||
var cursor = this.userCollection.find(Filters.eq("sessionToken", identification.sessionToken()));
|
* @return An optional client thread.
|
||||||
Document doc = cursor.firstOrDefault();
|
*/
|
||||||
if (doc != null) {
|
public Optional<ClientThread> getPendingClientById(UUID id) {
|
||||||
UUID id = doc.get("id", UUID.class);
|
return Optional.ofNullable(this.pendingClients.get(id));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,11 @@ import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
||||||
import nl.andrewl.concord_core.msg.Encryption;
|
import nl.andrewl.concord_core.msg.Encryption;
|
||||||
import nl.andrewl.concord_core.msg.Message;
|
import nl.andrewl.concord_core.msg.Message;
|
||||||
import nl.andrewl.concord_core.msg.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.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.ConcordServer;
|
||||||
import nl.andrewl.concord_server.channel.Channel;
|
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.");
|
System.err.println("Could not establish end-to-end encryption with the client.");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
final var clientManager = this.server.getClientManager();
|
||||||
int attempts = 0;
|
int attempts = 0;
|
||||||
while (attempts < 5) {
|
while (attempts < 5) {
|
||||||
try {
|
try {
|
||||||
var msg = this.server.getSerializer().readMessage(this.in);
|
var msg = this.server.getSerializer().readMessage(this.in);
|
||||||
if (msg instanceof Identification id) {
|
if (msg instanceof ClientRegistration cr) {
|
||||||
this.server.getClientManager().handleLogIn(id, this);
|
clientManager.handleRegistration(cr, this);
|
||||||
return true;
|
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) {
|
} catch (IOException e) {
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
|
|
|
@ -20,6 +20,7 @@ public final class ServerConfig {
|
||||||
private String name;
|
private String name;
|
||||||
private String description;
|
private String description;
|
||||||
private int port;
|
private int port;
|
||||||
|
private boolean acceptAllNewClients;
|
||||||
private int chatHistoryMaxCount;
|
private int chatHistoryMaxCount;
|
||||||
private int chatHistoryDefaultCount;
|
private int chatHistoryDefaultCount;
|
||||||
private int maxMessageLength;
|
private int maxMessageLength;
|
||||||
|
@ -51,6 +52,7 @@ public final class ServerConfig {
|
||||||
"My Concord Server",
|
"My Concord Server",
|
||||||
"A concord server for my friends and I.",
|
"A concord server for my friends and I.",
|
||||||
8123,
|
8123,
|
||||||
|
false,
|
||||||
100,
|
100,
|
||||||
50,
|
50,
|
||||||
8192,
|
8192,
|
||||||
|
|
Loading…
Reference in New Issue