This commit is contained in:
Darto KLoning
2026-09-27 06:13:23 +07:00
parent 1de654d569
commit 48eb9f1228
9 changed files with 315 additions and 5 deletions
@@ -0,0 +1,13 @@
package id.dartokloning.lobbysystems;
public final class AuthConfig {
public boolean enabled = true;
public boolean premiumAutoLogin = true;
public boolean protectPremiumUsernames = true;
public int authenticationTimeoutSeconds = 120;
public int maxLoginAttempts = 3;
public int reminderIntervalSeconds = 8;
public int minimumPasswordLength = 6;
public int pbkdf2Iterations = 210000;
public String mojangProfileUrl = "https://api.mojang.com/users/profiles/minecraft/{username}";
}
@@ -0,0 +1,221 @@
package id.dartokloning.lobbysystems;
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.ClientboundSetSubtitleTextPacket;
import net.minecraft.network.protocol.game.ClientboundSetTitleTextPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import java.io.Reader;
import java.io.Writer;
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.nio.file.Files;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
public final class AuthManager {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final SecureRandom RANDOM = new SecureRandom();
private static final HttpClient HTTP = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(4)).build();
private final Map<String, Account> accounts = new HashMap<>();
private final Map<UUID, Session> sessions = new ConcurrentHashMap<>();
private final Map<String, PremiumCache> premiumCache = new ConcurrentHashMap<>();
private AuthConfig config = new AuthConfig();
public boolean load() {
try {
Files.createDirectories(ConfigPaths.AUTH_DIRECTORY);
if (Files.exists(ConfigPaths.AUTH_CONFIG)) {
try (Reader r = Files.newBufferedReader(ConfigPaths.AUTH_CONFIG)) {
AuthConfig c = GSON.fromJson(r, AuthConfig.class);
if (c != null) config = c;
}
} else saveConfig();
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());
if (loaded != null) { accounts.clear(); accounts.putAll(loaded); }
}
} else saveAccounts();
return true;
} catch (Exception e) {
LobbySystemsMod.LOGGER.error("Failed to load Lobby Systems Auth", e);
return false;
}
}
public AuthConfig config() { return config; }
/** Called from the login mixin. true = keep vanilla online authentication; false = local/offline path. */
public boolean shouldUseVanillaAuthentication(String username) {
if (!config.enabled || !config.premiumAutoLogin) return true;
PremiumResult result = premiumStatus(username);
// Fail closed: if Mojang cannot be checked, retain vanilla authentication rather than granting a local identity.
return result != PremiumResult.NOT_PREMIUM;
}
public PremiumResult premiumStatus(String username) {
String key = username.toLowerCase(Locale.ROOT);
PremiumCache cached = premiumCache.get(key);
long now = System.currentTimeMillis();
if (cached != null && now - cached.checkedAt < 300_000L) return cached.result;
try {
String url = config.mojangProfileUrl.replace("{username}", java.net.URLEncoder.encode(username, StandardCharsets.UTF_8));
HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(5)).GET().build();
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
PremiumResult result;
if (response.statusCode() == 200 && response.body() != null && response.body().contains("\"id\"")) result = PremiumResult.PREMIUM;
else if (response.statusCode() == 204 || response.statusCode() == 404) result = PremiumResult.NOT_PREMIUM;
else result = PremiumResult.UNKNOWN;
premiumCache.put(key, new PremiumCache(result, now));
return result;
} catch (Exception e) {
LobbySystemsMod.LOGGER.warn("Premium username lookup failed for {}: {}", username, e.toString());
premiumCache.put(key, new PremiumCache(PremiumResult.UNKNOWN, now));
return PremiumResult.UNKNOWN;
}
}
public void onJoin(ServerPlayer player) {
if (!config.enabled) return;
String name = player.getName().getString();
PremiumResult premium = premiumStatus(name);
if (premium == PremiumResult.PREMIUM) {
sessions.put(player.getUUID(), new Session(true, true, 0, System.currentTimeMillis(), player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot()));
player.sendSystemMessage(Component.literal("§aLobby Systems: Premium account verified. Auto-login successful."));
return;
}
Session s = new Session(false, false, 0, System.currentTimeMillis(), player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot());
sessions.put(player.getUUID(), s);
sendPrompt(player, true);
}
public void onDisconnect(UUID uuid) { sessions.remove(uuid); }
public boolean isAuthenticated(ServerPlayer player) { Session s = sessions.get(player.getUUID()); return !config.enabled || (s != null && s.authenticated); }
public boolean isPremium(ServerPlayer player) { Session s = sessions.get(player.getUUID()); return s != null && s.premium; }
public boolean isRegistered(String username) { synchronized (accounts) { return accounts.containsKey(username.toLowerCase(Locale.ROOT)); } }
public void tick(MinecraftServer server) {
if (!config.enabled) return;
long now = System.currentTimeMillis();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
Session s = sessions.get(player.getUUID());
if (s == null || s.authenticated) continue;
if (now - s.joinedAt >= config.authenticationTimeoutSeconds * 1000L) {
player.connection.disconnect(Component.literal("Authentication timed out. Please reconnect and try again."));
continue;
}
// Server-authoritative freeze. This also prevents walking into Lobby portals before authentication.
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;
sendPrompt(player, false);
}
}
}
public int register(ServerPlayer player, String password, String confirm) {
if (!config.enabled) return fail(player, "§cAuthentication is disabled.");
Session s = sessions.get(player.getUUID());
if (s != null && s.premium) return fail(player, "§ePremium accounts do not need local registration.");
if (isAuthenticated(player)) return fail(player, "§eYou are already authenticated.");
String name = player.getName().getString();
PremiumResult premium = premiumStatus(name);
if (premium != PremiumResult.NOT_PREMIUM) return fail(player, premium == PremiumResult.PREMIUM ? "§cThis username belongs to a premium Minecraft account." : "§cPremium username verification is temporarily unavailable. Try again later.");
if (!password.equals(confirm)) return fail(player, "§cPasswords do not match.");
if (password.length() < config.minimumPasswordLength) return fail(player, "§cPassword must be at least " + config.minimumPasswordLength + " characters.");
synchronized (accounts) {
String key = name.toLowerCase(Locale.ROOT);
if (accounts.containsKey(key)) return fail(player, "§cThis local account is already registered. Use /login <password>.");
accounts.put(key, hash(password));
saveAccounts();
}
authenticate(player, false);
player.sendSystemMessage(Component.literal("§aRegistration successful. You are now logged in."));
return 1;
}
public int login(ServerPlayer player, String password) {
Session s = sessions.get(player.getUUID());
if (s != null && s.premium) return fail(player, "§ePremium account is already authenticated.");
if (isAuthenticated(player)) return fail(player, "§eYou are already authenticated.");
Account account;
synchronized (accounts) { account = accounts.get(player.getName().getString().toLowerCase(Locale.ROOT)); }
if (account == null) return fail(player, "§cAccount not registered. Use /register <password> <password>.");
if (!verify(password, account)) {
if (s != null && ++s.attempts >= config.maxLoginAttempts) {
player.connection.disconnect(Component.literal("Too many failed login attempts."));
return 0;
}
return fail(player, "§cIncorrect password.");
}
authenticate(player, false);
player.sendSystemMessage(Component.literal("§aLogin successful. Welcome back!"));
return 1;
}
private void authenticate(ServerPlayer player, boolean premium) {
Session old = sessions.get(player.getUUID());
sessions.put(player.getUUID(), new Session(true, premium, 0, old == null ? System.currentTimeMillis() : old.joinedAt,
player.getX(), player.getY(), player.getZ(), player.getYRot(), player.getXRot()));
player.connection.send(new ClientboundSetTitleTextPacket(Component.literal("§aAUTHENTICATED")));
player.connection.send(new ClientboundSetSubtitleTextPacket(Component.literal("§fWelcome, " + player.getName().getString() + "!")));
}
private void sendPrompt(ServerPlayer player, boolean chat) {
boolean registered = isRegistered(player.getName().getString());
String title = registered ? "§eLOGIN REQUIRED" : "§bREGISTER REQUIRED";
String command = registered ? "/login <password>" : "/register <password> <password>";
player.connection.send(new ClientboundSetTitleTextPacket(Component.literal(title)));
player.connection.send(new ClientboundSetSubtitleTextPacket(Component.literal("§f" + command)));
if (chat) player.sendSystemMessage(Component.literal("§6[Lobby Systems Auth] §f" + (registered ? "Please login: §e" : "Please register: §b") + command));
}
private static int fail(ServerPlayer p, String msg) { p.sendSystemMessage(Component.literal(msg)); return 0; }
private Account hash(String password) {
try {
byte[] salt = new byte[16]; RANDOM.nextBytes(salt);
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, config.pbkdf2Iterations, 256);
byte[] digest = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
spec.clearPassword();
return new Account(Base64.getEncoder().encodeToString(salt), Base64.getEncoder().encodeToString(digest), config.pbkdf2Iterations);
} catch (Exception e) { throw new IllegalStateException("Unable to hash password", e); }
}
private boolean verify(String password, Account a) {
try {
byte[] salt = Base64.getDecoder().decode(a.salt);
PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, a.iterations, 256);
byte[] actual = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
spec.clearPassword();
return MessageDigest.isEqual(actual, Base64.getDecoder().decode(a.hash));
} catch (Exception e) { return false; }
}
private void saveConfig() throws Exception { try (Writer w = Files.newBufferedWriter(ConfigPaths.AUTH_CONFIG)) { GSON.toJson(config, w); } }
private void saveAccounts() { try { Files.createDirectories(ConfigPaths.AUTH_DIRECTORY); try (Writer w = Files.newBufferedWriter(ConfigPaths.AUTH_ACCOUNTS)) { GSON.toJson(accounts, w); } } catch (Exception e) { LobbySystemsMod.LOGGER.error("Failed saving auth accounts", e); } }
public enum PremiumResult { PREMIUM, NOT_PREMIUM, UNKNOWN }
private record PremiumCache(PremiumResult result, long checkedAt) {}
private static final class Account { String salt; String hash; int iterations; Account(String salt,String hash,int iterations){this.salt=salt;this.hash=hash;this.iterations=iterations;} }
private static final class Session { boolean authenticated; boolean premium; int attempts; long joinedAt; long lastReminder; double x,y,z; float yaw,pitch; Session(boolean a,boolean p,int at,long j,double x,double y,double z,float yaw,float pitch){authenticated=a;premium=p;attempts=at;joinedAt=j;lastReminder=0;this.x=x;this.y=y;this.z=z;this.yaw=yaw;this.pitch=pitch;} }
}
@@ -13,6 +13,9 @@ public final class ConfigPaths {
public static final Path DIMENSION_GAMERULES = DIRECTORY.resolve("dimension-gamerules.json");
public static final Path STARTER_KITS = DIRECTORY.resolve("starterkits.json");
public static final Path STARTER_KIT_RECIPIENTS = DIRECTORY.resolve("starterkit-recipients.json");
public static final Path AUTH_DIRECTORY = DIRECTORY.resolve("auth");
public static final Path AUTH_CONFIG = AUTH_DIRECTORY.resolve("config.json");
public static final Path AUTH_ACCOUNTS = AUTH_DIRECTORY.resolve("accounts.json");
private ConfigPaths() {}
}
@@ -47,12 +47,21 @@ public final class LobbySystemsMod implements ModInitializer {
private static final DimensionGameModeManager DIMENSION_GAMEMODES = new DimensionGameModeManager();
private static final DimensionGameRuleManager DIMENSION_GAMERULES = new DimensionGameRuleManager();
private static final StarterKitManager STARTER_KITS = new StarterKitManager();
private static final AuthManager AUTH = new AuthManager();
private static int saveTicker;
/**
* Used by the NetherPortalBlock mixin to distinguish Lobby Systems visual
* portals from ordinary vanilla Nether portals.
*/
public static boolean shouldUseVanillaAuthentication(String username) {
return AUTH.shouldUseVanillaAuthentication(username);
}
public static boolean isAuthenticated(ServerPlayer player) {
return AUTH.isAuthenticated(player);
}
public static boolean isManagedPortalBlock(Level level, BlockPos pos) {
if (level == null || pos == null) return false;
return PORTALS.isManagedVisualBlock(
@@ -73,6 +82,15 @@ public final class LobbySystemsMod implements ModInitializer {
"Lobby dimension: " + CONFIG.get().lobbyDimension
);
boolean authLoaded = AUTH.load();
LobbyLog.state(
authLoaded ? "AUTH SYSTEM READY" : "AUTH SYSTEM LOAD FAILED",
"Config: " + ConfigPaths.AUTH_CONFIG,
"Accounts: " + ConfigPaths.AUTH_ACCOUNTS,
"Enabled: " + AUTH.config().enabled,
"Hybrid online-mode support: enabled"
);
POSITIONS.load();
POSITIONS.setExcludedDimension(lobbyKey());
LobbyLog.state(
@@ -163,6 +181,7 @@ public final class LobbySystemsMod implements ModInitializer {
DIMENSION_GAMEMODES.beginJoinEnforcement(player.getUUID());
SESSIONS.onJoin(player.getUUID(), lobbyKey());
STARTER_KITS.onJoin(player);
AUTH.onJoin(player);
LobbyLog.state(
"PLAYER JOIN",
@@ -181,6 +200,7 @@ public final class LobbySystemsMod implements ModInitializer {
PORTALS.onDisconnect(player.getUUID());
DIMENSION_GAMEMODES.onDisconnect(player.getUUID());
STARTER_KITS.onDisconnect(player.getUUID());
AUTH.onDisconnect(player.getUUID());
LobbyLog.state(
"PLAYER DISCONNECT",
"Player: " + player.getName().getString(),
@@ -194,6 +214,7 @@ public final class LobbySystemsMod implements ModInitializer {
DIMENSION_GAMEMODES.tick(server);
STARTER_KITS.tick(server);
AUTH.tick(server);
ResourceKey<Level> lobby = lobbyKey();
for (ServerPlayer player : server.getPlayerList().getPlayers()) {
@@ -216,6 +237,20 @@ public final class LobbySystemsMod implements ModInitializer {
dispatcher.register(Commands.literal("lobby").executes(ctx -> handleLobbyCommand(
ctx.getSource().getPlayerOrException(), ctx.getSource().getServer())));
dispatcher.register(Commands.literal("register")
.then(Commands.argument("password", StringArgumentType.word())
.then(Commands.argument("confirm", StringArgumentType.word())
.executes(ctx -> AUTH.register(
ctx.getSource().getPlayerOrException(),
StringArgumentType.getString(ctx, "password"),
StringArgumentType.getString(ctx, "confirm"))))));
dispatcher.register(Commands.literal("login")
.then(Commands.argument("password", StringArgumentType.word())
.executes(ctx -> AUTH.login(
ctx.getSource().getPlayerOrException(),
StringArgumentType.getString(ctx, "password")))));
dispatcher.register(Commands.literal("ls")
.requires(Commands.hasPermission(Commands.LEVEL_ADMINS))
.then(Commands.literal("reload")
@@ -0,0 +1,22 @@
package id.dartokloning.lobbysystems.mixin;
import id.dartokloning.lobbysystems.LobbySystemsMod;
import net.minecraft.network.protocol.login.ServerboundHelloPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.network.ServerLoginPacketListenerImpl;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Redirect;
/** Hybrid authentication: preserve vanilla auth for premium names; permit offline profile only for non-premium names. */
@Mixin(ServerLoginPacketListenerImpl.class)
public abstract class ServerLoginPacketListenerImplMixin {
@Redirect(
method = "handleHello(Lnet/minecraft/network/protocol/login/ServerboundHelloPacket;)V",
at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;usesAuthentication()Z")
)
private boolean lobbySystems$hybridAuthentication(MinecraftServer server, ServerboundHelloPacket packet) {
if (!server.usesAuthentication()) return false;
return LobbySystemsMod.shouldUseVanillaAuthentication(packet.name());
}
}