Вход без пароля, права админа и 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:
sasheg dev
2026-10-22 08:50:57 +03:00
parent cf90f71e58
commit d2a0a7a93b
21 changed files with 1478 additions and 543 deletions
+46 -1
View File
@@ -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`. Это **исходники клиента
+25
View File
@@ -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();
}
}
+9 -8
View File
@@ -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;