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