Вход без пароля, права админа и 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
+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) {