Вход без пароля, права админа и F3+B
Аутентификация переделана по схеме пользователя: лаунчер — единственный
источник токена, сервер проверяет его сам и лаунчера не спрашивает.
- 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, форк собирается чисто.
This commit is contained in:
@@ -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 <аккаунт>` выдаёт свой токен и
|
||||
пишет его в `<world>/dev-token.txt`; клиент берёт токен оттуда.
|
||||
|
||||
### 2.12 Права админа и F3+B (сделано)
|
||||
|
||||
- **Список — свой, у сервера**: `<world>/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`. Это **исходники клиента
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<String, String> 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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> knownPlayers = new java.util.ArrayList<String>();
|
||||
|
||||
/** 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) {
|
||||
|
||||
@@ -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 <b>itself</b>: the signature
|
||||
* is made with a key both sides know, so no one has to ask anybody.
|
||||
*
|
||||
* The token is two parts:
|
||||
*
|
||||
* <pre>
|
||||
* base64url(payload) "." base64url(HMAC-SHA256(payload, key))
|
||||
* </pre>
|
||||
*
|
||||
* 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:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>session</b> - the token of a running session. It lives an hour.
|
||||
* <li><b>reload</b> - 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.
|
||||
* </ul>
|
||||
*/
|
||||
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<String, String> 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<String, String> 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<String, String> readFields(String json) {
|
||||
if (json == null || !json.startsWith("{") || !json.endsWith("}")) {
|
||||
return null;
|
||||
}
|
||||
Map<String, String> out = new LinkedHashMap<String, String>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -5,13 +5,14 @@
|
||||
# Stage first if run\classpath.txt is missing:
|
||||
# python tools\stage_run.py <run> ..\..\game-fork\build\classes <meta>
|
||||
|
||||
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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> 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;
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 <b>itself</b>: 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String> admins = new HashSet<String>();
|
||||
|
||||
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<String> 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user