diff --git a/README.md b/README.md index 4aa2249..679bcd1 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Lobby Systems v1.6.1 — Fabric 26.2 +# Lobby Systems v1.7.0 — Fabric 26.2 Server-side lobby routing dan dimension policy untuk Minecraft 26.2/Fabric. v1.6.0 menambahkan Starter Kit per-dimension dengan active assignment dan recipient history, sambil mempertahankan initial Lobby placement pre-login, gamemode authoritative, gamerule per-dimension, portal, dan position persistence. @@ -463,3 +463,17 @@ Lobby Systems is **proprietary software — All Rights Reserved**. Public access - Managed `minecraft:nether_portal` surfaces are now explicitly intercepted so Lobby Systems exclusively owns their destination. - Reduced the creator-side post-create trigger suppression from the old 100-tick window to the configured portal check interval. - Added the Lobby Systems mod icon to Fabric metadata. + +## v1.7.0 - Lobby Systems Auth + +- Hybrid authentication while keeping `online-mode=true`. +- Premium usernames stay on vanilla/Mojang authentication and auto-login after successful verification. +- Non-premium usernames may join through a deterministic vanilla offline profile and must `/register` once, then `/login` on every connection. +- Premium usernames are protected: a cracked client using a premium name remains on vanilla authentication and is rejected when it cannot prove the official session. +- Registration/login guidance is shown both as title/subtitle and chat. +- Unauthenticated players are frozen in Lobby, cannot walk into Lobby Systems portals, and are disconnected after the configured timeout. +- Passwords are stored as salted PBKDF2-HMAC-SHA256 hashes; plaintext passwords are never persisted. +- New files: `config/lobby/auth/config.json` and `config/lobby/auth/accounts.json`. +- EasyAuth is intentionally marked incompatible while native Lobby Systems Auth is enabled. + +For the intended hybrid mode, keep `online-mode=true` in `server.properties` and remove EasyAuth before testing. diff --git a/gradle.properties b/gradle.properties index ce97e51..17d2f23 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,6 +5,6 @@ minecraft_version=26.2 loader_version=0.19.3 loom_version=1.17-SNAPSHOT fabric_api_version=0.156.0+26.2 -mod_version=1.6.1 +mod_version=1.7.0 maven_group=id.dartokloning archives_base_name=lobby-systems diff --git a/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java b/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java new file mode 100644 index 0000000..0e8ffe1 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/AuthConfig.java @@ -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}"; +} diff --git a/src/main/java/id/dartokloning/lobbysystems/AuthManager.java b/src/main/java/id/dartokloning/lobbysystems/AuthManager.java new file mode 100644 index 0000000..5feb0e5 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/AuthManager.java @@ -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 accounts = new HashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private final Map 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 loaded = GSON.fromJson(r, new TypeToken>(){}.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 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 ."); + 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 ."); + 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 " : "/register "; + 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;} } +} diff --git a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java index 409d7f5..fccbee5 100644 --- a/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java +++ b/src/main/java/id/dartokloning/lobbysystems/ConfigPaths.java @@ -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() {} } diff --git a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java index 118ecd0..18536d8 100644 --- a/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java +++ b/src/main/java/id/dartokloning/lobbysystems/LobbySystemsMod.java @@ -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 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") diff --git a/src/main/java/id/dartokloning/lobbysystems/mixin/ServerLoginPacketListenerImplMixin.java b/src/main/java/id/dartokloning/lobbysystems/mixin/ServerLoginPacketListenerImplMixin.java new file mode 100644 index 0000000..fa3e389 --- /dev/null +++ b/src/main/java/id/dartokloning/lobbysystems/mixin/ServerLoginPacketListenerImplMixin.java @@ -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()); + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index aee71d7..bed4e6c 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -3,7 +3,7 @@ "id": "dartokloning", "version": "${version}", "name": "Lobby Systems", - "description": "Server-side Fabric lobby routing, persistent world positions, portals, per-dimension gamemodes/gamerules, and starter kits with recipient history.", + "description": "Server-side Fabric lobby routing, hybrid premium/cracked authentication, persistent world positions, portals, per-dimension gamemodes/gamerules, and starter kits.", "authors": [ "DartoKloning" ], @@ -27,7 +27,8 @@ "lobby-systems.mixins.json" ], "breaks": { - "world-gamerules": "*" + "world-gamerules": "*", + "easyauth": "*" }, "icon": "assets/dartokloning/icon.png" } diff --git a/src/main/resources/lobby-systems.mixins.json b/src/main/resources/lobby-systems.mixins.json index 2df6411..a306873 100644 --- a/src/main/resources/lobby-systems.mixins.json +++ b/src/main/resources/lobby-systems.mixins.json @@ -5,7 +5,8 @@ "mixins": [ "ServerLevelGameRulesMixin", "PlayerListMixin", - "NetherPortalBlockMixin" + "NetherPortalBlockMixin", + "ServerLoginPacketListenerImplMixin" ], "injectors": { "defaultRequire": 1