Вход без пароля, права админа и 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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user