From d34a4072847c3ece267fa2ac9d864a1659561b0b Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Sat, 25 Sep 2021 13:08:14 +0200 Subject: [PATCH] Updated user login system to use unique usernames, login and logout stuff. Client not yet updated to new authentication flow. --- .../nl/andrewl/concord_core/msg/Message.java | 16 +- ...geType.java => MessageTypeSerializer.java} | 14 +- .../concord_core/msg/MessageUtils.java | 8 +- .../andrewl/concord_core/msg/Serializer.java | 46 +++-- .../msg/types/client_setup/ClientLogin.java | 9 + ...istration.java => ClientRegistration.java} | 7 +- .../client_setup/ClientSessionResume.java | 9 + .../types/client_setup/Identification.java | 11 -- .../client_setup/RegistrationStatus.java | 15 ++ .../util/ChainedDataOutputStream.java | 7 +- .../util/ExtendedDataInputStream.java | 8 +- server/pom.xml | 6 + server/src/main/java/module-info.java | 1 + .../andrewl/concord_server/ConcordServer.java | 2 + .../cli/command/ListClientsCommand.java | 2 +- .../client/AuthenticationService.java | 114 +++++++++++ .../concord_server/client/ClientManager.java | 181 ++++++++++-------- .../concord_server/client/ClientThread.java | 20 +- .../concord_server/config/ServerConfig.java | 2 + 19 files changed, 344 insertions(+), 134 deletions(-) rename core/src/main/java/nl/andrewl/concord_core/msg/{MessageType.java => MessageTypeSerializer.java} (87%) create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientLogin.java rename core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/{Registration.java => ClientRegistration.java} (60%) create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java delete mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java create mode 100644 server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Message.java b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java index 3be10cb..2c83a71 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Message.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java @@ -8,12 +8,22 @@ package nl.andrewl.concord_core.msg; *

