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