Implemented session tokens for persistent users.
This commit is contained in:
parent
97e886f0f6
commit
6770418c66
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
// }
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
package nl.andrewl.concord_server.util;
|
||||
|
||||
public record Pair<A, B>(A first, B second) {}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue