diff --git a/README.md b/README.md index 22ff2a9..80bd162 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This platform will be organized by many independent servers, each of which will - [x] Private message between users in a server. **No support for private messaging users outside the context of a server.** - [ ] Banning users from the server. - [ ] Voice channels. -- [ ] Persistent users. Connect and disconnect from a server multiple times, while keeping your information intact. +- [x] Persistent users. Connect and disconnect from a server multiple times, while keeping your information intact. Here's a short demonstration of its current features: diff --git a/client/pom.xml b/client/pom.xml index be7bf11..7a53d35 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -33,6 +33,25 @@ jna-platform 5.9.0 + + + + com.fasterxml.jackson.core + jackson-databind + 2.12.4 + + + + com.fasterxml.jackson.core + jackson-core + 2.12.4 + + + + com.fasterxml.jackson.core + jackson-annotations + 2.12.4 + diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java index bca82e3..1928370 100644 --- a/client/src/main/java/module-info.java +++ b/client/src/main/java/module-info.java @@ -4,4 +4,7 @@ module concord_client { requires com.sun.jna; requires com.sun.jna.platform; requires static lombok; + requires com.fasterxml.jackson.databind; + requires com.fasterxml.jackson.core; + requires com.fasterxml.jackson.annotation; } \ No newline at end of file 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 2c52bc5..403c89c 100644 --- a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java +++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java @@ -1,5 +1,6 @@ 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; @@ -23,7 +24,11 @@ import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class ConcordClient implements Runnable { private final Socket socket; @@ -38,13 +43,13 @@ public class ConcordClient implements Runnable { private volatile boolean running; - public ConcordClient(String host, int port, String nickname) throws IOException { + public ConcordClient(String host, int port, String nickname, Path tokensFile) throws IOException { this.eventManager = new EventManager(this); this.socket = new Socket(host, port); this.in = new DataInputStream(this.socket.getInputStream()); this.out = new DataOutputStream(this.socket.getOutputStream()); this.serializer = new Serializer(); - this.model = this.initializeConnectionToServer(nickname); + this.model = this.initializeConnectionToServer(nickname, tokensFile); // Add event listeners. this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler()); @@ -61,16 +66,19 @@ public class ConcordClient implements Runnable { * 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) throws IOException { - this.serializer.writeMessage(new Identification(nickname), this.out); + 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.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getCurrentChannelName(), welcome.getMetaData()); + this.saveSessionToken(welcome.getSessionToken(), tokensFile); // Start fetching initial data for the channel we were initially put into. this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), "")); return model; @@ -117,6 +125,44 @@ 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 { 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 5e02783..67a3a9f 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 @@ -6,6 +6,7 @@ import com.googlecode.lanterna.input.KeyStroke; import nl.andrewl.concord_client.ConcordClient; import java.io.IOException; +import java.nio.file.Path; import java.util.concurrent.atomic.AtomicBoolean; public class MainWindow extends BasicWindow { @@ -49,7 +50,7 @@ public class MainWindow extends BasicWindow { if (nickname == null) return; try { - var client = new ConcordClient(host, port, nickname); + var client = new ConcordClient(host, port, nickname, Path.of("concord-session-tokens.json")); var chatPanel = new ServerPanel(client, this); client.getModel().addListener(chatPanel); new Thread(client).start(); diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java index 7a5d9a6..e36ab54 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java @@ -1,39 +1,56 @@ package nl.andrewl.concord_core.msg.types; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.MessageUtils; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import static nl.andrewl.concord_core.msg.MessageUtils.*; + /** * 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. */ @Data +@AllArgsConstructor @NoArgsConstructor public class Identification implements Message { + /** + * The nickname that a client wants to be identified by when in the server. + * If a valid session token is provided, this can be left as null, and the + * user will be given the same nickname they had in their previous session. + */ private String nickname; + /** + * A session token that's used to uniquely identify this client as the same + * as one who has previously connected to the server. If this is null, the + * client is indicating that they have not connected to this server before. + */ + private String sessionToken; + public Identification(String nickname) { this.nickname = nickname; } @Override public int getByteCount() { - return MessageUtils.getByteSize(this.nickname); + return getByteSize(this.nickname) + getByteSize(sessionToken); } @Override public void write(DataOutputStream o) throws IOException { - MessageUtils.writeString(this.nickname, o); + writeString(this.nickname, o); + writeString(this.sessionToken, o); } @Override public void read(DataInputStream i) throws IOException { - this.nickname = MessageUtils.readString(i); + this.nickname = readString(i); + this.sessionToken = readString(i); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java index 17864b6..293e05d 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java @@ -20,19 +20,41 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; @NoArgsConstructor @AllArgsConstructor public class ServerWelcome implements Message { + /** + * The unique id of this client. + */ private UUID clientId; + + /** + * The token which this client can use to reconnect to the server later and + * still be recognized as the same user. + */ + private String sessionToken; + + /** + * The id of the channel that the user has been placed in. + */ private UUID currentChannelId; + + /** + * The name of the channel that the user has been placed in. + */ private String currentChannelName; + + /** + * Information about the server's structure. + */ private ServerMetaData metaData; @Override public int getByteCount() { - return 2 * UUID_BYTES + getByteSize(this.currentChannelName) + this.metaData.getByteCount(); + return 2 * UUID_BYTES + getByteSize(this.sessionToken) + getByteSize(this.currentChannelName) + this.metaData.getByteCount(); } @Override public void write(DataOutputStream o) throws IOException { writeUUID(this.clientId, o); + writeString(this.sessionToken, o); writeUUID(this.currentChannelId, o); writeString(this.currentChannelName, o); this.metaData.write(o); @@ -41,6 +63,7 @@ public class ServerWelcome implements Message { @Override public void read(DataInputStream i) throws IOException { this.clientId = readUUID(i); + this.sessionToken = readString(i); this.currentChannelId = readUUID(i); this.metaData = new ServerMetaData(); this.currentChannelName = readString(i); 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 5f90ec0..50b9ae3 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 @@ -49,31 +49,19 @@ public class Channel { } /** - * Adds a client to this channel. Also sends an update to all clients, - * including the new one, telling them that a user has joined. + * Adds a client to this channel. * @param clientThread The client to add. */ public void addClient(ClientThread clientThread) { this.connectedClients.add(clientThread); -// try { -// this.sendMessage(new ChannelUsersResponse(this.getUserData())); -// } catch (IOException e) { -// e.printStackTrace(); -// } } /** - * Removes a client from this channel. Also sends an update to all the - * clients that are still connected, telling them that a user has left. + * Removes a client from this channel. * @param clientThread The client to remove. */ public void removeClient(ClientThread clientThread) { this.connectedClients.remove(clientThread); -// try { -// this.sendMessage(new ChannelUsersResponse(this.getUserData())); -// } catch (IOException e) { -// e.printStackTrace(); -// } } /** 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 44cbb28..0eda45c 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,11 +1,15 @@ package nl.andrewl.concord_server.client; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.types.Identification; -import nl.andrewl.concord_core.msg.types.ServerUsers; -import nl.andrewl.concord_core.msg.types.ServerWelcome; -import nl.andrewl.concord_core.msg.types.UserData; +import nl.andrewl.concord_core.msg.types.Error; +import nl.andrewl.concord_core.msg.types.*; 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.io.ByteArrayOutputStream; import java.io.IOException; @@ -20,10 +24,17 @@ import java.util.stream.Collectors; public class ClientManager { private final ConcordServer server; private final Map clients; + private final NitriteCollection userCollection; public ClientManager(ConcordServer server) { this.server = server; this.clients = new ConcurrentHashMap<>(); + this.userCollection = server.getDb().getCollection("users"); + CollectionUtils.ensureIndexes(this.userCollection, Map.of( + "id", IndexType.Unique, + "sessionToken", IndexType.Unique, + "nickname", IndexType.Fulltext + )); } /** @@ -36,18 +47,29 @@ public class ClientManager { * @param clientThread The client manager thread. */ public void registerClient(Identification identification, ClientThread clientThread) { - var id = this.server.getIdProvider().newId(); - System.out.printf("Client \"%s\" joined with id %s.\n", identification.getNickname(), id); - this.clients.put(id, clientThread); - clientThread.setClientId(id); - clientThread.setClientNickname(identification.getNickname()); - // Immediately add the client to the default channel and send the initial welcome message. + ClientConnectionData data; + try { + data = identification.getSessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification); + } catch (InvalidIdentificationException e) { + clientThread.sendToClient(Error.warning(e.getMessage())); + return; + } + + this.clients.put(data.id, clientThread); + clientThread.setClientId(data.id); + clientThread.setClientNickname(data.nickname); var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow(); - clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData())); + 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. defaultChannel.addClient(clientThread); clientThread.setCurrentChannel(defaultChannel); - System.out.println("Moved client " + clientThread + " to " + 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())); } @@ -98,4 +120,43 @@ public class ClientManager { 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.getSessionToken())); + Document doc = cursor.firstOrDefault(); + if (doc != null) { + UUID id = doc.get("id", UUID.class); + String nickname = identification.getNickname(); + 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.getNickname(); + 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); + } } diff --git a/server/src/main/java/nl/andrewl/concord_server/client/InvalidIdentificationException.java b/server/src/main/java/nl/andrewl/concord_server/client/InvalidIdentificationException.java new file mode 100644 index 0000000..ffc45ad --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/client/InvalidIdentificationException.java @@ -0,0 +1,10 @@ +package nl.andrewl.concord_server.client; + +/** + * Exception that's thrown when a client's identification information is invalid. + */ +public class InvalidIdentificationException extends Exception { + public InvalidIdentificationException(String message) { + super(message); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/util/Pair.java b/server/src/main/java/nl/andrewl/concord_server/util/Pair.java new file mode 100644 index 0000000..ddce77d --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/util/Pair.java @@ -0,0 +1,3 @@ +package nl.andrewl.concord_server.util; + +public record Pair(A first, B second) {} diff --git a/server/src/main/java/nl/andrewl/concord_server/util/StringUtils.java b/server/src/main/java/nl/andrewl/concord_server/util/StringUtils.java new file mode 100644 index 0000000..b74e232 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/util/StringUtils.java @@ -0,0 +1,17 @@ +package nl.andrewl.concord_server.util; + +import java.security.SecureRandom; +import java.util.Random; + +public class StringUtils { + + public static String random(int length) { + Random random = new SecureRandom(); + final String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-=+[]{}()<>"; + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(alphabet.charAt(random.nextInt(alphabet.length()))); + } + return sb.toString(); + } +}