*/ public interface Message { + /** + * Convenience method to get the serializer for this message's type, using + * the static auto-generated set of serializers. + * @param The message type. + * @return The serializer to use to read and write messages of this type. + */ @SuppressWarnings("unchecked") - default MessageType getType() { - return MessageType.get((Class) this.getClass()); + default MessageTypeSerializer getTypeSerializer() { + return MessageTypeSerializer.get((Class) this.getClass()); } + /** + * Convenience method to determine the size of this message in bytes. + * @return The size of this message, in bytes. + */ default int byteSize() { - return getType().byteSizeFunction().apply(this); + return getTypeSerializer().byteSizeFunction().apply(this); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/MessageType.java b/core/src/main/java/nl/andrewl/concord_core/msg/MessageTypeSerializer.java similarity index 87% rename from core/src/main/java/nl/andrewl/concord_core/msg/MessageType.java rename to core/src/main/java/nl/andrewl/concord_core/msg/MessageTypeSerializer.java index c70bc14..ca961b7 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/MessageType.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/MessageTypeSerializer.java @@ -19,24 +19,24 @@ import java.util.function.Function; * @param reader A reader that can read messages from an input stream. * @param writer A writer that write messages from an input stream. */ -public record MessageType( +public record MessageTypeSerializer( Class messageClass, Function byteSizeFunction, MessageReader reader, MessageWriter writer ) { - private static final Map, MessageType> generatedMessageTypes = new HashMap<>(); + private static final Map, MessageTypeSerializer> generatedMessageTypes = new HashMap<>(); /** - * Gets the {@link MessageType} instance for a given message class, and + * Gets the {@link MessageTypeSerializer} instance for a given message class, and * generates a new implementation if none exists yet. * @param messageClass The class of the message to get a type for. * @param The type of the message. * @return The message type. */ @SuppressWarnings("unchecked") - public static MessageType get(Class messageClass) { - return (MessageType) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class) c)); + public static MessageTypeSerializer get(Class messageClass) { + return (MessageTypeSerializer) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class) c)); } /** @@ -49,7 +49,7 @@ public record MessageType( * @param The type of the message. * @return A message type instance. */ - public static MessageType generateForRecord(Class messageTypeClass) { + public static MessageTypeSerializer generateForRecord(Class messageTypeClass) { RecordComponent[] components = messageTypeClass.getRecordComponents(); Constructor constructor; try { @@ -58,7 +58,7 @@ public record MessageType( } catch (NoSuchMethodException e) { throw new IllegalArgumentException(e); } - return new MessageType<>( + return new MessageTypeSerializer<>( messageTypeClass, generateByteSizeFunction(components), generateReader(constructor), diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java index b813e14..61a3f87 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java @@ -35,10 +35,14 @@ public class MessageUtils { return size; } + public static int getByteSize(Message msg) { + return 1 + (msg == null ? 0 : msg.byteSize()); + } + public static int getByteSize(T[] items) { int count = Integer.BYTES; for (var item : items) { - count += item.byteSize(); + count += getByteSize(items); } return count; } @@ -59,7 +63,7 @@ public class MessageUtils { } else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) { return getByteSize((Message[]) o); } else if (o instanceof Message) { - return ((Message) o).byteSize(); + return getByteSize((Message) o); } else { throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName()); } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java index f7b8945..4030647 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java @@ -3,15 +3,13 @@ package nl.andrewl.concord_core.msg; import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_core.msg.types.ServerMetaData; import nl.andrewl.concord_core.msg.types.ServerUsers; +import nl.andrewl.concord_core.msg.types.UserData; import nl.andrewl.concord_core.msg.types.channel.CreateThread; import nl.andrewl.concord_core.msg.types.channel.MoveToChannel; import nl.andrewl.concord_core.msg.types.chat.Chat; import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse; -import nl.andrewl.concord_core.msg.types.client_setup.Identification; -import nl.andrewl.concord_core.msg.types.client_setup.KeyData; -import nl.andrewl.concord_core.msg.types.client_setup.Registration; -import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome; +import nl.andrewl.concord_core.msg.types.client_setup.*; import nl.andrewl.concord_core.util.ChainedDataOutputStream; import nl.andrewl.concord_core.util.ExtendedDataInputStream; @@ -20,6 +18,7 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -32,31 +31,36 @@ public class Serializer { * The mapping which defines each supported message type and the byte value * used to identify it when reading and writing messages. */ - private final Map> messageTypes = new HashMap<>(); + private final Map> messageTypes = new HashMap<>(); /** * An inverse of {@link Serializer#messageTypes} which is used to look up a * message's byte value when you know the class of the message. */ - private final Map, Byte> inverseMessageTypes = new HashMap<>(); + private final Map, Byte> inverseMessageTypes = new HashMap<>(); /** * Constructs a new serializer instance, with a standard set of supported * message types. */ public Serializer() { - registerType(0, Identification.class); - registerType(1, ServerWelcome.class); - registerType(2, Chat.class); - registerType(3, MoveToChannel.class); - registerType(4, ChatHistoryRequest.class); - registerType(5, ChatHistoryResponse.class); - registerType(6, Registration.class); - registerType(7, ServerUsers.class); - registerType(8, ServerMetaData.class); - registerType(9, Error.class); - registerType(10, CreateThread.class); - registerType(11, KeyData.class); + List> messageClasses = List.of( + // Utility messages. + Error.class, + UserData.class, + ServerUsers.class, + // Client setup messages. + KeyData.class, ClientRegistration.class, ClientLogin.class, ClientSessionResume.class, + RegistrationStatus.class, ServerWelcome.class, ServerMetaData.class, + // Chat messages. + Chat.class, ChatHistoryRequest.class, ChatHistoryResponse.class, + // Channel messages. + MoveToChannel.class, + CreateThread.class + ); + for (int id = 0; id < messageClasses.size(); id++) { + registerType(id, messageClasses.get(id)); + } } /** @@ -67,7 +71,7 @@ public class Serializer { * @param messageClass The type of message associated with the given id. */ private synchronized void registerType(int id, Class messageClass) { - MessageType type = MessageType.get(messageClass); + MessageTypeSerializer type = MessageTypeSerializer.get(messageClass); messageTypes.put((byte) id, type); inverseMessageTypes.put(type, (byte) id); } @@ -104,12 +108,12 @@ public class Serializer { */ public void writeMessage(Message msg, OutputStream o) throws IOException { DataOutputStream d = new DataOutputStream(o); - Byte typeId = inverseMessageTypes.get(msg.getType()); + Byte typeId = inverseMessageTypes.get(msg.getTypeSerializer()); if (typeId == null) { throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName()); } d.writeByte(typeId); - msg.getType().writer().write(msg, new ChainedDataOutputStream(d)); + msg.getTypeSerializer().writer().write(msg, new ChainedDataOutputStream(d)); d.flush(); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientLogin.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientLogin.java new file mode 100644 index 0000000..6455aff --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientLogin.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_core.msg.types.client_setup; + +import nl.andrewl.concord_core.msg.Message; + +/** + * This message is sent by clients to log into a server that they have already + * registered with, but don't have a valid session token for. + */ +public record ClientLogin(String username, String password) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Registration.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientRegistration.java similarity index 60% rename from core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Registration.java rename to core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientRegistration.java index 4421dc5..a8a2f74 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Registration.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientRegistration.java @@ -6,4 +6,9 @@ import nl.andrewl.concord_core.msg.Message; * The data that new users should send to a server in order to register in that * server. */ -public record Registration (String username, String password) implements Message {} +public record ClientRegistration( + String name, + String description, + String username, + String password +) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java new file mode 100644 index 0000000..ee9881e --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/ClientSessionResume.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_core.msg.types.client_setup; + +import nl.andrewl.concord_core.msg.Message; + +/** + * This message is sent by the client to log into a server using a session token + * instead of a username/password combination. + */ +public record ClientSessionResume(String sessionToken) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java deleted file mode 100644 index ed2d237..0000000 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/Identification.java +++ /dev/null @@ -1,11 +0,0 @@ -package nl.andrewl.concord_core.msg.types.client_setup; - -import nl.andrewl.concord_core.msg.Message; - -/** - * This message is sent from the client to a server, to provide identification - * information about the client to the server when the connection is started. - * - * @param nickname - */ -public record Identification(String nickname, String sessionToken) implements Message {} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java new file mode 100644 index 0000000..45d8159 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/client_setup/RegistrationStatus.java @@ -0,0 +1,15 @@ +package nl.andrewl.concord_core.msg.types.client_setup; + +import nl.andrewl.concord_core.msg.Message; + +/** + * A response from the server which indicates the current status of the client's + * registration request. + */ +public record RegistrationStatus (Type type) implements Message { + public enum Type {PENDING, ACCEPTED, REJECTED} + + public static RegistrationStatus pending() { + return new RegistrationStatus(Type.PENDING); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java b/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java index 48e0687..38df084 100644 --- a/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java +++ b/core/src/main/java/nl/andrewl/concord_core/util/ChainedDataOutputStream.java @@ -63,13 +63,16 @@ public class ChainedDataOutputStream { public ChainedDataOutputStream writeArray(T[] array) throws IOException { this.out.writeInt(array.length); for (var item : array) { - item.getType().writer().write(item, this); + writeMessage(item); } return this; } public ChainedDataOutputStream writeMessage(Message msg) throws IOException { - msg.getType().writer().write(msg, this); + this.out.writeBoolean(msg != null); + if (msg != null) { + msg.getTypeSerializer().writer().write(msg, this); + } return this; } diff --git a/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java b/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java index aada77f..3470b36 100644 --- a/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java +++ b/core/src/main/java/nl/andrewl/concord_core/util/ExtendedDataInputStream.java @@ -1,7 +1,7 @@ package nl.andrewl.concord_core.util; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.MessageType; +import nl.andrewl.concord_core.msg.MessageTypeSerializer; import java.io.DataInputStream; import java.io.IOException; @@ -45,7 +45,7 @@ public class ExtendedDataInputStream extends DataInputStream { } @SuppressWarnings("unchecked") - public T[] readArray(MessageType type) throws IOException { + public T[] readArray(MessageTypeSerializer type) throws IOException { int length = super.readInt(); T[] array = (T[]) Array.newInstance(type.messageClass(), length); for (int i = 0; i < length; i++) { @@ -76,10 +76,10 @@ public class ExtendedDataInputStream extends DataInputStream { int length = this.readInt(); return this.readNBytes(length); } else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) { - var messageType = MessageType.get((Class) type.getComponentType()); + var messageType = MessageTypeSerializer.get((Class) type.getComponentType()); return this.readArray(messageType); } else if (Message.class.isAssignableFrom(type)) { - var messageType = MessageType.get((Class) type); + var messageType = MessageTypeSerializer.get((Class) type); return messageType.reader().read(this); } else { throw new IOException("Unsupported object type: " + type.getSimpleName()); diff --git a/server/pom.xml b/server/pom.xml index 70fb7bd..c64020a 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -42,6 +42,12 @@ jackson-annotations 2.12.4 + + + at.favre.lib + bcrypt + 0.9.0 + diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java index 6552aac..a6eee84 100644 --- a/server/src/main/java/module-info.java +++ b/server/src/main/java/module-info.java @@ -4,6 +4,7 @@ module concord_server { requires com.fasterxml.jackson.databind; requires com.fasterxml.jackson.core; requires com.fasterxml.jackson.annotation; + requires bcrypt; requires java.base; requires java.logging; diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java index 9bc4e90..6b8756a 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -85,6 +85,8 @@ public class ConcordServer implements Runnable { private final ClientManager clientManager; private final DiscoveryServerPublisher discoveryServerPublisher; + + @Getter private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); public ConcordServer() throws IOException { diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java index e9e668e..3f1e152 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java @@ -9,7 +9,7 @@ import nl.andrewl.concord_server.cli.ServerCliCommand; public class ListClientsCommand implements ServerCliCommand { @Override public void handle(ConcordServer server, String[] args) throws Exception { - var users = server.getClientManager().getClients(); + var users = server.getClientManager().getConnectedClients(); if (users.isEmpty()) { System.out.println("There are no connected clients."); } else { diff --git a/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java b/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java new file mode 100644 index 0000000..7a8f341 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/client/AuthenticationService.java @@ -0,0 +1,114 @@ +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 { + public static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {} + + private final NitriteCollection userCollection; + private final NitriteCollection sessionTokenCollection; + private final ConcordServer server; + + public AuthenticationService(ConcordServer server, NitriteCollection userCollection) { + this.server = server; + this.userCollection = userCollection; + this.sessionTokenCollection = server.getDb().getCollection("session-tokens"); + CollectionUtils.ensureIndexes(this.sessionTokenCollection, Map.of( + "sessionToken", IndexType.Unique, + "userId", IndexType.NonUnique, + "expiresAt", IndexType.NonUnique + )); + } + + public ClientConnectionData registerNewClient(ClientRegistration registration) { + UUID id = this.server.getIdProvider().newId(); + String sessionToken = this.generateSessionToken(id); + String passwordHash = BCrypt.withDefaults().hashToString(12, registration.password().toCharArray()); + Document doc = new Document(Map.of( + "id", id, + "username", registration.username(), + "passwordHash", passwordHash, + "name", registration.name(), + "description", registration.description(), + "createdAt", System.currentTimeMillis(), + "pending", false + )); + this.userCollection.insert(doc); + return new ClientConnectionData(id, registration.username(), sessionToken, true); + } + + public UUID registerPendingClient(ClientRegistration registration) { + UUID id = this.server.getIdProvider().newId(); + String passwordHash = BCrypt.withDefaults().hashToString(12, registration.password().toCharArray()); + Document doc = new Document(Map.of( + "id", id, + "username", registration.username(), + "passwordHash", passwordHash, + "name", registration.name(), + "description", registration.description(), + "createdAt", System.currentTimeMillis(), + "pending", true + )); + this.userCollection.insert(doc); + return id; + } + + public Document findAndAuthenticateUser(ClientLogin login) { + Document userDoc = this.userCollection.find(Filters.eq("username", login.username())).firstOrDefault(); + if (userDoc != null) { + byte[] passwordHash = userDoc.get("passwordHash", String.class).getBytes(StandardCharsets.UTF_8); + if (BCrypt.verifyer().verify(login.password().getBytes(StandardCharsets.UTF_8), passwordHash).verified) { + return userDoc; + } + } + return null; + } + + public Document findAndAuthenticateUser(ClientSessionResume sessionResume) { + Document tokenDoc = this.sessionTokenCollection.find(Filters.and( + Filters.eq("sessionToken", sessionResume.sessionToken()), + Filters.gt("expiresAt", Instant.now().toEpochMilli()) + )).firstOrDefault(); + if (tokenDoc == null) return null; + UUID userId = tokenDoc.get("userId", UUID.class); + return this.userCollection.find(Filters.eq("id", userId)).firstOrDefault(); + } + + public String generateSessionToken(UUID userId) { + String sessionToken = StringUtils.random(128); + long expiresAt = Instant.now().plus(7, ChronoUnit.DAYS).toEpochMilli(); + Document doc = new Document(Map.of( + "sessionToken", sessionToken, + "userId", userId, + "expiresAt", expiresAt + )); + this.sessionTokenCollection.insert(doc); + return sessionToken; + } + + public void removeExpiredSessionTokens() { + long now = System.currentTimeMillis(); + this.sessionTokenCollection.remove(Filters.lt("expiresAt", now)); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java index 7d4c6a7..10565cb 100644 --- a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java +++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java @@ -1,14 +1,11 @@ package nl.andrewl.concord_server.client; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_core.msg.types.ServerUsers; import nl.andrewl.concord_core.msg.types.UserData; -import nl.andrewl.concord_core.msg.types.client_setup.Identification; -import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome; +import nl.andrewl.concord_core.msg.types.client_setup.*; import nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.util.CollectionUtils; -import nl.andrewl.concord_server.util.StringUtils; import org.dizitart.no2.Document; import org.dizitart.no2.IndexType; import org.dizitart.no2.NitriteCollection; @@ -18,6 +15,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -27,58 +25,107 @@ import java.util.stream.Collectors; public class ClientManager { private final ConcordServer server; private final Map clients; + private final Map pendingClients; private final NitriteCollection userCollection; + private final AuthenticationService authService; + public ClientManager(ConcordServer server) { this.server = server; this.clients = new ConcurrentHashMap<>(); + this.pendingClients = new ConcurrentHashMap<>(); this.userCollection = server.getDb().getCollection("users"); CollectionUtils.ensureIndexes(this.userCollection, Map.of( "id", IndexType.Unique, - "sessionToken", IndexType.Unique, - "nickname", IndexType.Fulltext + "username", IndexType.Unique, + "pending", IndexType.NonUnique )); + this.authService = new AuthenticationService(server, this.userCollection); + // Start a daily scheduled removal of expired session tokens. + server.getScheduledExecutorService().scheduleAtFixedRate(this.authService::removeExpiredSessionTokens, 1, 1, TimeUnit.DAYS); + } + + public void handleRegistration(ClientRegistration registration, ClientThread clientThread) throws InvalidIdentificationException { + Document userDoc = this.userCollection.find(Filters.eq("username", registration.username())).firstOrDefault(); + if (userDoc != null) throw new InvalidIdentificationException("Username is taken."); + if (this.server.getConfig().isAcceptAllNewClients()) { + var clientData = this.authService.registerNewClient(registration); + this.initializeClientConnection(clientData, clientThread); + } else { + var clientId = this.authService.registerPendingClient(registration); + this.initializePendingClientConnection(clientId, registration.username(), clientThread); + } + } + + 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 AuthenticationService.ClientConnectionData(userId, username, sessionToken, false), clientThread); + } + } + + public void handleSessionResume(ClientSessionResume sessionResume, ClientThread clientThread) throws InvalidIdentificationException { + Document userDoc = this.authService.findAndAuthenticateUser(sessionResume); + if (userDoc == null) throw new InvalidIdentificationException("Invalid session. Log in to obtain a new session token."); + UUID userId = userDoc.get("id", UUID.class); + String username = userDoc.get("username", String.class); + String sessionToken = this.authService.generateSessionToken(userId); + this.initializeClientConnection(new AuthenticationService.ClientConnectionData(userId, username, sessionToken, false), clientThread); + } + + public void decidePendingUser(UUID userId, boolean accepted) { + 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)); + String username = userDoc.get("username", String.class); + String sessionToken = this.authService.generateSessionToken(userId); + this.initializeClientConnection(new AuthenticationService.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)); + } + } + } } /** - * Registers a new client as connected to the server. This is done once the - * client thread has received the correct identification information from - * the client. The server will register the client in its global set of - * connected clients, and it will immediately move the client to the default - * channel. - *

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

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