|
|
|
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
|
|
|
|
|
import com.google.gson.GsonBuilder;
|
|
|
|
|
import com.google.gson.reflect.TypeToken;
|
|
|
|
|
import net.minecraft.network.chat.Component;
|
|
|
|
|
import net.minecraft.network.protocol.game.ClientboundSetActionBarTextPacket;
|
|
|
|
|
import net.minecraft.server.MinecraftServer;
|
|
|
|
|
import net.minecraft.server.level.ServerPlayer;
|
|
|
|
|
|
|
|
|
@@ -23,6 +24,7 @@ import java.time.Duration;
|
|
|
|
|
import java.time.Instant;
|
|
|
|
|
import java.util.Base64;
|
|
|
|
|
import java.util.HashMap;
|
|
|
|
|
import java.util.List;
|
|
|
|
|
import java.util.Locale;
|
|
|
|
|
import java.util.Map;
|
|
|
|
|
import java.util.UUID;
|
|
|
|
@@ -40,6 +42,7 @@ public final class AuthManager {
|
|
|
|
|
private final Map<UUID, Session> sessions = new ConcurrentHashMap<>();
|
|
|
|
|
private final Map<String, PremiumCache> premiumCache = new ConcurrentHashMap<>();
|
|
|
|
|
private AuthConfig config = new AuthConfig();
|
|
|
|
|
private RulesConfig rules = new RulesConfig();
|
|
|
|
|
|
|
|
|
|
public boolean load() {
|
|
|
|
|
try {
|
|
|
|
@@ -52,6 +55,14 @@ public final class AuthManager {
|
|
|
|
|
}
|
|
|
|
|
saveConfig(); // writes newly-added defaults/fields during upgrades
|
|
|
|
|
|
|
|
|
|
if (Files.exists(ConfigPaths.AUTH_RULES)) {
|
|
|
|
|
try (Reader r = Files.newBufferedReader(ConfigPaths.AUTH_RULES)) {
|
|
|
|
|
RulesConfig loadedRules = GSON.fromJson(r, RulesConfig.class);
|
|
|
|
|
if (loadedRules != null) rules = loadedRules;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
saveRules(); // creates rules.json on first run and writes newly-added defaults
|
|
|
|
|
|
|
|
|
|
if (Files.exists(ConfigPaths.AUTH_ACCOUNTS)) {
|
|
|
|
|
try (Reader r = Files.newBufferedReader(ConfigPaths.AUTH_ACCOUNTS)) {
|
|
|
|
|
Map<String, Account> loaded = GSON.fromJson(r, new TypeToken<Map<String, Account>>(){}.getType());
|
|
|
|
@@ -98,7 +109,7 @@ public final class AuthManager {
|
|
|
|
|
s.stage = Stage.RULES;
|
|
|
|
|
s.joinedAt = System.currentTimeMillis();
|
|
|
|
|
s.attempts = 0;
|
|
|
|
|
showDialog(player, DIALOG_RULES);
|
|
|
|
|
showRulesDialog(player);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -183,11 +194,10 @@ public final class AuthManager {
|
|
|
|
|
|
|
|
|
|
if (needsRules(account)) {
|
|
|
|
|
s.stage = Stage.RULES;
|
|
|
|
|
showDialog(player, DIALOG_RULES);
|
|
|
|
|
return;
|
|
|
|
|
} else {
|
|
|
|
|
prepareAfterRules(player);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
continueAfterRules(player);
|
|
|
|
|
scheduleInitialDialog(s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void onDisconnect(UUID uuid) { sessions.remove(uuid); }
|
|
|
|
@@ -210,10 +220,13 @@ public final class AuthManager {
|
|
|
|
|
public void tick(MinecraftServer server) {
|
|
|
|
|
if (!config.enabled) return;
|
|
|
|
|
long now = System.currentTimeMillis();
|
|
|
|
|
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
|
|
|
|
|
// Use a snapshot: disconnect() can synchronously remove a player from PlayerList.
|
|
|
|
|
// Iterating the live ArrayList while doing that caused the v1.8.1 server crash.
|
|
|
|
|
for (ServerPlayer player : List.copyOf(server.getPlayerList().getPlayers())) {
|
|
|
|
|
Session s = sessions.get(player.getUUID());
|
|
|
|
|
if (s == null || s.authenticated) continue;
|
|
|
|
|
if (now - s.joinedAt >= config.authenticationTimeoutSeconds * 1000L) {
|
|
|
|
|
long remainingMillis = config.authenticationTimeoutSeconds * 1000L - (now - s.joinedAt);
|
|
|
|
|
if (remainingMillis <= 0L) {
|
|
|
|
|
player.connection.disconnect(Component.literal("Authentication timed out. Please reconnect and try again."));
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
@@ -222,10 +235,24 @@ public final class AuthManager {
|
|
|
|
|
if (player.distanceToSqr(s.x, s.y, s.z) > 0.01) {
|
|
|
|
|
player.connection.teleport(s.x, s.y, s.z, s.yaw, s.pitch);
|
|
|
|
|
}
|
|
|
|
|
if (now - s.lastReminder >= config.reminderIntervalSeconds * 1000L) {
|
|
|
|
|
s.lastReminder = now;
|
|
|
|
|
|
|
|
|
|
// JOIN can fire just before the player becomes targetable by /dialog. Defer the
|
|
|
|
|
// first dialog slightly instead of spamming a failing command immediately.
|
|
|
|
|
if (s.dialogPending && now >= s.dialogReadyAt) {
|
|
|
|
|
s.dialogPending = false;
|
|
|
|
|
showCurrentDialog(player, s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A live countdown is shown in the action bar. Do not reopen the dialog every
|
|
|
|
|
// few seconds: reopening destroys text currently typed into password fields.
|
|
|
|
|
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
|
|
|
|
|
if (remainingSeconds != s.lastCountdownSecond) {
|
|
|
|
|
s.lastCountdownSecond = remainingSeconds;
|
|
|
|
|
player.connection.send(new ClientboundSetActionBarTextPacket(
|
|
|
|
|
Component.literal("Authentication time remaining: " + remainingSeconds + "s")
|
|
|
|
|
.withStyle(net.minecraft.ChatFormatting.YELLOW)
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -235,7 +262,7 @@ public final class AuthManager {
|
|
|
|
|
if (s == null || s.authenticated || s.stage != Stage.RULES) return 0;
|
|
|
|
|
if (!accepted) {
|
|
|
|
|
player.sendSystemMessage(Component.literal("§cYou must accept the server rules to continue."));
|
|
|
|
|
showDialog(player, DIALOG_RULES);
|
|
|
|
|
showRulesDialog(player);
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -244,7 +271,7 @@ public final class AuthManager {
|
|
|
|
|
Account a = accounts.computeIfAbsent(key(name), ignored -> Account.metadataOnly(s.premium));
|
|
|
|
|
a.accountType = s.premium ? "PREMIUM" : "LOCAL";
|
|
|
|
|
if (s.premium) a.officialUuid = player.getUUID().toString();
|
|
|
|
|
a.rulesVersionAccepted = config.rulesVersion;
|
|
|
|
|
a.rulesVersionAccepted = rules.version;
|
|
|
|
|
a.rulesAcceptedAt = Instant.now().toString();
|
|
|
|
|
saveAccounts();
|
|
|
|
|
}
|
|
|
|
@@ -316,20 +343,28 @@ public final class AuthManager {
|
|
|
|
|
private void continueAfterRules(ServerPlayer player) {
|
|
|
|
|
Session s = sessions.get(player.getUUID());
|
|
|
|
|
if (s == null) return;
|
|
|
|
|
if (prepareAfterRules(player)) showCurrentDialog(player, s);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Selects the post-rules stage. Returns true when a dialog is required. */
|
|
|
|
|
private boolean prepareAfterRules(ServerPlayer player) {
|
|
|
|
|
Session s = sessions.get(player.getUUID());
|
|
|
|
|
if (s == null) return false;
|
|
|
|
|
|
|
|
|
|
if (s.premium && !config.requireLoginEveryJoin) {
|
|
|
|
|
authenticate(player);
|
|
|
|
|
player.sendSystemMessage(Component.literal("§aLobby Systems: Premium account verified. Auto-login successful."));
|
|
|
|
|
return;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isRegistered(player.getName().getString())) {
|
|
|
|
|
s.stage = Stage.LOGIN;
|
|
|
|
|
showDialog(player, DIALOG_LOGIN);
|
|
|
|
|
} else {
|
|
|
|
|
s.stage = Stage.REGISTER;
|
|
|
|
|
showDialog(player, DIALOG_REGISTER);
|
|
|
|
|
}
|
|
|
|
|
s.stage = isRegistered(player.getName().getString()) ? Stage.LOGIN : Stage.REGISTER;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void scheduleInitialDialog(Session s) {
|
|
|
|
|
if (s == null || s.authenticated) return;
|
|
|
|
|
s.dialogPending = true;
|
|
|
|
|
s.dialogReadyAt = System.currentTimeMillis() + 1000L;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void authenticate(ServerPlayer player) {
|
|
|
|
@@ -342,7 +377,7 @@ public final class AuthManager {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private boolean needsRules(Account account) {
|
|
|
|
|
return config.rulesEnabled && (account == null || account.rulesVersionAccepted < config.rulesVersion);
|
|
|
|
|
return rules.enabled && (account == null || account.rulesVersionAccepted < rules.version);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void ensurePremiumMetadata(ServerPlayer player, Account existing) {
|
|
|
|
@@ -361,13 +396,78 @@ public final class AuthManager {
|
|
|
|
|
|
|
|
|
|
private void showCurrentDialog(ServerPlayer player, Session s) {
|
|
|
|
|
switch (s.stage) {
|
|
|
|
|
case RULES -> showDialog(player, DIALOG_RULES);
|
|
|
|
|
case RULES -> showRulesDialog(player);
|
|
|
|
|
case REGISTER -> showDialog(player, DIALOG_REGISTER);
|
|
|
|
|
case LOGIN -> showDialog(player, DIALOG_LOGIN);
|
|
|
|
|
default -> { }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void showRulesDialog(ServerPlayer player) {
|
|
|
|
|
try {
|
|
|
|
|
com.google.gson.JsonObject dialog = new com.google.gson.JsonObject();
|
|
|
|
|
dialog.addProperty("type", "minecraft:notice");
|
|
|
|
|
dialog.add("title", textComponent(rules.title, "yellow", true));
|
|
|
|
|
dialog.addProperty("can_close_with_escape", false);
|
|
|
|
|
dialog.addProperty("pause", false);
|
|
|
|
|
dialog.addProperty("after_action", "close");
|
|
|
|
|
|
|
|
|
|
com.google.gson.JsonArray body = new com.google.gson.JsonArray();
|
|
|
|
|
body.add(plainMessage(rules.introduction, "yellow"));
|
|
|
|
|
StringBuilder lines = new StringBuilder();
|
|
|
|
|
if (rules.rules != null) {
|
|
|
|
|
for (int i = 0; i < rules.rules.size(); i++) {
|
|
|
|
|
if (i > 0) lines.append("\n\n");
|
|
|
|
|
lines.append(i + 1).append(". ").append(rules.rules.get(i));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
body.add(plainMessage(lines.toString(), "white"));
|
|
|
|
|
body.add(plainMessage(rules.footer + "\n\nRules version: " + rules.version, "gray"));
|
|
|
|
|
dialog.add("body", body);
|
|
|
|
|
|
|
|
|
|
com.google.gson.JsonArray inputs = new com.google.gson.JsonArray();
|
|
|
|
|
com.google.gson.JsonObject agreement = new com.google.gson.JsonObject();
|
|
|
|
|
agreement.addProperty("type", "minecraft:boolean");
|
|
|
|
|
agreement.addProperty("key", "accepted");
|
|
|
|
|
agreement.add("label", textComponent(rules.agreementLabel, null, false));
|
|
|
|
|
agreement.addProperty("initial", false);
|
|
|
|
|
agreement.addProperty("on_true", "true");
|
|
|
|
|
agreement.addProperty("on_false", "false");
|
|
|
|
|
inputs.add(agreement);
|
|
|
|
|
dialog.add("inputs", inputs);
|
|
|
|
|
|
|
|
|
|
com.google.gson.JsonObject button = new com.google.gson.JsonObject();
|
|
|
|
|
button.add("label", textComponent(rules.acceptButton, "green", false));
|
|
|
|
|
com.google.gson.JsonObject action = new com.google.gson.JsonObject();
|
|
|
|
|
action.addProperty("type", "dynamic/run_command");
|
|
|
|
|
action.addProperty("template", "lobbyauth rules $(accepted)");
|
|
|
|
|
button.add("action", action);
|
|
|
|
|
dialog.add("action", button);
|
|
|
|
|
|
|
|
|
|
String command = "dialog show " + player.getGameProfile().name() + " " + GSON.toJson(dialog);
|
|
|
|
|
player.level().getServer().getCommands().getDispatcher().execute(command, player.level().getServer().createCommandSourceStack());
|
|
|
|
|
LobbySystemsMod.LOGGER.info("Displayed rules dialog v{} to {}", rules.version, player.getName().getString());
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
LobbySystemsMod.LOGGER.error("Failed to show rules dialog v{} to {}", rules.version, player.getName().getString(), e);
|
|
|
|
|
player.sendSystemMessage(Component.literal("§cServer rules UI could not be opened. Please reconnect or contact staff."));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static com.google.gson.JsonObject textComponent(String text, String color, boolean bold) {
|
|
|
|
|
com.google.gson.JsonObject o = new com.google.gson.JsonObject();
|
|
|
|
|
o.addProperty("text", text == null ? "" : text);
|
|
|
|
|
if (color != null) o.addProperty("color", color);
|
|
|
|
|
if (bold) o.addProperty("bold", true);
|
|
|
|
|
return o;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static com.google.gson.JsonObject plainMessage(String text, String color) {
|
|
|
|
|
com.google.gson.JsonObject o = new com.google.gson.JsonObject();
|
|
|
|
|
o.addProperty("type", "minecraft:plain_message");
|
|
|
|
|
o.add("contents", textComponent(text, color, false));
|
|
|
|
|
return o;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void showDialog(ServerPlayer player, String id) {
|
|
|
|
|
try {
|
|
|
|
|
String command = "dialog show " + player.getGameProfile().name() + " " + id;
|
|
|
|
@@ -448,6 +548,10 @@ public final class AuthManager {
|
|
|
|
|
try (Writer w = Files.newBufferedWriter(ConfigPaths.AUTH_CONFIG)) { GSON.toJson(config, w); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void saveRules() throws Exception {
|
|
|
|
|
try (Writer w = Files.newBufferedWriter(ConfigPaths.AUTH_RULES)) { GSON.toJson(rules, w); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void saveAccounts() {
|
|
|
|
|
try {
|
|
|
|
|
Files.createDirectories(ConfigPaths.AUTH_DIRECTORY);
|
|
|
|
@@ -493,7 +597,10 @@ public final class AuthManager {
|
|
|
|
|
final boolean premium;
|
|
|
|
|
int attempts;
|
|
|
|
|
long joinedAt;
|
|
|
|
|
long lastReminder;
|
|
|
|
|
long lastReminder; // retained for binary/source migration; no longer reopens dialogs
|
|
|
|
|
boolean dialogPending;
|
|
|
|
|
long dialogReadyAt;
|
|
|
|
|
long lastCountdownSecond = -1L;
|
|
|
|
|
final double x, y, z;
|
|
|
|
|
final float yaw, pitch;
|
|
|
|
|
Stage stage = Stage.RULES;
|
|
|
|
|