Updated user login system to use unique usernames, login and logout stuff. Client not yet updated to new authentication flow.
This commit is contained in:
parent
c35fbbec9e
commit
d34a407284
|
@ -8,12 +8,22 @@ package nl.andrewl.concord_core.msg;
|
|||
* </p>
|
||||
*/
|
||||
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")
|
||||
default <T extends Message> MessageType<T> getType() {
|
||||
return MessageType.get((Class<T>) this.getClass());
|
||||
default <T extends Message> MessageTypeSerializer<T> getTypeSerializer() {
|
||||
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() {
|
||||
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 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,
|
||||
Function<T, Integer> byteSizeFunction,
|
||||
MessageReader<T> reader,
|
||||
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.
|
||||
* @param messageClass The class of the message to get a type for.
|
||||
* @param <T> The type of the message.
|
||||
* @return The message type.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public static <T extends Message> MessageType<T> get(Class<T> messageClass) {
|
||||
return (MessageType<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
|
||||
public static <T extends Message> MessageTypeSerializer<T> get(Class<T> messageClass) {
|
||||
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.
|
||||
* @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();
|
||||
Constructor<T> constructor;
|
||||
try {
|
||||
|
@ -58,7 +58,7 @@ public record MessageType<T extends Message>(
|
|||
} catch (NoSuchMethodException e) {
|
||||
throw new IllegalArgumentException(e);
|
||||
}
|
||||
return new MessageType<>(
|
||||
return new MessageTypeSerializer<>(
|
||||
messageTypeClass,
|
||||
generateByteSizeFunction(components),
|
||||
generateReader(constructor),
|
|
@ -35,10 +35,14 @@ public class MessageUtils {
|
|||
return size;
|
||||
}
|
||||
|
||||
public static int getByteSize(Message msg) {
|
||||
return 1 + (msg == null ? 0 : msg.byteSize());
|
||||
}
|
||||
|
||||
public static <T extends Message> 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());
|
||||
}
|
||||
|
|
|
@ -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<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
|
||||
* 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
|
||||
* 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<Class<? extends Message>> 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 <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);
|
||||
inverseMessageTypes.put(type, (byte) id);
|
||||
}
|
||||
|
@ -104,12 +108,12 @@ public class Serializer {
|
|||
*/
|
||||
public <T extends Message> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
* 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 {}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -63,13 +63,16 @@ public class ChainedDataOutputStream {
|
|||
public <T extends Message> 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 <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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 extends Message> T[] readArray(MessageType<T> type) throws IOException {
|
||||
public <T extends Message> T[] readArray(MessageTypeSerializer<T> 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<? extends Message>) type.getComponentType());
|
||||
var messageType = MessageTypeSerializer.get((Class<? extends Message>) type.getComponentType());
|
||||
return this.readArray(messageType);
|
||||
} 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);
|
||||
} else {
|
||||
throw new IOException("Unsupported object type: " + type.getSimpleName());
|
||||
|
|
|
@ -42,6 +42,12 @@
|
|||
<artifactId>jackson-annotations</artifactId>
|
||||
<version>2.12.4</version>
|
||||
</dependency>
|
||||
<!-- BCrypt implementation for password hashing. -->
|
||||
<dependency>
|
||||
<groupId>at.favre.lib</groupId>
|
||||
<artifactId>bcrypt</artifactId>
|
||||
<version>0.9.0</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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<UUID, ClientThread> clients;
|
||||
private final Map<UUID, ClientThread> 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.
|
||||
* <p>
|
||||
* 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.
|
||||
* </p>
|
||||
* @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<UserData> getClients() {
|
||||
public List<UserData> getConnectedClients() {
|
||||
return this.clients.values().stream()
|
||||
.sorted(Comparator.comparing(ClientThread::getClientNickname))
|
||||
.map(ClientThread::toData)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public List<UserData> getPendingClients() {
|
||||
return this.pendingClients.values().stream()
|
||||
.sorted(Comparator.comparing(ClientThread::getClientNickname))
|
||||
.map(ClientThread::toData)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
public Set<UUID> 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<ClientThread> getPendingClientById(UUID id) {
|
||||
return Optional.ofNullable(this.pendingClients.get(id));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Reference in New Issue