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