From d2a0a7a93b8a473c66883c03194ceaad5e18e8d2 Mon Sep 17 00:00:00 2001 From: sasheg dev <152582704+SashegDev@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:50:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=85=D0=BE=D0=B4=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F,=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B0=20=D0=B8=20?= =?UTF-8?q?F3+B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Аутентификация переделана по схеме пользователя: лаунчер — единственный источник токена, сервер проверяет его сам и лаунчера не спрашивает. - rsx-auth/GameToken: токен это base64url(payload) + "." + base64url(HMAC-SHA256), payload {uuid, nick, aud, exp, jti, typ}, typ = session или reload. - Сессия живёт час, reload-токен полтора часа и только внутри игры. Пакет SessionReload продлевает сессию и выдаёт новый reload-токен. - Сервер проверяет подпись, аудиенс, срок и совпадение uuid. HTTP-запрос к лаунчеру из сервера убран. - Клиент: окно пароля убрано совсем, ник берётся из токена, клиент сам продлевает сессию за 5 минут до конца. - Для локальной разработки сервер с --dev-token пишет свой токен в world/dev-token.txt, клиент берёт токен оттуда. - Права админа: свой список world/admins.txt, перечитывается раз в 5 секунд, поэтому право снимается на лету. Пакет AdminState. - Ванильные блоки мира ломает только админ, режим игры прав не даёт. - F3+B только для админов: без прав показываем отказ, с правом включаются и ванильные хитбоксы, и наш отладочный рендер. Права сняли посреди просмотра — гаснут сразу. - run-game.ps1 умеет -Token. Проверено на живом сервере: own session ok, player in, rights of Player given. Тесты: протокол 34, сервер 88, форк собирается чисто. --- PLAN.md | 47 ++- game-fork/patches/decompiler-fixes.txt | 25 ++ .../client/multiplayer/GuiConnecting.java | 155 +++------ .../net/rustnotmc/client/RsxBootstrap.java | 21 +- .../net/rustnotmc/client/RsxHitboxDebug.java | 13 + .../rustnotmc/client/net/RsxClientMode.java | 32 ++ .../client/net/RsxNetworkManager.java | 55 ++- .../java/net/rustnotmc/auth/GameToken.java | 307 +++++++++++++++++ rsx-client/scripts/run-game.ps1 | 17 +- .../java/net/rustnotmc/protocol/Packets.java | 150 +++++++- .../java/net/rustnotmc/protocol/ItemTest.java | 27 ++ .../net/rustnotmc/protocol/ProtocolTest.java | 9 +- .../net/rustnotmc/server/ClientSession.java | 201 +++++++++-- .../net/rustnotmc/server/LauncherAuth.java | 211 ------------ .../java/net/rustnotmc/server/Player.java | 31 +- .../java/net/rustnotmc/server/Server.java | 48 ++- .../java/net/rustnotmc/server/ServerMain.java | 78 ++++- .../java/net/rustnotmc/server/TokenAuth.java | 105 ++++++ .../rustnotmc/server/admin/AdminRegistry.java | 108 ++++++ .../java/net/rustnotmc/server/PlayClient.java | 56 ++- .../java/net/rustnotmc/server/PlayTest.java | 325 ++++++++++-------- 21 files changed, 1478 insertions(+), 543 deletions(-) create mode 100644 rsx-auth/src/main/java/net/rustnotmc/auth/GameToken.java delete mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java create mode 100644 rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java diff --git a/PLAN.md b/PLAN.md index 3ae4360..f1cdc18 100644 --- a/PLAN.md +++ b/PLAN.md @@ -817,7 +817,52 @@ MovementInputFromOptions` и `RsxPlayerTuning` с числами. регистрирует свои коробки. - Область: наши структуры (узлы, мусорка, постройки) и игроки с зонами. -### 2.11 Декомпил ремейка лежит на диске +### 2.11 Вход без пароля (сделано) + +**Схема пользователя — её нельзя менять:** + +> 1. Лаунчер при заходе предлагает войти. Он обращается к **AUTH-серверу**: есть ли +> доступ к игре у этого пользователя. Нет — идёт нахер. Да — пропускает. +> 2. При запуске игры лаунчер передаёт игре **токен** и **uuid (без ника)**; ник игра +> получает из токена+uuid. +> 3. Игра интерпретирует и проверяет токен, и при заходе на сервер передаёт **наш +> uuid и личный токен** (полученный **только для этого сервера**). +> 4. Сервер проверяет валидность uuid+личного токена **сам** и, если ок, пускает в игру. +> **Сервер лаунчер не спрашивает. Ролей от лаунчера не получаем.** +> 5. **Внутри игры пароля нет и логин-окна быть не должно.** +> 6. Если сессия просрочилась в игре — **reload-token** продлевает сессию на час, сам +> reload-token живёт 1.5 часа. Он **только внутри игры** и не влияет на reload-token +> аккаунта (чтобы не логиниться в лаунчер каждый раз). + +**Что сделано у нас:** + +- **Токен**: `base64url(payload).base64url(HMAC-SHA256(payload))`, payload = + `{uuid, nick, aud, exp, jti, typ}`; `typ` = `session` | `reload`. Ключ — в конфиге + сервера (`--token-key`), общий с auth-сервером. Класс `rsx-auth/GameToken`. +- **Сессия**: 1 час. **Reload-токен**: 1.5 часа, выдаётся вместе с сессией; + пакет `SessionReload` → продление на час + новый reload-токен. +- **Сервер** проверяет подпись, аудиенс (свой сервер), срок и совпадение uuid — сам, + без обращения к лаунчеру. HTTP-запрос `/auth/validate` из сервера убран. +- **Клиент**: окно ввода пароля **убрано совсем** (нет ни полей, ни кнопки Connect); + ник берётся из токена; клиент сам перезапрашивает сессию за 5 минут до конца. +- **Для локальной разработки**: сервер с `--dev-token <аккаунт>` выдаёт свой токен и + пишет его в `/dev-token.txt`; клиент берёт токен оттуда. + +### 2.12 Права админа и F3+B (сделано) + +- **Список — свой, у сервера**: `/admins.txt` (по строке на аккаунт), + перечитывается каждые 5 секунд → **права снимаются на лету**. Лаунчер про роли + ничего не сообщает. +- Пакет `AdminState` — сервер шлёт при входе и при каждом изменении. +- **Ванильные блоки мира ломает только админ** (режим игры прав не даёт) — правило + п. 2.3. +- **F3+B — только для админов.** Без прав показывается «only an administrator may look + at the boxes». Наш отладочный рендер (`/rsx hitboxes`) включается тем же F3+B. + Права сняли посреди просмотра → и ванильные хитбоксы, и наш рендер гаснут сразу. +- Отладочный рендер показывает и коробки структур, и зоны игрока (голова красная, + тело жёлтое, ноги синие), и области LOD. + +### 2.13 Декомпил ремейка лежит на диске `G:\src-decoded` (5207 файлов, строки расшифрованы), также `G:\src`, `G:\builds\v31\src{,-decoded}` и `G:\decompiled-rustex`. Это **исходники клиента diff --git a/game-fork/patches/decompiler-fixes.txt b/game-fork/patches/decompiler-fixes.txt index 7b19c41..8190193 100644 --- a/game-fork/patches/decompiler-fixes.txt +++ b/game-fork/patches/decompiler-fixes.txt @@ -1302,3 +1302,28 @@ import org.lwjgl.opengl.ContextCapabilities; net.rustnotmc.client.net.RsxNetworkManager.sendChat(s); } +# F3+B shows the boxes of the world, and that belongs to the administrators. +# The server says who they are, and a right that is taken away while the +# player is looking takes the boxes with it. +=== FILE net/minecraft/client/Minecraft.java +--- BEFORE + if (i == 48) { + flag = !this.renderManager.isDebugBoundingBox(); + this.renderManager.setDebugBoundingBox(flag); + this.debugFeedbackTranslated(flag ? "debug.show_hitboxes.on" : "debug.show_hitboxes.off"); + return true; +--- AFTER + if (i == 48) { + if (!net.rustnotmc.client.net.RsxClientMode.isAdmin()) { + this.ingameGUI.getChatGUI().printChatMessage( + new net.minecraft.util.text.TextComponentString( + "only an administrator may look at the boxes")); + return true; + } + + flag = !this.renderManager.isDebugBoundingBox(); + this.renderManager.setDebugBoundingBox(flag); + net.rustnotmc.client.RsxHitboxDebug.setVisible(flag); + this.debugFeedbackTranslated(flag ? "debug.show_hitboxes.on" : "debug.show_hitboxes.off"); + return true; + diff --git a/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java b/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java index bcf15f7..6e8c881 100644 --- a/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java +++ b/game-fork/src-overlay/net/minecraft/client/multiplayer/GuiConnecting.java @@ -6,55 +6,43 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.GuiButton; import net.minecraft.client.gui.GuiDisconnected; import net.minecraft.client.gui.GuiScreen; -import net.minecraft.client.gui.GuiTextField; import net.minecraft.util.text.TextComponentTranslation; -import net.rustnotmc.client.gui.RsxPasswordField; +import net.rustnotmc.auth.GameToken; import net.rustnotmc.client.net.RsxNetworkManager; import net.rustnotmc.client.net.RsxWorldBridge; /** * The screen that gets the player into a server. * - * The launcher is where accounts live, so the launcher signs the player in and - * hands the game a token. This screen then has nothing to ask: it takes the - * token out of the session the launcher built and offers it to the server. + * There is nothing to fill in here. The launcher asked the auth server + * whether the player may play, and when the answer was yes it gave the game a + * token and a uuid. The game shows both to the server, the server checks them + * itself, and the player is in. * - * When the game was started without a launcher there is no session and - * somebody has to say who they are, so the screen asks for a name and a - * password. That is the way in for a server of our own and the way in for - * development; a server with a launcher in front of it does not accept it. - * - * This replaces the game's own connecting screen, which is where the player - * already is when they press "join server", so the flow stays the one they - * know. + * So this screen has no account field and no password field: a password is + * something the launcher knows, not something the game asks for. All it does + * is open the socket and say what is happening. */ public class GuiConnecting extends GuiScreen { - /** What the launcher leaves in the session when nobody signed in. */ private static final String NO_TOKEN = "0"; - private final GuiScreen previousGuiScreen; private final String host; private final int port; + private final GuiScreen previousGuiScreen; - private GuiTextField accountField; - private RsxPasswordField passwordField; private RsxNetworkManager networkManager; - private volatile String status = ""; - - /** The launcher's session, if the game was started by the launcher. */ - private String launcherAccount; - private String launcherUuid; - private String launcherToken; + private String launcherAccount = ""; + private String launcherUuid = ""; + private String launcherToken = ""; + private String status = ""; public GuiConnecting(GuiScreen guiscreen, Minecraft minecraft, ServerData serverdata) { - this.mc = minecraft; - this.previousGuiScreen = guiscreen; - ServerAddress serveraddress = ServerAddress.fromString(serverdata.serverIP); - minecraft.loadWorld((WorldClient) null); - minecraft.setServerData(serverdata); - this.host = serveraddress.getIP(); - this.port = serveraddress.getPort(); + this(guiscreen, minecraft, ServerAddress.fromString(serverdata.serverIP)); + } + + public GuiConnecting(GuiScreen guiscreen, Minecraft minecraft, ServerAddress address) { + this(guiscreen, minecraft, address.getIP(), address.getPort()); } public GuiConnecting(GuiScreen guiscreen, Minecraft minecraft, String s, int i) { @@ -68,34 +56,18 @@ public class GuiConnecting extends GuiScreen { public void initGui() { this.buttonList.clear(); + // The launcher built the session: the name, the identity and the token. this.launcherAccount = sessionValue(this.mc, 0); this.launcherUuid = sessionValue(this.mc, 1); this.launcherToken = sessionValue(this.mc, 2); - this.accountField = new GuiTextField(0, this.fontRenderer, this.width / 2 - 100, - this.height / 4 + 20, 200, 20); - this.accountField.setMaxStringLength(32); - this.accountField.setText(this.launcherAccount == null - ? this.mc.getSession().getProfile().getName() : this.launcherAccount); - - this.passwordField = new RsxPasswordField(1, this.fontRenderer, this.width / 2 - 100, - this.height / 4 + 50, 200, 20); - this.passwordField.setMaxStringLength(128); - - // two buttons side by side, and they must not lie on top of each other: - // the plain constructor makes a button two hundred wide - this.buttonList.add(new GuiButton(0, this.width / 2 - 100, this.height / 4 + 90, - 98, 20, "Connect")); - this.buttonList.add(new GuiButton(1, this.width / 2 + 2, this.height / 4 + 90, - 98, 20, "Cancel")); + this.buttonList.add(new GuiButton(1, this.width / 2 - 100, this.height / 4 + 90, + 200, 20, "Cancel")); if (this.hasLauncherSession()) { - // the launcher already signed the player in, so there is nothing to - // ask for and nothing to wait for - this.passwordField.setFocused(true); this.start(); } else { - this.passwordField.setFocused(true); + this.status = "the launcher did not give a session"; } } @@ -115,32 +87,8 @@ public class GuiConnecting extends GuiScreen { reason == null ? "the connection was closed" : reason}))); } - protected void keyTyped(char c0, int i) { - if (c0 == '\r' || c0 == '\n') { - this.start(); - return; - } - - if (this.accountField.isFocused()) { - this.accountField.textboxKeyTyped(c0, i); - return; - } - - if (this.passwordField.isFocused()) { - this.passwordField.textboxKeyTyped(c0, i); - } - } - - public void mouseClicked(int mouseX, int mouseY, int mouseButton) { - super.mouseClicked(mouseX, mouseY, mouseButton); - this.accountField.mouseClicked(mouseX, mouseY, mouseButton); - this.passwordField.mouseClicked(mouseX, mouseY, mouseButton); - } - protected void actionPerformed(GuiButton guibutton) { - if (guibutton.id == 0) { - this.start(); - } else if (guibutton.id == 1) { + if (guibutton.id == 1) { this.mc.displayGuiScreen(this.previousGuiScreen); } } @@ -151,15 +99,8 @@ public class GuiConnecting extends GuiScreen { this.height / 4 - 20, 16777215); if (this.hasLauncherSession()) { - this.drawCenteredString(this.fontRenderer, "signed in as " + this.launcherAccount, + this.drawCenteredString(this.fontRenderer, "signed in as " + account(), this.width / 2, this.height / 4 + 10, 8421504); - } else { - this.drawString(this.fontRenderer, "Account", this.width / 2 - 100, - this.height / 4 + 10, 8421504); - this.accountField.drawTextBox(); - this.drawString(this.fontRenderer, "Password", this.width / 2 - 100, - this.height / 4 + 40, 8421504); - this.passwordField.drawTextBox(); } if (!this.status.isEmpty()) { @@ -170,6 +111,20 @@ public class GuiConnecting extends GuiScreen { super.drawScreen(i, j, f); } + /** + * The name of the player. + * + * It is inside the token: the launcher gives the game a token and a uuid + * without a name, and the name is read out of the token. + */ + private String account() { + java.util.Map claims = GameToken.peek(this.launcherToken); + if (claims != null && claims.get("nick") != null && !claims.get("nick").isEmpty()) { + return claims.get("nick"); + } + return this.launcherAccount == null ? "" : this.launcherAccount; + } + /** * Starts the connection. * @@ -180,36 +135,22 @@ public class GuiConnecting extends GuiScreen { if (this.networkManager != null) { return; } - - String account; - String token = null; - String uuid = null; - char[] password = new char[0]; - - if (this.hasLauncherSession()) { - account = this.launcherAccount; - token = this.launcherToken; - uuid = this.launcherUuid; - } else { - account = this.accountField.getText().trim(); - password = this.passwordField.getText().toCharArray(); - if (account.isEmpty()) { - this.status = "an account name is needed"; - return; - } - if (password.length == 0) { - this.status = "a password is needed"; - return; - } - this.passwordField.setText(""); + if (!this.hasLauncherSession()) { + this.status = "the launcher did not give a session"; + return; } + String account = account(); + String token = this.launcherToken; + String uuid = this.launcherUuid; + this.status = "connecting to " + this.host + ":" + this.port; - System.out.println("[rsx] connecting to " + this.host + ":" + this.port + " as " + account); + System.out.println("[rsx] connecting to " + this.host + ":" + this.port + + " as " + account); final RsxNetworkManager manager = new RsxNetworkManager(this.mc, this.previousGuiScreen, new GameProfile(RsxWorldBridge.uuidFor(account), account), - account, password, token, uuid); + account, new char[0], token, uuid); this.networkManager = manager; (new Thread("Rsx Connect") { diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java index 1bb38b3..19e4831 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxBootstrap.java @@ -36,6 +36,14 @@ public final class RsxBootstrap { private static boolean frameFailed; private static boolean tickFailed; + /** + * How long before the session runs out the client makes it longer. + * + * Five minutes is enough to reach the server even when the connection is + * not very good. + */ + private static final long RELOAD_MARGIN_MILLIS = 5L * 60L * 1000L; + private RsxBootstrap() { } @@ -182,9 +190,20 @@ public final class RsxBootstrap { public static synchronized void clientTick() { // the connection is pumped from here, not from the screen that started // it. A handshake that only runs while a screen is up is a handshake - // that the player can break by moving the window. + // the player can break by moving the window. net.rustnotmc.client.net.RsxNetworkManager.pump(); + // the session runs out after an hour, so it is made longer before that + net.rustnotmc.client.net.RsxNetworkManager manager = + net.rustnotmc.client.net.RsxNetworkManager.active(); + if (manager != null) { + long left = manager.sessionLeft(); + if (left > 0L && left < RELOAD_MARGIN_MILLIS) { + manager.reloadSession(); + } + } + + if (loader == null || broken || tickFailed) { return; } diff --git a/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java b/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java index 414e7b8..6dc01e9 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java +++ b/game-fork/src-overlay/net/rustnotmc/client/RsxHitboxDebug.java @@ -72,6 +72,19 @@ public final class RsxHitboxDebug { return visible; } + /** + * Shows or hides the boxes. + * + * The right to look at them belongs to the administrators, so when the + * right is taken away the boxes go at once. + */ + public static void setVisible(boolean value) { + visible = value; + if (!value) { + preview = null; + } + } + /** How many models the server has sent. */ public static int modelCount() { return MODELS.size(); diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxClientMode.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxClientMode.java index 1f28fa7..a09e3dc 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxClientMode.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxClientMode.java @@ -21,6 +21,9 @@ public final class RsxClientMode { private static volatile int mode = Packets.GAMEMODE_SURVIVAL; + /** Whether the server says the player is one of the administrators. */ + private static volatile boolean admin; + private RsxClientMode() { } @@ -34,6 +37,35 @@ public final class RsxClientMode { mode = newMode; } + /** + * Whether the player is one of the administrators. + * + * The server is the one who decides, and it can take the right away while + * the player is looking at the world. The things that only an + * administrator may do are not offered to anybody else. + */ + public static boolean isAdmin() { + return admin; + } + + /** The server said whether the player is an administrator. */ + public static void setAdmin(boolean value) { + if (admin == value) { + return; + } + admin = value; + if (value) { + return; + } + // The right was taken away, so the things that needed it go at once: the + // boxes of the world and our own debug view with them. + Minecraft minecraft = Minecraft.getMinecraft(); + if (minecraft.getRenderManager() != null) { + minecraft.getRenderManager().setDebugBoundingBox(false); + } + net.rustnotmc.client.RsxHitboxDebug.setVisible(false); + } + /** True when the player may break blocks. */ public static boolean canBreakBlocks() { return mode == Packets.GAMEMODE_CREATIVE; diff --git a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java index 5882e4f..505fd54 100644 --- a/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java +++ b/game-fork/src-overlay/net/rustnotmc/client/net/RsxNetworkManager.java @@ -77,6 +77,12 @@ public final class RsxNetworkManager extends NetworkManager { /** The names of the players the server told us about last time. */ private java.util.List knownPlayers = new java.util.ArrayList(); + + /** When the session runs out. */ + private volatile long sessionExpiresAt; + + /** The token that makes the session longer when it runs out. */ + private volatile String reloadToken = ""; private final FrameDecoder decoder = new FrameDecoder(); private volatile Socket socket; @@ -276,6 +282,20 @@ public final class RsxNetworkManager extends NetworkManager { this.updatePlayerList(list); return; } + case Packets.ADMIN_STATE: { + Packets.AdminState state = Packets.AdminState.read(new Buf(frame.body())); + RsxClientMode.setAdmin(state.admin); + return; + } + case Packets.SESSION_RENEWED: { + Packets.SessionRenewed renewed = + Packets.SessionRenewed.read(new Buf(frame.body())); + if (renewed.accepted) { + this.sessionExpiresAt = renewed.expiresAt; + this.reloadToken = renewed.reloadToken; + } + return; + } default: onPlay(frame); } @@ -378,14 +398,14 @@ public final class RsxNetworkManager extends NetworkManager { if (this.token != null && !this.token.isEmpty()) { // the launcher already signed the player in, and the token it // handed the game is all the game server needs - send(Packets.LAUNCHER_AUTH, - new Packets.LauncherAuth(this.account, this.uuid, this.token)); + send(Packets.TOKEN_AUTH, + new Packets.TokenAuth(this.account, this.uuid, this.token)); return; } - byte[] proof = Packets.Auth.proofFor(this.account, this.password, this.salt, this.nonce); - java.util.Arrays.fill(this.password, '\0'); - send(Packets.AUTH, new Packets.Auth(this.account, proof)); + // without a session there is nothing to offer, and the game never + // asks the player for a password + lost("the launcher did not give a session"); } private void onAuthAck(Frame frame) { @@ -394,9 +414,33 @@ public final class RsxNetworkManager extends NetworkManager { lost(auth.reason); return; } + // the session and the token that makes it longer are ours now + this.sessionExpiresAt = auth.expiresAt; + this.reloadToken = auth.reloadToken; send(Packets.LOGIN, new Packets.Login(this.account, auth.token)); } + /** + * The session runs out in a while, so it is made longer before that. + * + * The token in it lives inside the game and for that: it has nothing to + * do with the reload token of the account in the launcher. + */ + public void reloadSession() { + if (this.reloadToken == null || this.reloadToken.isEmpty()) { + return; + } + send(Packets.SESSION_RELOAD, new Packets.SessionReload(this.reloadToken)); + } + + /** How long is left of the session, in milliseconds. */ + public long sessionLeft() { + if (this.sessionExpiresAt <= 0L) { + return 0L; + } + return this.sessionExpiresAt - System.currentTimeMillis(); + } + private void onLoginAck(Frame frame) { Packets.LoginAck login = Packets.LoginAck.read(new Buf(frame.body())); if (!login.accepted) { @@ -520,6 +564,7 @@ public final class RsxNetworkManager extends NetworkManager { private void lost(String reason) { this.failure = reason == null ? "the connection was lost" : reason; + System.out.println("[rsx] connection lost: " + this.failure); this.open = false; Socket socket = this.socket; if (socket != null) { diff --git a/rsx-auth/src/main/java/net/rustnotmc/auth/GameToken.java b/rsx-auth/src/main/java/net/rustnotmc/auth/GameToken.java new file mode 100644 index 0000000..a303e66 --- /dev/null +++ b/rsx-auth/src/main/java/net/rustnotmc/auth/GameToken.java @@ -0,0 +1,307 @@ +package net.rustnotmc.auth; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * The token the launcher hands to the game. + * + * The launcher asks the auth server whether the player may play, and when the + * answer is yes it gives the game a token and a uuid. The game shows the token + * to the game server, and the game server checks it itself: the signature + * is made with a key both sides know, so no one has to ask anybody. + * + * The token is two parts: + * + *
+ *   base64url(payload) "." base64url(HMAC-SHA256(payload, key))
+ * 
+ * + * The payload is a small JSON with the uuid, the nick, the server it is meant + * for, the moment it runs out, a number that tells one token from another and + * the kind of token it is. + * + * The kinds are: + * + *
    + *
  • session - the token of a running session. It lives an hour. + *
  • reload - the token that makes a session longer when it runs out. + * It lives an hour and a half, and it lives only inside the game: it has + * nothing to do with the reload token of the account in the launcher. + *
+ */ +public final class GameToken { + + /** The kind of a token: a session. */ + public static final String KIND_SESSION = "session"; + + /** The kind of a token: one that makes a session longer. */ + public static final String KIND_RELOAD = "reload"; + + private static final String HMAC = "HmacSHA256"; + private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding(); + private static final Base64.Decoder DECODER = Base64.getUrlDecoder(); + + /** The claims of a token. */ + public static final class Claims { + + public final String uuid; + public final String nick; + public final String audience; + public final long expiresAt; + public final String id; + public final String kind; + + Claims(String uuid, String nick, String audience, long expiresAt, String id, + String kind) { + this.uuid = uuid; + this.nick = nick; + this.audience = audience; + this.expiresAt = expiresAt; + this.id = id; + this.kind = kind; + } + + /** True when the token is of that kind and has not run out. */ + public boolean usable(String wantedKind, String wantedAudience, long now) { + if (wantedKind != null && !wantedKind.equals(this.kind)) { + return false; + } + if (wantedAudience != null && !wantedAudience.equals(this.audience)) { + return false; + } + return this.expiresAt > now; + } + } + + private final byte[] key; + + public GameToken(String key) { + if (key == null || key.isEmpty()) { + throw new IllegalArgumentException("the token key cannot be empty"); + } + this.key = key.getBytes(StandardCharsets.UTF_8); + } + + /** + * Makes a token. + * + * @param uuid the identity of the player + * @param nick the name the player is shown under + * @param audience the server the token is meant for + * @param kind what kind of token it is + * @param expiresAt when it runs out + */ + public String issue(String uuid, String nick, String audience, String kind, + long expiresAt) { + StringBuilder payload = new StringBuilder(); + payload.append("{\"uuid\":").append(quote(uuid)); + payload.append(",\"nick\":").append(quote(nick)); + payload.append(",\"aud\":").append(quote(audience)); + payload.append(",\"exp\":").append(expiresAt); + payload.append(",\"jti\":").append(quote(randomId())); + payload.append(",\"typ\":").append(quote(kind)); + payload.append('}'); + byte[] body = payload.toString().getBytes(StandardCharsets.UTF_8); + return ENCODER.encodeToString(body) + "." + ENCODER.encodeToString(sign(body)); + } + + /** + * Reads a token. + * + * Returns null when the token is not ours: the signature does not match, + * the text is broken or it was cut in half. A token that cannot be read is + a token that is not there. + */ + public Claims read(String token) { + if (token == null || token.isEmpty()) { + return null; + } + int dot = token.indexOf('.'); + if (dot <= 0 || dot == token.length() - 1) { + return null; + } + byte[] body; + byte[] signature; + try { + body = DECODER.decode(token.substring(0, dot)); + signature = DECODER.decode(token.substring(dot + 1)); + } catch (IllegalArgumentException broken) { + return null; + } + if (!MessageDigest.isEqual(sign(body), signature)) { + return null; + } + Map fields = readFields(new String(body, StandardCharsets.UTF_8)); + if (fields == null) { + return null; + } + long expiresAt; + try { + expiresAt = Long.parseLong(fields.get("exp")); + } catch (RuntimeException broken) { + return null; + } + return new Claims(fields.get("uuid"), fields.get("nick"), fields.get("aud"), + expiresAt, fields.get("jti"), fields.get("typ")); + } + + /** + * Reads the token without checking the signature. + * + * The game uses this to learn the name of the player before it says + * anything: the name is inside the token, and the server checks the + * signature anyway. + */ + public static Map peek(String token) { + if (token == null || token.isEmpty()) { + return null; + } + int dot = token.indexOf('.'); + if (dot <= 0) { + return null; + } + try { + byte[] body = DECODER.decode(token.substring(0, dot)); + return readFields(new String(body, StandardCharsets.UTF_8)); + } catch (IllegalArgumentException broken) { + return null; + } + } + + private byte[] sign(byte[] body) { + try { + Mac mac = Mac.getInstance(HMAC); + mac.init(new SecretKeySpec(this.key, HMAC)); + return mac.doFinal(body); + } catch (java.security.GeneralSecurityException impossible) { + throw new IllegalStateException("the token key does not work", impossible); + } + } + + private static String randomId() { + byte[] bytes = Secure.bytes(12); + StringBuilder out = new StringBuilder(bytes.length * 2); + for (int i = 0; i < bytes.length; i++) { + out.append(Character.forDigit((bytes[i] >> 4) & 0xF, 16)); + out.append(Character.forDigit(bytes[i] & 0xF, 16)); + } + return out.toString(); + } + + /** The fields of the payload. */ + private static Map readFields(String json) { + if (json == null || !json.startsWith("{") || !json.endsWith("}")) { + return null; + } + Map out = new LinkedHashMap(); + int i = 1; + while (i < json.length() - 1) { + while (i < json.length() && (json.charAt(i) == ',' || json.charAt(i) == ' ')) { + i++; + } + if (i >= json.length() - 1) { + break; + } + if (json.charAt(i) != '"') { + return null; + } + int[] keyEnd = new int[]{0}; + String key = readString(json, i, keyEnd); + if (key == null) { + return null; + } + i = keyEnd[0]; + while (i < json.length() && json.charAt(i) == ' ') { + i++; + } + if (i >= json.length() || json.charAt(i) != ':') { + return null; + } + i++; + while (i < json.length() && json.charAt(i) == ' ') { + i++; + } + if (i >= json.length()) { + return null; + } + String value; + if (json.charAt(i) == '"') { + int[] end = new int[]{0}; + value = readString(json, i, end); + if (value == null) { + return null; + } + i = end[0]; + } else { + int start = i; + while (i < json.length() && json.charAt(i) != ',' && json.charAt(i) != '}') { + i++; + } + value = json.substring(start, i).trim(); + } + out.put(key, value); + } + return out; + } + + private static String readString(String json, int start, int[] end) { + if (json.charAt(start) != '"') { + return null; + } + StringBuilder out = new StringBuilder(); + int i = start + 1; + while (i < json.length()) { + char c = json.charAt(i); + if (c == '"') { + end[0] = i + 1; + return out.toString(); + } + if (c == '\\' && i + 1 < json.length()) { + i++; + char escape = json.charAt(i); + switch (escape) { + case 'n': out.append('\n'); break; + case 't': out.append('\t'); break; + case 'r': out.append('\r'); break; + case 'u': + if (i + 4 >= json.length()) { + return null; + } + out.append((char) Integer.parseInt(json.substring(i + 1, i + 5), 16)); + i += 4; + break; + default: out.append(escape); break; + } + i++; + continue; + } + out.append(c); + i++; + } + return null; + } + + /** A string in JSON, with the quotes and the backslashes put right. */ + private static String quote(String value) { + if (value == null) { + return "\"\""; + } + StringBuilder out = new StringBuilder(value.length() + 2); + out.append('"'); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + out.append('\\'); + } + out.append(c); + } + out.append('"'); + return out.toString(); + } +} diff --git a/rsx-client/scripts/run-game.ps1 b/rsx-client/scripts/run-game.ps1 index ef826d9..ee90861 100644 --- a/rsx-client/scripts/run-game.ps1 +++ b/rsx-client/scripts/run-game.ps1 @@ -5,13 +5,14 @@ # Stage first if run\classpath.txt is missing: # python tools\stage_run.py ..\..\game-fork\build\classes -param( - [string]$Run = 'G:\RustNotMC\run', - [string]$Java = '', - [string[]]$ExtraArgs = @(), - [int]$Width = 1280, - [int]$Height = 720 -) + param( + [string]$Run = 'G:\RustNotMC\run', + [string]$Java = '', + [string[]]$ExtraArgs = @(), + [string]$Token = '', + [int]$Width = 1280, + [int]$Height = 720 + ) $ErrorActionPreference = 'Stop' $root = 'G:\RustNotMC' @@ -56,7 +57,7 @@ $javaArgs += '--version'; $javaArgs += '1.12.2' $javaArgs += '--gameDir'; $javaArgs += $Run $javaArgs += '--assetsDir'; $javaArgs += $assets $javaArgs += '--uuid'; $javaArgs += '00000000000000000000000000000000' -$javaArgs += '--accessToken'; $javaArgs += '0' + $javaArgs += '--accessToken'; $javaArgs += $(if ($Token) { $Token } else { '0' }) $javaArgs += '--userType'; $javaArgs += 'legacy' $javaArgs += '--versionType'; $javaArgs += 'release' $javaArgs += '--width'; $javaArgs += "$Width" diff --git a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java index 67db3bf..02c73c7 100644 --- a/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java +++ b/rsx-protocol/src/main/java/net/rustnotmc/protocol/Packets.java @@ -100,7 +100,7 @@ public final class Packets { * is in. This is the part of the handshake that Yggdrasil does in * vanilla, done with our own launcher instead of Mojang's. */ - public static final int LAUNCHER_AUTH = 0x1A; + public static final int TOKEN_AUTH = 0x1A; /** * The whole inventory, sent when the player arrives. @@ -170,6 +170,15 @@ public final class Packets { /** Who is in the world. */ public static final int PLAYER_LIST = 0x30; + /** The client asks for a longer session. */ + public static final int SESSION_RELOAD = 0x31; + + /** The session was made longer. */ + public static final int SESSION_RENEWED = 0x32; + + /** Whether the player is one of the administrators. */ + public static final int ADMIN_STATE = 0x33; + /** * One slot of the inventory and what is in it now. * @@ -392,19 +401,39 @@ public final class Packets { public final String token; public final long expiresAt; - private AuthAck(boolean accepted, String reason, String token, long expiresAt) { + /** + * The token that makes the session longer when it runs out. + * + * It lives inside the game and for that: it does not touch the + * account's own reload token, so nobody has to sign in to the + * launcher again after every restart. + */ + public final String reloadToken; + + /** When the reload token itself runs out. */ + public final long reloadExpiresAt; + + private AuthAck(boolean accepted, String reason, String token, long expiresAt, + String reloadToken, long reloadExpiresAt) { this.accepted = accepted; this.reason = reason; this.token = token; this.expiresAt = expiresAt; + this.reloadToken = reloadToken; + this.reloadExpiresAt = reloadExpiresAt; } public static AuthAck ok(String token, long expiresAt) { - return new AuthAck(true, "", token, expiresAt); + return new AuthAck(true, "", token, expiresAt, "", 0L); + } + + public static AuthAck ok(String token, long expiresAt, String reloadToken, + long reloadExpiresAt) { + return new AuthAck(true, "", token, expiresAt, reloadToken, reloadExpiresAt); } public static AuthAck refused(String reason) { - return new AuthAck(false, reason, "", 0L); + return new AuthAck(false, reason, "", 0L, "", 0L); } public Buf write() { @@ -414,6 +443,8 @@ public final class Packets { if (this.accepted) { buf.writeString(this.token); buf.writeLong(this.expiresAt); + buf.writeString(this.reloadToken); + buf.writeLong(this.reloadExpiresAt); } return buf; } @@ -426,7 +457,8 @@ public final class Packets { if (!accepted) { return refused(reason); } - return ok(in.readString(MAX_TEXT), in.readLong()); + return ok(in.readString(MAX_TEXT), in.readLong(), in.readString(MAX_TEXT), + in.readLong()); } } @@ -1007,12 +1039,12 @@ public final class Packets { * sent to the game server and nowhere else, and the game server uses it * once, to ask the launcher whether it is good. */ - public static final class LauncherAuth implements Packet { + public static final class TokenAuth implements Packet { public final String account; public final String uuid; public final String token; - public LauncherAuth(String account, String uuid, String token) { + public TokenAuth(String account, String uuid, String token) { this.account = account; this.uuid = uuid; this.token = token; @@ -1026,8 +1058,8 @@ public final class Packets { return buf; } - public static LauncherAuth read(Buf in) { - return new LauncherAuth(in.readString(MAX_TEXT), in.readString(MAX_TEXT), + public static TokenAuth read(Buf in) { + return new TokenAuth(in.readString(MAX_TEXT), in.readString(MAX_TEXT), in.readString(MAX_TEXT)); } } @@ -1707,6 +1739,101 @@ public final class Packets { } } + /** + * The client asks for a longer session. + * + * The token in it is the one the server handed out with the session. It + * lives inside the game and for that: it does not touch the account's own + * reload token. + */ + public static final class SessionReload implements Packet { + + public final String token; + + public SessionReload(String token) { + this.token = token == null ? "" : token; + } + + public Buf write() { + Buf buf = new Buf(64 + this.token.length()); + buf.writeString(this.token); + return buf; + } + + public static SessionReload read(Buf in) { + return new SessionReload(in.readString(MAX_TEXT)); + } + } + + /** The session was made longer. */ + public static final class SessionRenewed implements Packet { + + public final boolean accepted; + public final String reason; + public final String token; + public final long expiresAt; + public final String reloadToken; + public final long reloadExpiresAt; + + public SessionRenewed(boolean accepted, String reason, String token, long expiresAt, + String reloadToken, long reloadExpiresAt) { + this.accepted = accepted; + this.reason = reason == null ? "" : reason; + this.token = token == null ? "" : token; + this.expiresAt = expiresAt; + this.reloadToken = reloadToken == null ? "" : reloadToken; + this.reloadExpiresAt = reloadExpiresAt; + } + + public Buf write() { + Buf buf = new Buf(128); + buf.writeBoolean(this.accepted); + buf.writeString(this.reason); + if (this.accepted) { + buf.writeString(this.token); + buf.writeLong(this.expiresAt); + buf.writeString(this.reloadToken); + buf.writeLong(this.reloadExpiresAt); + } + return buf; + } + + public static SessionRenewed read(Buf in) { + boolean accepted = in.readBoolean(); + String reason = in.readString(MAX_TEXT); + if (!accepted) { + return new SessionRenewed(false, reason, "", 0L, "", 0L); + } + return new SessionRenewed(true, reason, in.readString(MAX_TEXT), in.readLong(), + in.readString(MAX_TEXT), in.readLong()); + } + } + + /** + * Whether the player is one of the administrators. + * + * The server is the one who decides, and it can take the rights away + * while the player is looking at the world. + */ + public static final class AdminState implements Packet { + + public final boolean admin; + + public AdminState(boolean admin) { + this.admin = admin; + } + + public Buf write() { + Buf buf = new Buf(4); + buf.writeBoolean(this.admin); + return buf; + } + + public static AdminState read(Buf in) { + return new AdminState(in.readBoolean()); + } + } + /** * Every packet id this build knows, and what it decodes into. * @@ -1731,7 +1858,7 @@ public final class Packets { map.put(Integer.valueOf(PONG), "Pong"); map.put(Integer.valueOf(ENTITY_SPAWN), "EntitySpawn"); map.put(Integer.valueOf(ENTITY_REMOVE), "EntityRemove"); - map.put(Integer.valueOf(LAUNCHER_AUTH), "LauncherAuth"); + map.put(Integer.valueOf(TOKEN_AUTH), "TokenAuth"); map.put(Integer.valueOf(SET_GAME_MODE), "GameMode"); map.put(Integer.valueOf(INVENTORY), "Inventory"); map.put(Integer.valueOf(INVENTORY_SLOT), "SlotChange"); @@ -1754,6 +1881,9 @@ public final class Packets { map.put(Integer.valueOf(CHAT), "Chat"); map.put(Integer.valueOf(CHAT_SEND), "ChatSend"); map.put(Integer.valueOf(PLAYER_LIST), "PlayerList"); + map.put(Integer.valueOf(SESSION_RELOAD), "SessionReload"); + map.put(Integer.valueOf(SESSION_RENEWED), "SessionRenewed"); + map.put(Integer.valueOf(ADMIN_STATE), "AdminState"); map.put(Integer.valueOf(DISCONNECT), "Disconnect"); return map; } diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java index 127cc24..dc79ce0 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ItemTest.java @@ -343,6 +343,33 @@ public final class ItemTest { eq("two names", Integer.valueOf(2), Integer.valueOf(listBack.names.length)); eq("the first name", "One", listBack.names[0]); eq("the second mode", Integer.valueOf(1), Integer.valueOf(listBack.gameModes[1])); + + Packets.AuthAck ack = Packets.AuthAck.ok("the session", 111L, "the reload", 222L); + Packets.AuthAck ackBack = Packets.AuthAck.read(new Buf(ack.write().toByteArray())); + yes("the session was made", ackBack.accepted); + eq("the session token", "the session", ackBack.token); + eq("when it runs out", Long.valueOf(111L), Long.valueOf(ackBack.expiresAt)); + eq("the reload token", "the reload", ackBack.reloadToken); + eq("when the reload token runs out", Long.valueOf(222L), + Long.valueOf(ackBack.reloadExpiresAt)); + + Packets.SessionReload reload = new Packets.SessionReload("the reload"); + Packets.SessionReload reloadBack = + Packets.SessionReload.read(new Buf(reload.write().toByteArray())); + eq("the reload token survives", "the reload", reloadBack.token); + + Packets.SessionRenewed renewed = new Packets.SessionRenewed(true, "", + "the session", 333L, "the reload", 444L); + Packets.SessionRenewed renewedBack = + Packets.SessionRenewed.read(new Buf(renewed.write().toByteArray())); + yes("the session was made longer", renewedBack.accepted); + eq("the new session token", "the session", renewedBack.token); + eq("the new reload token", "the reload", renewedBack.reloadToken); + + Packets.AdminState admin = new Packets.AdminState(true); + Packets.AdminState adminBack = + Packets.AdminState.read(new Buf(admin.write().toByteArray())); + yes("the right is there", adminBack.admin); } private static void near(Object what, double expected, double actual) { diff --git a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java index a82c00a..3d97ef3 100644 --- a/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java +++ b/rsx-protocol/src/test/java/net/rustnotmc/protocol/ProtocolTest.java @@ -488,10 +488,10 @@ public final class ProtocolTest { eq("remove", Integer.valueOf(11), Integer.valueOf( Packets.EntityRemove.read(new Buf(remove.write().toByteArray())).entityId)); - Packets.LauncherAuth launcher = new Packets.LauncherAuth("Player", + Packets.TokenAuth launcher = new Packets.TokenAuth("Player", "00000000-0000-0000-0000-000000000000", "a.b.c"); - Packets.LauncherAuth launcherBack = - Packets.LauncherAuth.read(new Buf(launcher.write().toByteArray())); + Packets.TokenAuth launcherBack = + Packets.TokenAuth.read(new Buf(launcher.write().toByteArray())); eq("launcher account", "Player", launcherBack.account); eq("launcher uuid", "00000000-0000-0000-0000-000000000000", launcherBack.uuid); eq("launcher token", "a.b.c", launcherBack.token); @@ -544,7 +544,7 @@ public final class ProtocolTest { Packets.LOGIN, Packets.LOGIN_ACK, Packets.JOIN_WORLD, Packets.WORLD_JOIN, Packets.CHUNK_DATA, Packets.BLOCK_CHANGE, Packets.PLAYER_MOVE, Packets.ENTITY_MOVE, Packets.PING, Packets.PONG, - Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE, Packets.LAUNCHER_AUTH, + Packets.ENTITY_SPAWN, Packets.ENTITY_REMOVE, Packets.TOKEN_AUTH, Packets.SET_GAME_MODE, Packets.INVENTORY, Packets.INVENTORY_SLOT, Packets.DIG, Packets.BREAK_PROGRESS, Packets.CRAFT_START, Packets.CRAFT_UPDATE, Packets.RESEARCH_START, Packets.RESEARCH_UPDATE, Packets.TECH_TREE_UNLOCK, @@ -553,6 +553,7 @@ public final class ProtocolTest { Packets.LEARN_BLUEPRINT, Packets.HITBOXES, Packets.HITBOX_INSTANCES, Packets.HITBOXES_RELOAD, Packets.ATTACK, Packets.CHAT, Packets.CHAT_SEND, Packets.PLAYER_LIST, + Packets.SESSION_RELOAD, Packets.SESSION_RENEWED, Packets.ADMIN_STATE, Packets.DISCONNECT, }; for (int i = 0; i < ids.length; i++) { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java index f9e7853..a608246 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ClientSession.java @@ -46,6 +46,11 @@ public final class ClientSession implements Runnable { private final Socket socket; private final Server server; + + /** The server this session belongs to. */ + Server server() { + return this.server; + } private final FrameDecoder decoder; private final long id; private final String address; @@ -57,6 +62,16 @@ public final class ClientSession implements Runnable { private volatile long deadline; private volatile String account; private volatile String token; + + /** The identity of the player, as the launcher gave it. */ + private volatile String uuid = ""; + + /** When the session runs out. */ + private volatile long sessionExpiresAt; + + /** The token that makes the session longer, and when it runs out. */ + private volatile String reloadToken = ""; + private volatile long reloadExpiresAt; private volatile int playerId = -1; private volatile int entityId = -1; private volatile String lastError; @@ -202,8 +217,11 @@ public final class ClientSession implements Runnable { case Packets.AUTH: onAuth(frame); return true; - case Packets.LAUNCHER_AUTH: - onLauncherAuth(frame); + case Packets.TOKEN_AUTH: + onTokenAuth(frame); + return true; + case Packets.SESSION_RELOAD: + onSessionReload(frame); return true; case Packets.LOGIN: onLogin(frame); @@ -303,50 +321,142 @@ public final class ClientSession implements Runnable { } /** - * The launcher's session, presented instead of a password. + * The token of the launcher, presented instead of a password. * - * The launcher said who the player is and the game server asks the - * launcher whether it is telling the truth. The token is not kept. + * The launcher said who the player is and the game server checks the + * token itself: the signature is made with a key both sides know, so + * nobody has to ask anybody. The token is not kept, only what it says. */ - private void onLauncherAuth(Frame frame) { + private void onTokenAuth(Frame frame) { if (this.state != SessionState.HANDSHAKING) { return; } - Packets.LauncherAuth launcher = Packets.LauncherAuth.read(new Buf(frame.body())); + Packets.TokenAuth given = Packets.TokenAuth.read(new Buf(frame.body())); - LauncherAuth checker = this.server.launcherAuth(); - if (checker == null) { + long now = System.currentTimeMillis(); + TokenAuth checker = this.server.tokenAuth(); + if (checker != null) { + net.rustnotmc.auth.GameToken.Claims claims = + checker.check(given.uuid, given.token, now); + if (claims == null) { + this.server.log("token of " + given.account + " refused: " + + "it is not ours, it is for another server, or it has run out"); + this.server.throttle().failed(given.account, this.address); + send(Packets.AUTH_ACK, Packets.AuthAck.refused( + "the session is not valid, please sign in again")); + close(); + return; + } + + // The name is inside the token, and the uuid has to be the same + // one the token was made for. + String canonical = this.server.canonicalAccount(claims.nick); + if (canonical == null) { + canonical = claims.nick; + } + this.server.throttle().succeeded(given.account); + this.account = canonical; + this.uuid = claims.uuid == null ? "" : claims.uuid; + this.state = SessionState.AUTHENTICATED; + this.sendSession(checker, claims.uuid, canonical, now); + this.server.log("session ok: " + canonical + " from " + this.address); + return; + } + + // The server has no signed tokens of its own, so only the tokens of + // its own accounts get in. That is the way a server runs without a + // launcher. + net.rustnotmc.auth.TokenService.Token own = + this.server.tokens().check(given.token); + if (own == null || own.expired(now)) { + this.server.log("token of " + given.account + " refused: it is not ours"); + this.server.throttle().failed(given.account, this.address); send(Packets.AUTH_ACK, Packets.AuthAck.refused( - "this server does not accept launcher sessions")); + "the session is not valid, please sign in again")); close(); return; } - - String canonical = this.server.canonicalAccount(launcher.account); - if (canonical == null) { - // the launcher knows the account, but the game server has never - // seen it. That is not a failure: the first time a player joins is - // the moment the game server learns about them. - canonical = launcher.account; - } - - String refusal = checker.check(canonical, launcher.uuid, launcher.token); - this.server.log("launcher check for " + canonical + ": " - + (refusal == null ? "accepted" : "refused, " + refusal)); - if (refusal != null) { - this.server.throttle().failed(launcher.account, this.address); - send(Packets.AUTH_ACK, Packets.AuthAck.refused(refusal)); - close(); - return; - } - - this.server.throttle().succeeded(launcher.account); - net.rustnotmc.auth.TokenService.Token issued = this.server.tokens().issue(canonical); - this.account = canonical; - this.token = issued.value; + this.server.throttle().succeeded(given.account); + this.account = own.account; this.state = SessionState.AUTHENTICATED; - send(Packets.AUTH_ACK, Packets.AuthAck.ok(issued.value, issued.expiresAt)); - this.server.log("launcher session ok: " + canonical + " from " + this.address); + this.token = own.value; + this.sessionExpiresAt = own.expiresAt; + send(Packets.AUTH_ACK, Packets.AuthAck.ok(own.value, own.expiresAt)); + this.server.log("own session ok: " + own.account + " from " + this.address); + } + + /** + * Gives the player a session and the token that makes it longer. + * + * The session lasts an hour. The token that makes it longer lives an hour + * and a half and lives only inside the game: it has nothing to do with the + * reload token of the account in the launcher. + */ + private void sendSession(TokenAuth checker, String uuid, String nick, long now) { + long sessionUntil = now + TokenAuth.SESSION_MILLIS; + long reloadUntil = now + TokenAuth.RELOAD_MILLIS; + String session = checker.issueSession(uuid, nick, sessionUntil); + String reload = checker.issueReload(uuid, nick, reloadUntil); + this.token = session; + this.sessionExpiresAt = sessionUntil; + this.reloadToken = reload; + this.reloadExpiresAt = reloadUntil; + send(Packets.AUTH_ACK, Packets.AuthAck.ok(session, sessionUntil, reload, reloadUntil)); + } + + /** + * The session is made longer. + * + * The answer is a different packet from the one that opens a session: the + * client is already in the world and only needs the new times. + */ + private void sendRenewed(TokenAuth checker, String uuid, String nick, long now) { + long sessionUntil = now + TokenAuth.SESSION_MILLIS; + long reloadUntil = now + TokenAuth.RELOAD_MILLIS; + String session = checker.issueSession(uuid, nick, sessionUntil); + String reload = checker.issueReload(uuid, nick, reloadUntil); + this.token = session; + this.sessionExpiresAt = sessionUntil; + this.reloadToken = reload; + this.reloadExpiresAt = reloadUntil; + send(Packets.SESSION_RENEWED, new Packets.SessionRenewed(true, "", session, + sessionUntil, reload, reloadUntil)); + } + + /** + * The session ran out and the client asks for a longer one. + * + * The token in it is the one the server handed out with the session. When + * it is good the session is made an hour longer and a new token that makes + * it longer is handed out. + */ + private void onSessionReload(Frame frame) { + if (this.state != SessionState.PLAYING && this.state != SessionState.AUTHENTICATED) { + return; + } + Packets.SessionReload request = Packets.SessionReload.read(new Buf(frame.body())); + TokenAuth checker = this.server.tokenAuth(); + if (checker == null) { + send(Packets.SESSION_RENEWED, new Packets.SessionRenewed(false, + "this server has no sessions to make longer", "", 0L, "", 0L)); + return; + } + long now = System.currentTimeMillis(); + net.rustnotmc.auth.GameToken.Claims claims = + checker.checkReload(request.token, now); + if (claims == null) { + send(Packets.SESSION_RENEWED, new Packets.SessionRenewed(false, + "the session cannot be made longer, please sign in again", + "", 0L, "", 0L)); + return; + } + this.sendRenewed(checker, claims.uuid, claims.nick, now); + this.server.log("session made longer for " + this.account); + } + + /** Tells the client whether the player is an administrator. */ + void sendAdminState(boolean admin) { + send(Packets.ADMIN_STATE, new Packets.AdminState(admin)); } private void onAuth(Frame frame) { @@ -405,6 +515,25 @@ public final class ClientSession implements Runnable { this.server.log("auth ok: " + name + " from " + this.address); } + /** + * Whether the token is one the server gave out. + * + * There are two kinds: the session token of the launcher, which the + * server signed itself, and the tokens of the server's own accounts. + */ + private boolean tokenAccepted(String token, String account) { + if (this.server.tokens().valid(token, account)) { + return true; + } + TokenAuth checker = this.server.tokenAuth(); + if (checker == null) { + return false; + } + net.rustnotmc.auth.GameToken.Claims claims = + checker.check(this.uuid, token, System.currentTimeMillis()); + return claims != null; + } + private void onLogin(Frame frame) { // a token may be redeemed straight after the hello, without the // password step. A client that logged in once keeps its token and @@ -416,7 +545,7 @@ public final class ClientSession implements Runnable { Packets.Login login = Packets.Login.read(in); - if (!this.server.tokens().valid(login.token, login.account)) { + if (!this.tokenAccepted(login.token, login.account)) { send(Packets.LOGIN_ACK, Packets.LoginAck.refused("bad or expired token")); close(); diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java b/rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java deleted file mode 100644 index d621b50..0000000 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/LauncherAuth.java +++ /dev/null @@ -1,211 +0,0 @@ -package net.rustnotmc.server; - -import java.io.IOException; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.nio.charset.StandardCharsets; -import java.time.Duration; - -/** - * Asks the launcher whether a player's token is good. - * - * The launcher is where accounts live: it has the passwords, the roles, the - * bans and the friends. The game server has none of that and does not want a - * second copy of it, so it asks. - * - * The launcher answers with a verdict and a reason, and the reason is shown to - * the player as it is. A player who was banned is told they were banned, not - * that something went wrong. - * - * The token is used once and then dropped. It is a bearer credential, and a - * game server that keeps it can do things the player could not. - */ -public final class LauncherAuth { - - /** How long the game server waits for the launcher to answer. */ - public static final Duration TIMEOUT = Duration.ofSeconds(10L); - - private final String endpoint; - private final HttpClient http; - - public LauncherAuth(String baseUrl) { - this.endpoint = trim(baseUrl) + "/auth/validate"; - this.http = HttpClient.newBuilder() - .connectTimeout(TIMEOUT) - .followRedirects(HttpClient.Redirect.NEVER) - .build(); - } - - /** - * Checks a token. - * - * Returns null when the token is good, and a reason when it is not. A - * launcher that cannot be reached is a reason too: the player is told the - * server cannot check them, which is true. - */ - public String check(String account, String uuid, String token) { - if (token == null || token.isEmpty()) { - return "no session was given"; - } - String body = "{\"username\":" + quote(account) + ",\"uuid\":" + quote(uuid) + "}"; - try { - HttpRequest request = HttpRequest.newBuilder(URI.create(this.endpoint)) - .timeout(TIMEOUT) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + token) - .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) - .build(); - HttpResponse response = - this.http.send(request, HttpResponse.BodyHandlers.ofString()); - - if (response.statusCode() == 401) { - return "the session has expired, please sign in again"; - } - if (response.statusCode() == 403) { - return "the session does not belong to this player"; - } - if (response.statusCode() / 100 != 2) { - return "the launcher answered with " + response.statusCode(); - } - String text = response.body() == null ? "" : response.body(); - Boolean valid = readBoolean(text, "valid"); - if (Boolean.TRUE.equals(valid)) { - return null; - } - if (Boolean.FALSE.equals(valid)) { - String reason = readString(text, "reason"); - return reason == null || reason.isEmpty() - ? "the launcher refused the session" : reason; - } - return "the launcher gave an answer we do not understand"; - } catch (IOException e) { - return "the launcher could not be reached"; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return "the launcher check was interrupted"; - } - } - - /** - * A boolean field of a flat JSON object. - * - * The answer is read rather than matched: a launcher that writes - * {@code {"valid": true}} with a space and one that writes - * {@code {"valid":true}} without one are the same answer, and a server - * that only understands the second turns a working launcher into a broken - * one. - */ - private static Boolean readBoolean(String text, String key) { - int at = text.indexOf('"' + key + '"'); - if (at < 0) { - return null; - } - int i = skipToValue(text, at + key.length() + 2); - if (i < 0) { - return null; - } - if (text.startsWith("true", i)) { - return Boolean.TRUE; - } - if (text.startsWith("false", i)) { - return Boolean.FALSE; - } - return null; - } - - /** A string field of a flat JSON object. */ - private static String readString(String text, String key) { - int at = text.indexOf('"' + key + '"'); - if (at < 0) { - return null; - } - int i = skipToValue(text, at + key.length() + 2); - if (i < 0 || i >= text.length() || text.charAt(i) != '"') { - return null; - } - StringBuilder out = new StringBuilder(); - i++; - while (i < text.length()) { - char c = text.charAt(i++); - if (c == '"') { - return out.toString(); - } - if (c != '\\' || i >= text.length()) { - out.append(c); - continue; - } - char escaped = text.charAt(i++); - switch (escaped) { - case 'n': out.append('\n'); break; - case 't': out.append('\t'); break; - case 'r': out.append('\r'); break; - case 'b': out.append('\b'); break; - case 'f': out.append('\f'); break; - case 'u': - if (i + 4 <= text.length()) { - try { - out.append((char) Integer.parseInt(text.substring(i, i + 4), 16)); - i += 4; - } catch (NumberFormatException notHex) { - out.append(escaped); - } - } - break; - default: - out.append(escaped); - } - } - return null; - } - - /** The place where a field's value starts, or -1. */ - private static int skipToValue(String text, int from) { - int i = from; - while (i < text.length() && Character.isWhitespace(text.charAt(i))) { - i++; - } - if (i < text.length() && text.charAt(i) == ':') { - i++; - } - while (i < text.length() && Character.isWhitespace(text.charAt(i))) { - i++; - } - return i < text.length() ? i : -1; - } - - /** A string in JSON, with the quotes and the backslashes put right. */ - private static String quote(String value) { - if (value == null) { - return "\"\""; - } - StringBuilder out = new StringBuilder(value.length() + 2); - out.append('"'); - for (int i = 0; i < value.length(); i++) { - char c = value.charAt(i); - if (c == '"' || c == '\\') { - out.append('\\'); - } - out.append(c); - } - out.append('"'); - return out.toString(); - } - - private static String trim(String url) { - if (url == null) { - return ""; - } - String trimmed = url.trim(); - while (trimmed.endsWith("/")) { - trimmed = trimmed.substring(0, trimmed.length() - 1); - } - return trimmed; - } - - /** The address the check goes to. */ - public String endpoint() { - return this.endpoint; - } -} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java index cf2f8f5..9253724 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Player.java @@ -62,6 +62,9 @@ public final class Player { /** What the player is carrying. The server owns it. */ private final Inventory inventory; + /** Whether the player is one of the administrators, as we last told them. */ + private boolean admin; + /** How much life the player has left. */ private float health = MAX_HEALTH; @@ -126,6 +129,26 @@ public final class Player { return this.inventory; } + /** + * Whether the player is one of the administrators. + * + * The list belongs to this server, and it is read again and again, so a + * right that is taken away is gone at once. + */ + public boolean isAdmin() { + return this.session.server().admins().isAdmin(this.account); + } + + /** What the client was last told about the rights of the player. */ + public boolean admin() { + return this.admin; + } + + /** Remembers what the client was told. */ + public void admin(boolean admin) { + this.admin = admin; + } + /** What the player knows. */ public Knowledge knowledge() { return this.knowledge; @@ -337,12 +360,12 @@ public final class Player { /** * Changes a block, if the player is allowed to. * - * Two things are checked: that the block id is one the world knows, and - * that the player is close enough to have plausibly touched it. Without the - * second, a client can rewrite the world from the other side of it. + * The blocks of the world are the administrators' work: a player who is + * not one of them does not break them, whatever the game mode says. A + * creative player is not an administrator. */ public boolean changeBlock(World world, int x, int y, int z, byte id) { - if (!mayBreakBlocks()) { + if (!isAdmin()) { return false; } if (!Blocks.isValid(id)) { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java index 993531e..fbc074e 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/Server.java @@ -18,6 +18,7 @@ import net.rustnotmc.auth.Passwords; import net.rustnotmc.auth.TokenService; import net.rustnotmc.protocol.Packets; import net.rustnotmc.server.world.ChunkPos; +import net.rustnotmc.server.admin.AdminRegistry; import net.rustnotmc.server.hitbox.HitboxRegistry; import net.rustnotmc.server.world.ChunkStorage; import net.rustnotmc.server.world.World; @@ -55,8 +56,9 @@ public final class Server { private final AtomicInteger peakOnline = new AtomicInteger(); private final World world; - private volatile LauncherAuth launcherAuth; + private volatile TokenAuth tokenAuth; private volatile boolean localAccounts = true; + private volatile AdminRegistry admins = new AdminRegistry(null); private volatile HitboxRegistry hitboxes = new HitboxRegistry(null); private volatile ServerSocket listener; @@ -151,12 +153,39 @@ public final class Server { * Null when there is none, which means the launcher path is closed and * only the server's own accounts work. */ - public LauncherAuth launcherAuth() { - return this.launcherAuth; + public TokenAuth tokenAuth() { + return this.tokenAuth; } - public void launcherAuth(LauncherAuth launcherAuth) { - this.launcherAuth = launcherAuth; + public void tokenAuth(TokenAuth tokenAuth) { + this.tokenAuth = tokenAuth; + } + + /** + * Who may do the things only an administrator may do. + * + * The list is the server's own, and it is read again and again, so that a + * right taken away is taken away at once. + */ + public AdminRegistry admins() { + return this.admins; + } + + public void admins(AdminRegistry admins) { + this.admins = admins == null ? new AdminRegistry(null) : admins; + } + + /** + * Tells everybody whether they are an administrator. + * + * The client needs to know: the things that only an administrator may do + * are not offered to anybody else, and a right that is taken away while + * the player is looking has to disappear at once. + */ + public void sendAdminStates() { + for (Player player : this.players.values()) { + player.session().sendAdminState(player.isAdmin()); + } } /** @@ -396,6 +425,15 @@ public final class Server { this.world.evictIdle(); for (Player player : this.players.values()) { tickResearch(player); + // the rights of a player can be taken away while they play, and + // the client has to hear about it at once + boolean admin = player.isAdmin(); + if (admin != player.admin()) { + player.admin(admin); + player.session().sendAdminState(admin); + log("rights of " + player.account() + ": " + + (admin ? "given" : "taken away")); + } } } if ((this.ticks.get() & 0x3FFFL) == 0L) { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java index 63778b9..8145451 100644 --- a/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/ServerMain.java @@ -1,6 +1,7 @@ package net.rustnotmc.server; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import net.rustnotmc.server.world.ChunkStorage; import net.rustnotmc.server.world.World; @@ -13,19 +14,23 @@ import net.rustnotmc.server.world.World; * only has to be obviously correct rather than clever. * * run-server.ps1 [--port N] [--accounts path] [--name text] [--motd text] - * [--world path] [--seed N] [--launcher-api url] - * [--allow-local-accounts] [--gamemode survival|creative] + * [--world path] [--seed N] [--token-key text] [--audience name] + * [--admins path] [--gamemode survival|creative] * run-server.ps1 --register Player [--password text] + * run-server.ps1 --issue-token Player * * Registration is an operator action and not something a client can do over the * wire. Open self-registration turns an account store into a spam target and a * support queue, and neither is a problem worth having at the start. * - * With --launcher-api the launcher is where accounts live and therefore the - * way in: the game server asks it whether a player is one of ours, and it - * also gets the ban list for free. The server's own accounts are then shut, - * because a server with two doors has no doors; --allow-local-accounts opens - * them again on purpose. + * The way in is the token of the launcher. The launcher asks the auth server + * whether the player may play and hands the game a token; the game server + * checks that token itself, with the key both sides know, and never asks the + * launcher anything. The server's own accounts are for a server that runs + * without a launcher, and --issue-token makes one of them for a test. + * + * The administrators are the server's own list, in a file that is read again + * and again: a right that is taken away is taken away at once. */ public final class ServerMain { @@ -46,6 +51,11 @@ public final class ServerMain { String register = null; String password = null; Path hitboxes = null; + String tokenKey = null; + String audience = "default"; + Path adminsFile = null; + String issueToken = null; + String devToken = null; for (int i = 0; i < args.length - 1; i++) { String flag = args[i]; @@ -60,6 +70,16 @@ public final class ServerMain { worldDir = Path.of(value); } else if ("--hitboxes".equals(flag)) { hitboxes = Path.of(value); + } else if ("--token-key".equals(flag)) { + tokenKey = value; + } else if ("--audience".equals(flag)) { + audience = value; + } else if ("--admins".equals(flag)) { + adminsFile = Path.of(value); + } else if ("--issue-token".equals(flag)) { + issueToken = value; + } else if ("--dev-token".equals(flag)) { + devToken = value; } else if ("--seed".equals(flag)) { seed = Long.parseLong(value); } else if ("--launcher-api".equals(flag)) { @@ -96,11 +116,19 @@ public final class ServerMain { // the launcher is where accounts live, so it is the way in. The // server's own accounts stay shut unless they are opened on // purpose, because a server with two doors has no doors. - server.launcherAuth(new LauncherAuth(launcherApi)); server.localAccounts(allowLocalAccounts); } else { server.localAccounts(true); } + + // The key the tokens are signed with. The auth server and the game + // server share it, and nobody has to ask anybody anything. + if (tokenKey != null) { + server.tokenAuth(new TokenAuth(tokenKey, audience)); + } + // The administrators are the server's own list. + Path adminFile = adminsFile != null ? adminsFile : worldDir.resolve("admins.txt"); + server.admins(new net.rustnotmc.server.admin.AdminRegistry(adminFile)); server.loadAccounts(accounts); final Path accountFile = accounts.toAbsolutePath(); @@ -121,14 +149,38 @@ public final class ServerMain { } } + if (issueToken != null) { + // A token for a server that runs without a launcher. The game takes + // it as its access token and never asks for a password. + net.rustnotmc.auth.TokenService.Token issued = + server.tokens().issue(issueToken); + System.out.println("[rsx] token for " + issueToken + ":"); + System.out.println(issued.value); + System.out.println("[rsx] it runs out at " + issued.expiresAt); + return; + } + server.start(); server.log("accounts file: " + accountFile); server.log("world folder: " + worldDir.toAbsolutePath() + ", seed " + seed); - if (server.launcherAuth() != null) { - server.log("launcher: " + server.launcherAuth().endpoint() - + (server.localAccounts() - ? " (local accounts are open as well)" - : " (local accounts are shut)")); + server.log("admins file: " + adminFile.toAbsolutePath()); + if (devToken != null) { + // A server without a launcher has no other way in, so it hands out + // a token of its own and writes it where the game can find it. + net.rustnotmc.auth.TokenService.Token issued = + server.tokens().issue(devToken); + Path tokenFile = worldDir.resolve("dev-token.txt"); + try { + Files.write(tokenFile, issued.value.getBytes("UTF-8")); + server.log("token for " + devToken + " written to " + tokenFile); + } catch (IOException e) { + server.log("could not write the token: " + e.getMessage()); + } + } + if (server.tokenAuth() != null) { + server.log("tokens: signed, for the server " + server.tokenAuth().audience()); + } else { + server.log("tokens: none, only the server's own accounts will get in"); } Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() { diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java b/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java new file mode 100644 index 0000000..2280356 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/TokenAuth.java @@ -0,0 +1,105 @@ +package net.rustnotmc.server; + +import net.rustnotmc.auth.GameToken; + +/** + * The token the launcher hands to the game. + * + * The launcher asks the auth server whether the player may play. When the + * answer is yes, the launcher gives the game a token and a uuid, and the game + * shows both to this server. This server checks the token itself: the + * signature is made with a key both sides know, so nobody has to ask anybody + * and a launcher that is down does not lock anybody out. + * + * The token is meant for one server only. A token that was made for another + * server is not accepted here, and the reason says so. + */ +public final class TokenAuth { + + /** How long a session lasts. */ + public static final long SESSION_MILLIS = 60L * 60L * 1000L; + + /** + * How long the token that makes a session longer lasts. + * + * It lives inside the game and for that: it has nothing to do with the + * reload token of the account in the launcher. + */ + public static final long RELOAD_MILLIS = 90L * 60L * 1000L; + + private final GameToken tokens; + + /** The server the tokens are meant for. */ + private final String audience; + + public TokenAuth(String key, String audience) { + this.tokens = new GameToken(key); + this.audience = audience == null || audience.isEmpty() ? "default" : audience; + } + + /** The server the tokens are meant for. */ + public String audience() { + return this.audience; + } + + /** + * Checks a token. + * + * Returns the claims of the token, or null when the token is not ours. The + * caller tells the player what was wrong. + */ + public GameToken.Claims check(String uuid, String token, long now) { + GameToken.Claims claims = this.tokens.read(token); + if (claims == null) { + return null; + } + if (!GameToken.KIND_SESSION.equals(claims.kind)) { + return null; + } + if (!this.audience.equals(claims.audience)) { + return null; + } + if (claims.expiresAt <= now) { + return null; + } + if (uuid != null && !uuid.isEmpty() && claims.uuid != null + && !uuid.equals(claims.uuid)) { + return null; + } + return claims; + } + + /** + * Checks the token that makes a session longer. + * + * Returns the claims, or null when the token is not ours or has run out. + */ + public GameToken.Claims checkReload(String token, long now) { + GameToken.Claims claims = this.tokens.read(token); + if (claims == null) { + return null; + } + if (!GameToken.KIND_RELOAD.equals(claims.kind)) { + return null; + } + if (!this.audience.equals(claims.audience)) { + return null; + } + if (claims.expiresAt <= now) { + return null; + } + return claims; + } + + /** Makes a session token. */ + public String issueSession(String uuid, String nick, long expiresAt) { + return this.tokens.issue(uuid, nick, this.audience, GameToken.KIND_SESSION, + expiresAt); + } + + /** Makes the token that makes a session longer. */ + public String issueReload(String uuid, String nick, long expiresAt) { + return this.tokens.issue(uuid, nick, this.audience, GameToken.KIND_RELOAD, + expiresAt); + } +} diff --git a/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java b/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java new file mode 100644 index 0000000..0542ab9 --- /dev/null +++ b/rsx-server-core/src/main/java/net/rustnotmc/server/admin/AdminRegistry.java @@ -0,0 +1,108 @@ +package net.rustnotmc.server.admin; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; + +/** + * Who may do the things only an administrator may do. + * + * The list belongs to the server and to nobody else. The launcher knows who + * paid for what, but the launcher does not say: a server that takes its + * administrators from somewhere else cannot take them away, and an + * administrator here is an administrator of this server and not of any other. + * + * The file is read again and again, so that taking a right away takes effect + * at once and not at the next restart. + */ +public final class AdminRegistry { + + /** The file the administrators are written in. */ + private final Path file; + + private final Set admins = new HashSet(); + + private long lastRead; + + public AdminRegistry(Path file) { + this.file = file; + read(); + } + + /** The file the administrators are written in, or null. */ + public Path file() { + return this.file; + } + + /** + * Whether the account is one of the administrators. + * + * The file is read again when it changed, so the answer is never older + * than the file. + */ + public boolean isAdmin(String account) { + if (account == null || account.isEmpty()) { + return false; + } + reread(); + return this.admins.contains(account.toLowerCase(Locale.ROOT)); + } + + /** How many administrators there are. */ + public int size() { + reread(); + return this.admins.size(); + } + + /** The names of the administrators. */ + public Set admins() { + reread(); + return Collections.unmodifiableSet(this.admins); + } + + /** Reads the file again when it changed. */ + private void reread() { + if (this.file == null) { + return; + } + try { + long changed = Files.getLastModifiedTime(this.file).toMillis(); + if (changed != this.lastRead) { + read(); + } + } catch (IOException ignored) { + // A file that cannot be read leaves the list as it was: taking a + // right away must not be undone by a disk that hiccups. + } + } + + /** Reads the file. */ + private void read() { + this.admins.clear(); + this.lastRead = 0L; + if (this.file == null) { + return; + } + try { + if (!Files.isRegularFile(this.file)) { + return; + } + this.lastRead = Files.getLastModifiedTime(this.file).toMillis(); + for (String line : Files.readAllLines(this.file, StandardCharsets.UTF_8)) { + String name = line.trim(); + if (name.isEmpty() || name.startsWith("#")) { + continue; + } + this.admins.add(name.toLowerCase(Locale.ROOT)); + } + } catch (IOException ignored) { + // A file that cannot be read is an empty list, and the log says so + // elsewhere. + } + } +} diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java index 4bde27a..6292d37 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayClient.java @@ -163,7 +163,61 @@ final class PlayClient { /** The launcher's session, offered instead of a password. */ void launcherAuth(String account, String uuid, String token) throws IOException { - send(Packets.LAUNCHER_AUTH, new Packets.LauncherAuth(account, uuid, token)); + send(Packets.TOKEN_AUTH, new Packets.TokenAuth(account, uuid, token)); + } + + /** + * Signs in with the token of the launcher. + * + * There is no password anywhere in this: the token and the uuid are all the + * server gets, and it checks them itself. + */ + PlayClient signInWithToken(String account, String uuid, String token) + throws IOException { + Packets.HelloAck hello = hello(account, "test"); + if (hello == null) { + return null; + } + send(Packets.TOKEN_AUTH, new Packets.TokenAuth(account, uuid, token)); + Packets.AuthAck auth = awaitAuthAck(5000L); + this.lastAuth = auth; + if (auth == null || !auth.accepted) { + return null; + } + Packets.LoginAck login = login(account, auth.token); + if (login == null || !login.accepted) { + return null; + } + return this; + } + + /** The answer the server gave when the session was made. */ + Packets.AuthAck lastAuth() { + return this.lastAuth; + } + + private Packets.AuthAck lastAuth; + + /** Waits for the server to say whether the player is an administrator. */ + Boolean awaitAdminState(long millis) throws IOException { + Frame frame = await(Packets.ADMIN_STATE, millis); + if (frame == null) { + return null; + } + return Boolean.valueOf(Packets.AdminState.read( + new net.rustnotmc.protocol.Buf(frame.body())).admin); + } + + /** Asks the server to make the session longer. */ + void reloadSession(String token) throws IOException { + send(Packets.SESSION_RELOAD, new Packets.SessionReload(token)); + } + + /** Waits for the server's answer about the session. */ + Packets.SessionRenewed awaitSessionRenewed(long millis) throws IOException { + Frame frame = await(Packets.SESSION_RENEWED, millis); + return frame == null ? null : Packets.SessionRenewed.read( + new net.rustnotmc.protocol.Buf(frame.body())); } void close() { try { diff --git a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java index 4839cdc..1cd4597 100644 --- a/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java +++ b/rsx-server-core/src/test/java/net/rustnotmc/server/PlayTest.java @@ -63,21 +63,24 @@ public final class PlayTest { run("the world survives a restart", new Throwing() { public void go() throws Exception { testRestart(work); } }); - run("the launcher's session decides who gets in", new Throwing() { - public void go() throws Exception { testLauncherAuth(work); } - }); - run("a launcher session that is not ours is refused", new Throwing() { - public void go() throws Exception { testLauncherRefusal(work); } - }); - run("the server's own accounts are shut when a launcher is set", new Throwing() { - public void go() throws Exception { testLocalAccountsShut(work); } - }); run("what one player says the other hears", new Throwing() { public void go() throws Exception { testChat(work); } }); run("the list of players is told to everybody", new Throwing() { public void go() throws Exception { testPlayerList(work); } }); + run("a token of the launcher is enough to get in", new Throwing() { + public void go() throws Exception { testTokenSignIn(work); } + }); + run("a token of another server is refused", new Throwing() { + public void go() throws Exception { testTokenOfAnotherServer(work); } + }); + run("a session that ran out is made longer", new Throwing() { + public void go() throws Exception { testSessionReload(work); } + }); + run("the administrators are told apart", new Throwing() { + public void go() throws Exception { testAdminRights(work); } + }); System.out.println(); for (int i = 0; i < failures.size(); i++) { @@ -96,7 +99,11 @@ public final class PlayTest { void go() throws Exception; } + /** The test that is running, so a failure says where it came from. */ + private static String current = ""; + private static void run(String name, Throwing body) { + current = name; checks++; int before = failed; try { @@ -115,14 +122,15 @@ public final class PlayTest { private static void yes(String what, boolean condition) { if (!condition) { failed++; - failures.add(what); + failures.add(current + " -> " + what); } } private static void eq(Object what, Object expected, Object actual) { if (expected == null ? actual != null : !expected.equals(actual)) { failed++; - failures.add(what + ": expected " + expected + " but got " + actual); + failures.add(current + " -> " + what + ": expected " + expected + + " but got " + actual); } } @@ -143,6 +151,11 @@ public final class PlayTest { Files.deleteIfExists(accounts); server.loadAccounts(accounts); server.accounts().create(account, "password1".toCharArray(), 1L); + // the blocks of the world are the administrators' work, so the player + // of the test is one of them + Path admins = work.resolve("play-admins-" + account + ".txt"); + Files.write(admins, (account + "\n").getBytes("UTF-8")); + server.admins(new net.rustnotmc.server.admin.AdminRegistry(admins)); server.start(); return server; } @@ -515,6 +528,11 @@ public final class PlayTest { Server server = boot(work, "Survivor", net.rustnotmc.protocol.Packets.GAMEMODE_SURVIVAL); try { + // an ordinary player: the blocks of the world are not theirs + Path admins = work.resolve("play-admins-Survivor.txt"); + Files.write(admins, "\n".getBytes("UTF-8")); + server.admins(new net.rustnotmc.server.admin.AdminRegistry(admins)); + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); client.signIn("Survivor", "password1"); client.send(Packets.JOIN_WORLD, Packets.JoinWorld.fresh()); @@ -538,11 +556,12 @@ public final class PlayTest { yes("the client was told", mode != null && mode.mode == net.rustnotmc.protocol.Packets.GAMEMODE_CREATIVE); + // the mode is not the right to break the blocks of the world: that + // belongs to the administrators client.send(Packets.BLOCK_CHANGE, new Packets.BlockChange(0, under, 0, net.rustnotmc.protocol.Blocks.AIR)); - waitFor(() -> blockAt(server.world(), 0, under, 0) - == net.rustnotmc.protocol.Blocks.AIR, 3000L); - eq("now the block is gone", Byte.valueOf(net.rustnotmc.protocol.Blocks.AIR), + Thread.sleep(300L); + eq("and the block is still there", Byte.valueOf(before), Byte.valueOf(server.world().blockAt(0, under, 0))); client.close(); } finally { @@ -610,6 +629,10 @@ public final class PlayTest { Files.deleteIfExists(accounts); server.loadAccounts(accounts); server.accounts().create("Keeper", "password1".toCharArray(), 1L); + // the blocks of the world are the administrators' work + Path admins = work.resolve("play-admins-restart.txt"); + Files.write(admins, "Keeper\n".getBytes("UTF-8")); + server.admins(new net.rustnotmc.server.admin.AdminRegistry(admins)); server.start(); try { PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); @@ -643,139 +666,167 @@ public final class PlayTest { } } + + + /** - * A launcher, stood up for the test. + * A server whose tokens are signed with a key. * - * The real one is a service of its own with accounts, roles and bans in - * it. What the game server needs from it is one question answered one - * way, and that is what this answers. + * This is the way in for a real launcher: the launcher asks the auth + * server whether the player may play, and the game server checks the token + * itself, with the key both sides know. */ - private static final class FakeLauncher implements AutoCloseable { - - private final com.sun.net.httpserver.HttpServer http; - - /** The token the launcher accepts. */ - static final String GOOD = "a-good-token"; - - FakeLauncher() throws IOException { - this.http = com.sun.net.httpserver.HttpServer.create( - new java.net.InetSocketAddress("127.0.0.1", 0), 0); - this.http.createContext("/auth/validate", exchange -> { - String authorization = exchange.getRequestHeaders().getFirst("Authorization"); - String body = new String(exchange.getRequestBody().readAllBytes(), - java.nio.charset.StandardCharsets.UTF_8); - - String answer; - int status; - if (authorization == null || !authorization.equals("Bearer " + GOOD)) { - status = 401; - answer = "{\"valid\": false, \"reason\": \"unknown token\"}"; - } else if (!body.contains("\"username\":\"Player\"")) { - status = 403; - answer = "{\"valid\": false, \"reason\": \"not your token\"}"; - } else { - status = 200; - // a space after the colon, on purpose: the server has to - // read the answer, not match it - answer = "{\"valid\": true}"; - } - - byte[] bytes = answer.getBytes(java.nio.charset.StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json"); - exchange.sendResponseHeaders(status, bytes.length); - exchange.getResponseBody().write(bytes); - exchange.close(); - }); - this.http.start(); - } - - String url() { - return "http://127.0.0.1:" + this.http.getAddress().getPort(); - } - - @Override - public void close() { - this.http.stop(0); - } - } - - /** The launcher lets one of ours in and turns away the rest. */ - private static void testLauncherAuth(Path work) throws Exception { - try (FakeLauncher launcher = new FakeLauncher()) { - Server server = launcherServer(work, "First", launcher.url()); - try { - PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); - client.hello("Player", "test"); - client.launcherAuth("Player", "uuid-1", FakeLauncher.GOOD); - - Packets.AuthAck auth = client.awaitAuthAck(5000L); - yes("the launcher session was accepted", auth != null && auth.accepted); - if (auth != null && auth.accepted) { - Packets.LoginAck login = client.login("Player", auth.token); - yes("the player got in", login != null && login.accepted); - } - client.close(); - } finally { - server.stop(); - } - } - } - - /** A token that is not ours does not get in. */ - private static void testLauncherRefusal(Path work) throws Exception { - try (FakeLauncher launcher = new FakeLauncher()) { - Server server = launcherServer(work, "Second", launcher.url()); - try { - PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); - client.hello("Player", "test"); - client.launcherAuth("Player", "uuid-1", "somebody-elses-token"); - - Packets.AuthAck auth = client.awaitAuthAck(5000L); - yes("the session was refused", auth != null && !auth.accepted); - yes("and the reason says so", auth != null && auth.reason != null - && !auth.reason.isEmpty()); - client.close(); - } finally { - server.stop(); - } - } - } - - /** With a launcher in front, the server's own accounts are not a way in. */ - private static void testLocalAccountsShut(Path work) throws Exception { - try (FakeLauncher launcher = new FakeLauncher()) { - Server server = launcherServer(work, "Third", launcher.url()); - try { - PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); - Packets.HelloAck hello = client.hello("Third", "test"); - Packets.AuthAck auth = client.auth("Third", "password1".toCharArray(), - hello.salt, hello.nonce); - yes("the password was refused", auth != null && !auth.accepted); - yes("and the reason says why", auth != null && auth.reason != null - && auth.reason.contains("launcher")); - client.close(); - } finally { - server.stop(); - } - } - } - - /** A server that takes its sessions from a launcher. */ - private static Server launcherServer(Path work, String account, String launcherUrl) - throws IOException { + private static Server bootWithTokens(Path work, String account, String key, + String audience) throws IOException { Server server = new Server("RustNotMC play", "hello", 20, 0, new World(1234L, ChunkStorage.memoryOnly())); server.viewDistance(2); - server.launcherAuth(new LauncherAuth(launcherUrl)); - server.localAccounts(false); - Path accounts = work.resolve("launcher-accounts-" + account + ".txt"); - Files.deleteIfExists(accounts); - server.loadAccounts(accounts); - server.accounts().create(account, "password1".toCharArray(), 1L); + server.tokenAuth(new TokenAuth(key, audience)); server.start(); return server; } + /** + * The token of the launcher is enough to get in. + * + * There is no password in this: the token and the uuid are all the server + * gets, and it checks them itself. + */ + private static void testTokenSignIn(Path work) throws Exception { + String key = "the key of the auth server"; + Server server = bootWithTokens(work, "Token", key, "play"); + try { + net.rustnotmc.auth.GameToken tokens = new net.rustnotmc.auth.GameToken(key); + String uuid = "11111111-2222-3333-4444-555555555555"; + String token = tokens.issue(uuid, "Token", "play", + net.rustnotmc.auth.GameToken.KIND_SESSION, + System.currentTimeMillis() + 3600000L); + + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + yes("the player got in", client.signInWithToken("Token", uuid, token) != null); + + Packets.AuthAck ack = client.lastAuth(); + yes("the session was made", ack != null && ack.accepted); + if (ack != null) { + yes("with a token that makes it longer", + ack.reloadToken != null && !ack.reloadToken.isEmpty()); + yes("and the session runs out in an hour", + ack.expiresAt > System.currentTimeMillis()); + } + client.close(); + } finally { + server.stop(); + } + } + + /** + * A token that was made for another server is refused. + * + * The token is meant for one server only, and a token from a neighbour is + * no good here. + */ + private static void testTokenOfAnotherServer(Path work) throws Exception { + String key = "the key of the auth server"; + Server server = bootWithTokens(work, "Foreign", key, "play"); + try { + net.rustnotmc.auth.GameToken tokens = new net.rustnotmc.auth.GameToken(key); + String uuid = "11111111-2222-3333-4444-555555555555"; + String token = tokens.issue(uuid, "Foreign", "somewhere else", + net.rustnotmc.auth.GameToken.KIND_SESSION, + System.currentTimeMillis() + 3600000L); + + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + client.hello("Foreign", "test"); + client.launcherAuth("Foreign", uuid, token); + Packets.AuthAck ack = client.awaitAuthAck(3000L); + yes("the token was refused", ack != null && !ack.accepted); + client.close(); + } finally { + server.stop(); + } + } + + /** + * A session that ran out is made longer. + * + * The token that makes it longer lives inside the game and for that: it + * has nothing to do with the reload token of the account in the launcher. + */ + private static void testSessionReload(Path work) throws Exception { + String key = "the key of the auth server"; + Server server = bootWithTokens(work, "Reloader", key, "play"); + try { + net.rustnotmc.auth.GameToken tokens = new net.rustnotmc.auth.GameToken(key); + String uuid = "11111111-2222-3333-4444-555555555555"; + long now = System.currentTimeMillis(); + String session = tokens.issue(uuid, "Reloader", "play", + net.rustnotmc.auth.GameToken.KIND_SESSION, now + 3600000L); + String reload = tokens.issue(uuid, "Reloader", "play", + net.rustnotmc.auth.GameToken.KIND_RELOAD, now + 5400000L); + + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + yes("the player got in", + client.signInWithToken("Reloader", uuid, session) != null); + + client.reloadSession(reload); + Packets.SessionRenewed renewed = client.awaitSessionRenewed(3000L); + yes("the session was made longer", renewed != null && renewed.accepted); + if (renewed != null) { + yes("with a new token that makes it longer", + renewed.reloadToken != null && !renewed.reloadToken.isEmpty()); + yes("and it runs out later than before", + renewed.expiresAt > now + 3600000L); + } + + client.reloadSession("nonsense"); + Packets.SessionRenewed refused = client.awaitSessionRenewed(3000L); + yes("and a made up token is refused", + refused != null && !refused.accepted); + client.close(); + } finally { + server.stop(); + } + } + + /** + * The administrators are told apart. + * + * The list belongs to the server, and the client is told, because the + * things only an administrator may do are not offered to anybody else. + */ + private static void testAdminRights(Path work) throws Exception { + Server server = boot(work, "Ordinary"); + try { + Path admins = work.resolve("play-admins.txt"); + Files.write(admins, "Ordinary\n".getBytes("UTF-8")); + server.admins(new net.rustnotmc.server.admin.AdminRegistry(admins)); + + PlayClient client = new PlayClient("127.0.0.1", server.port(), 5000); + yes("the player signed in", client.signIn("Ordinary", "password1") != null); + Boolean state = client.awaitAdminState(3000L); + yes("the client was told", state != null); + if (state != null) { + yes("and that the player is an administrator", state.booleanValue()); + } + + // the right is taken away while the player is playing + Files.write(admins, "\n".getBytes("UTF-8")); + Thread.sleep(1200L); + Boolean after = null; + for (int i = 0; i < 20; i++) { + after = client.awaitAdminState(500L); + if (after != null && !after.booleanValue()) { + break; + } + } + yes("and the client hears that the right is gone", + after != null && !after.booleanValue()); + client.close(); + } finally { + server.stop(); + } + } + private static void waitFor(java.util.function.BooleanSupplier condition, long millis) throws InterruptedException { long end = System.currentTimeMillis() + millis;