add auth
This commit is contained in:
@@ -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")
|
||||
|
||||
+22
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user