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