Implemented session tokens for persistent users.

This commit is contained in:
Andrew Lalis 2021-09-09 09:56:00 +02:00
parent 97e886f0f6
commit 6770418c66
12 changed files with 225 additions and 37 deletions

View File

@ -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:

View File

@ -33,6 +33,25 @@
<artifactId>jna-platform</artifactId>
<version>5.9.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.4</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-core -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>2.12.4</version>
</dependency>
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-annotations -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.12.4</version>
</dependency>
</dependencies>
<build>

View File

@ -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;
}

View File

@ -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<String, String> sessionTokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
token = sessionTokens.get(address);
}
return token;
}
/**
* Saves a session token that this client should use the next time it
* connects to the same server.
* @param token The token to save.
* @param tokensFile The file containing the session tokens.
* @throws IOException If the tokens file could not be read or written to.
*/
@SuppressWarnings("unchecked")
private void saveSessionToken(String token, Path tokensFile) throws IOException {
String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
Map<String, String> tokens = new HashMap<>();
ObjectMapper mapper = new ObjectMapper();
if (Files.exists(tokensFile)) {
tokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
}
tokens.put(address, token);
mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(tokensFile), tokens);
}
public static void main(String[] args) throws IOException {

View File

@ -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();

View File

@ -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);
}
}

View File

@ -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);

View File

@ -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();
// }
}
/**

View File

@ -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<UUID, ClientThread> 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<ClientThread> 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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,3 @@
package nl.andrewl.concord_server.util;
public record Pair<A, B>(A first, B second) {}

View File

@ -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();
}
}