diff --git a/src/main/java/cn/alini/trueuuid/config/TrueuuidConfig.java b/src/main/java/cn/alini/trueuuid/config/TrueuuidConfig.java index 57658db..a2b7ae4 100644 --- a/src/main/java/cn/alini/trueuuid/config/TrueuuidConfig.java +++ b/src/main/java/cn/alini/trueuuid/config/TrueuuidConfig.java @@ -71,39 +71,42 @@ public static final class Common { b.push("auth"); timeoutMs = b.defineInRange("timeoutMs", 30_000L, 1_000L, 600_000L); - allowOfflineOnTimeout = b.comment("false: 超时踢出 / Kick on timeout. true: 超时放行为离线 / Allow offline fallback on timeout.").define("allowOfflineOnTimeout", false); - allowOfflineOnFailure = b.comment("false: 失败时踢出 / Kick on failure. true: 鉴权失败放行为离线 / Allow offline fallback on authentication failure.").define("allowOfflineOnFailure", true); + allowOfflineOnTimeout = b.comment("false: kick on timeout. true: allow offline fallback on timeout. / false: 超时踢出。true: 超时允许离线兜底。").define("allowOfflineOnTimeout", false); + allowOfflineOnFailure = b.comment("false: kick on authentication failure. true: allow offline fallback on authentication failure. / false: 鉴权失败时踢出。true: 鉴权失败时允许离线兜底。").define("allowOfflineOnFailure", true); - timeoutKickMessage = b.comment("Kick message on authentication timeout. Use a trueuuid.* translation key for client-side localization, or enter plain text to force a custom server message.") + timeoutKickMessage = b.comment("Kick message on authentication timeout. Use a trueuuid.* translation key for client-side localization, or enter plain text to force a custom server message. / 鉴权超时踢出消息。使用 trueuuid.* 翻译键可按客户端语言显示,也可填写纯文本强制使用服务器消息。") .define("timeoutKickMessage", "trueuuid.disconnect.timeout"); - offlineFallbackMessage = b.define( + offlineFallbackMessage = b.comment("Offline fallback chat message. Keep the default trueuuid.* key to use each player's game language. / 离线兜底聊天提示。保留默认 trueuuid.* 翻译键可按每个玩家的游戏语言显示。") + .define( "offlineFallbackMessage", "trueuuid.chat.offline_fallback" ); - // 默认短、不占屏 (Default short, does not occupy screen) - offlineShortSubtitle = b.define("offlineShortSubtitle", "trueuuid.subtitle.offline"); - onlineShortSubtitle = b.define("onlineShortSubtitle", "trueuuid.subtitle.online"); - showJoinFeedback = b.comment("是否在玩家进服后显示登录状态提示。/ Show join feedback after a player joins. 关闭后不再发送正版/皮肤站/离线/单人模式 Title,也不发送离线兜底聊天提示;不影响皮肤刷新和鉴权逻辑。/ When disabled, no premium/skin-site/offline/single-player Title or offline fallback chat message is sent; authentication and skin refresh are unchanged.") + // Default subtitles are short and localized by the client language. + offlineShortSubtitle = b.comment("Offline fallback subtitle. Keep the default trueuuid.* key to use each player's game language. / 离线兜底副标题。保留默认 trueuuid.* 翻译键可按每个玩家的游戏语言显示。") + .define("offlineShortSubtitle", "trueuuid.subtitle.offline"); + onlineShortSubtitle = b.comment("Premium login subtitle. Keep the default trueuuid.* key to use each player's game language. / 正版登录副标题。保留默认 trueuuid.* 翻译键可按每个玩家的游戏语言显示。") + .define("onlineShortSubtitle", "trueuuid.subtitle.online"); + showJoinFeedback = b.comment("Show join feedback after a player joins. When disabled, no premium/skin-site/offline/single-player title or offline fallback chat message is sent; authentication and skin refresh are unchanged. / 玩家进服后显示登录状态提示。关闭后不再发送正版、皮肤站、离线、单人模式标题,也不发送离线兜底聊天提示;不影响鉴权和皮肤刷新。") .define("showJoinFeedback", true); // 策略项 (Strategy items) - knownPremiumDenyOffline = b.comment("一旦该名字成功验证过正版/皮肤站,后续鉴权失败时禁止以离线身份进入。/ Once a name has been verified as premium/skin-site, deny later offline fallback for that name.") + knownPremiumDenyOffline = b.comment("Once a name has been verified as premium/skin-site, deny later offline fallback for that name. / 一旦该名字成功验证过正版或皮肤站,后续鉴权失败时禁止以离线身份进入。") .define("knownPremiumDenyOffline", true); - allowOfflineForUnknownOnly = b.comment("仅对从未验证为正版/皮肤站的新名字允许离线兜底。/ Only allow offline fallback for names that have never been verified as premium/skin-site.") + allowOfflineForUnknownOnly = b.comment("Only allow offline fallback for names that have never been verified as premium/skin-site. / 仅对从未验证为正版或皮肤站的新名字允许离线兜底。") .define("allowOfflineForUnknownOnly", true); - recentIpGraceEnabled = b.comment("启用“退出后同 IP 短时重连”容错,只在玩家退出后的 TTL 秒内临时沿用上次认证来源。/ Enable short same-IP reconnect grace after logout, reusing the last verified identity only within the TTL window.") + recentIpGraceEnabled = b.comment("Enable short same-IP reconnect grace after logout, reusing the last verified identity only within the TTL window. / 启用退出后同 IP 短时重连容错,只在 TTL 窗口内临时沿用上次认证身份。") .define("recentIpGrace.enabled", true); - recentIpGraceTtlSeconds = b.comment("退出游戏后允许同 IP 容错重连的秒数。默认 10 秒,避免长期误导为皮肤站/正版登录。/ Same-IP grace seconds after logout. Default is 10 seconds to avoid long-lived misleading premium/skin-site identity.") + recentIpGraceTtlSeconds = b.comment("Same-IP grace seconds after logout. Default is 10 seconds to avoid long-lived misleading premium/skin-site identity. / 退出游戏后允许同 IP 容错重连的秒数。默认 10 秒,避免长期误导为正版或皮肤站登录。") .defineInRange("recentIpGrace.ttlSeconds", 10, 1, 60); - debug = b.comment("启用调试日志输出。/ Enable debug logging.").define("debug", false); + debug = b.comment("Enable debug logging. / 启用调试日志输出。").define("debug", false); apiRootWhitelist = b.comment( - "authlib-injector 皮肤站域名白名单。/ Whitelist for authlib-injector skin-site domains.", - "留空(默认)表示信任客户端上报的任何皮肤站 URL。/ Empty by default: trust any skin-site URL reported by the client.", - "配置后,只有 URL 中包含白名单条目的皮肤站才会被接受。/ When configured, only URLs containing a whitelist entry are accepted.", - "例如 / Example: [\"littleskin.cn\", \"skin.example.com\"]" - ).defineListAllowEmpty(List.of("yggdrasil.apiRootWhitelist"), List::of, o -> o instanceof String); + "Whitelist for authlib-injector skin-site domains. / authlib-injector 皮肤站域名白名单。", + "Empty by default: trust any skin-site URL reported by the client. / 留空表示信任客户端上报的任何皮肤站 URL。", + "When configured, only skin-site hosts matching a whitelist entry are accepted. / 配置后,只有主机名匹配白名单条目的皮肤站才会被接受。", + "Example: [\"littleskin.cn\", \"skin.example.com\"] / 示例: [\"littleskin.cn\", \"skin.example.com\"]" + ).defineListAllowEmpty(List.of("yggdrasil", "apiRootWhitelist"), List::of, o -> o instanceof String); b.pop(); } diff --git a/src/main/java/cn/alini/trueuuid/mixin/client/ClientHandshakeMixin.java b/src/main/java/cn/alini/trueuuid/mixin/client/ClientHandshakeMixin.java index 6bbcbf6..988ad43 100644 --- a/src/main/java/cn/alini/trueuuid/mixin/client/ClientHandshakeMixin.java +++ b/src/main/java/cn/alini/trueuuid/mixin/client/ClientHandshakeMixin.java @@ -61,6 +61,12 @@ public abstract class ClientHandshakeMixin { Connection loginConnection = this.connection; int transactionId = packet.getTransactionId(); + if (offlineUpgradeAvailable && NetIds.MIGRATION_CONFIRM_SERVER_ID.equals(serverId)) { + trueuuid$confirmOfflinePlayerDataUpgrade(mc, offlineUuid, offlineDataSummary, "", loginConnection, transactionId); + ci.cancel(); + return; + } + // dev/离线启动常见的占位 token 不可能通过 Mojang 校验,立即回失败,避免登录线程等到服务器超时。 if (trueuuid$isMissingSessionToken(token)) { trueuuid$sendAuthAck(loginConnection, transactionId, false, "", false, true); diff --git a/src/main/java/cn/alini/trueuuid/mixin/server/ServerLoginMixin.java b/src/main/java/cn/alini/trueuuid/mixin/server/ServerLoginMixin.java index 04e0822..82b5806 100644 --- a/src/main/java/cn/alini/trueuuid/mixin/server/ServerLoginMixin.java +++ b/src/main/java/cn/alini/trueuuid/mixin/server/ServerLoginMixin.java @@ -24,6 +24,7 @@ import java.net.InetSocketAddress; import java.net.URI; +import java.util.List; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; @@ -41,6 +42,11 @@ public abstract class ServerLoginMixin { @Unique private String trueuuid$nonce = null; @Unique private long trueuuid$sentAt = 0L; @Unique private boolean trueuuid$offlineUpgradeOffered = false; + @Unique private boolean trueuuid$migrationConfirmation = false; + @Unique private GameProfile trueuuid$pendingVerifiedProfile = null; + @Unique private AuthState.AuthSource trueuuid$pendingAuthSource = null; + @Unique private String trueuuid$pendingAuthDisplayName = null; + @Unique private String trueuuid$pendingIp = null; @Unique private static final java.util.concurrent.ConcurrentHashMap TRUEUUID$MIGRATION_PENDING = new java.util.concurrent.ConcurrentHashMap<>(); @@ -63,24 +69,17 @@ public abstract class ServerLoginMixin { System.out.println("[TrueUUID] 握手 nonce: " + this.trueuuid$nonce + ", txId: " + this.trueuuid$txId); } - PlayerDataMigration.OfflineData offlineData = null; - if (this.gameProfile != null && !TrueuuidRuntime.NAME_REGISTRY.isKnownPremiumName(this.gameProfile.getName())) { - offlineData = PlayerDataMigration.findOfflineData(this.server, this.gameProfile.getName()); - } - if (offlineData != null && this.gameProfile != null && trueuuid$isMigrationPending(this.gameProfile.getName())) { + if (this.gameProfile != null && trueuuid$isMigrationPending(this.gameProfile.getName())) { sendDisconnectWithReason(Component.translatable("trueuuid.disconnect.migration_pending")); reset(); return; } - this.trueuuid$offlineUpgradeOffered = offlineData != null; + this.trueuuid$offlineUpgradeOffered = false; + this.trueuuid$migrationConfirmation = false; FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer()); buf.writeUtf(this.trueuuid$nonce); - buf.writeBoolean(this.trueuuid$offlineUpgradeOffered); - if (this.trueuuid$offlineUpgradeOffered) { - buf.writeUtf(offlineData.offlineUuid().toString()); - buf.writeUtf(offlineData.summary()); - } + buf.writeBoolean(false); this.connection.send(new ClientboundCustomQueryPacket(this.trueuuid$txId, NetIds.AUTH, buf)); } @@ -186,6 +185,38 @@ public abstract class ServerLoginMixin { if (TrueuuidConfig.debug()) { System.out.println("[TrueUUID] 客户端认证包ackOk: " + ackOk + ", hasJoinedUrl: " + (clientHasJoinedUrl.isEmpty() ? "(mojang default)" : clientHasJoinedUrl)); } + if (this.trueuuid$migrationConfirmation) { + if (this.trueuuid$ackHandled) { + if (TrueuuidConfig.debug()) { + System.out.println("[TrueUUID] 重复迁移确认包忽略, txId: " + this.trueuuid$txId); + } + ci.cancel(); + return; + } + this.trueuuid$ackHandled = true; + try { + if (!ackOk || !finalMigrationConfirmed || this.trueuuid$pendingVerifiedProfile == null || this.trueuuid$pendingAuthSource == null) { + trueuuid$sendDuplicateUuidDisconnect(); + return; + } + + trueuuid$markMigrationPending(this.trueuuid$pendingVerifiedProfile.getName()); + trueuuid$completeVerifiedLogin( + this.trueuuid$pendingIp != null ? this.trueuuid$pendingIp : ip, + this.trueuuid$pendingVerifiedProfile, + this.trueuuid$pendingAuthSource, + this.trueuuid$pendingAuthDisplayName, + true + ); + } finally { + if (this.trueuuid$pendingVerifiedProfile != null) { + trueuuid$clearMigrationPending(this.trueuuid$pendingVerifiedProfile.getName()); + } + reset(); + } + ci.cancel(); + return; + } if (!ackOk) { if (TrueuuidConfig.debug()) { System.out.println("[TrueUUID] 认证失败, 玩家: " + (this.gameProfile != null ? this.gameProfile.getName() : "") + ", ip: " + ip + ", 原因: 客户端拒绝"); @@ -198,18 +229,12 @@ public abstract class ServerLoginMixin { if (!clientHasJoinedUrl.isEmpty()) { var whitelist = TrueuuidConfig.apiRootWhitelist(); if (!whitelist.isEmpty()) { - boolean allowed = false; - for (String entry : whitelist) { - if (clientHasJoinedUrl.contains(entry)) { - allowed = true; - break; - } - } + boolean allowed = trueuuid$isWhitelistedAuthEndpoint(clientHasJoinedUrl, whitelist); if (!allowed) { if (TrueuuidConfig.debug()) { System.out.println("[TrueUUID] 客户端上报的 hasJoined URL 不在白名单中: " + clientHasJoinedUrl); } - handleAuthFailure(ip, "不受信任的认证服务器", false); + handleAuthFailure(ip, "不受信任的认证服务器", false); reset(); ci.cancel(); return; } } @@ -280,16 +305,9 @@ public abstract class ServerLoginMixin { propMap.put(p.name(), new Property(p.name(), p.value())); } } - this.gameProfile = newProfile; - AuthState.markAuthSuccess(this.connection, res.uuid(), res.name(), source, displayName); - if (!trueuuid$handleOfflineUpgradeIfNeeded(res.name(), res.uuid(), source, displayName, finalMigrationConfirmed)) { + if (!trueuuid$completeVerifiedLogin(ip, newProfile, source, displayName, finalMigrationConfirmed)) { return; } - TrueuuidRuntime.NAME_REGISTRY.recordSuccess(res.name(), res.uuid(), ip, source, displayName); - TrueuuidRuntime.IP_GRACE.record(res.name(), ip, res.uuid(), source, displayName); - if (TrueuuidConfig.debug()) { - System.out.println("[TrueUUID] 记录认证成功来源: " + source + ", displayName=" + displayName); - } // 认证成功后只替换档案并释放暂停,后续由 Forge 原版登录 tick 继续完成协商和放入世界。 } catch (Throwable t) { if (TrueuuidConfig.debug()) { @@ -297,10 +315,12 @@ public abstract class ServerLoginMixin { } handleAuthFailure(ip, "服务器异常", false); } finally { - if (this.gameProfile != null) { - trueuuid$clearMigrationPending(this.gameProfile.getName()); + if (!this.trueuuid$migrationConfirmation) { + if (this.gameProfile != null) { + trueuuid$clearMigrationPending(this.gameProfile.getName()); + } + reset(); } - reset(); } }); }); @@ -424,46 +444,152 @@ private void sendDisconnectWithReason(Component reason) { } @Unique - private boolean trueuuid$handleOfflineUpgradeIfNeeded(String name, UUID verifiedUuid, AuthState.AuthSource source, String displayName, boolean confirmed) { - if (!PlayerDataMigration.needsOfflineUpgrade(this.server, name, verifiedUuid)) { - return true; + private boolean trueuuid$completeVerifiedLogin(String ip, GameProfile verifiedProfile, AuthState.AuthSource source, String displayName, boolean migrationConfirmed) { + if (verifiedProfile == null || verifiedProfile.getId() == null || verifiedProfile.getName() == null || verifiedProfile.getName().isBlank()) { + sendDisconnectWithReason(Component.translatable("trueuuid.disconnect.auth_denied")); + return false; } + + String name = verifiedProfile.getName(); + UUID verifiedUuid = verifiedProfile.getId(); PlayerDataMigration.OfflineData data = PlayerDataMigration.findOfflineData(this.server, name); - if (data == null) { - return true; + if (data != null && !data.offlineUuid().equals(verifiedUuid)) { + if (!migrationConfirmed) { + trueuuid$requestOfflineUpgradeConfirmation(data, verifiedProfile, source, displayName, ip); + return false; + } + try { + PlayerDataMigration.migrateOfflineToVerified(this.server, name, verifiedUuid); + if (TrueuuidConfig.debug()) { + System.out.println("[TrueUUID] migrated offline data before login: player=" + name + + ", offlineUuid=" + data.offlineUuid() + ", verifiedUuid=" + verifiedUuid); + } + } catch (Exception ex) { + System.out.println("[TrueUUID] 离线玩家数据继承失败, player=" + name + ", offlineUuid=" + data.offlineUuid() + + ", verifiedUuid=" + verifiedUuid + ", error=" + ex); + sendDisconnectWithReason(Component.translatable( + "trueuuid.disconnect.migration_failed", + name, + data.offlineUuid(), + verifiedUuid, + ex.getMessage() + )); + return false; + } + } + + this.gameProfile = verifiedProfile; + AuthState.markAuthSuccess(this.connection, verifiedUuid, name, source, displayName); + TrueuuidRuntime.NAME_REGISTRY.recordSuccess(name, verifiedUuid, ip, source, displayName); + TrueuuidRuntime.IP_GRACE.record(name, ip, verifiedUuid, source, displayName); + if (TrueuuidConfig.debug()) { + System.out.println("[TrueUUID] 记录认证成功来源: " + source + ", displayName=" + displayName); + } + return true; + } + + @Unique + private void trueuuid$requestOfflineUpgradeConfirmation(PlayerDataMigration.OfflineData data, GameProfile verifiedProfile, AuthState.AuthSource source, String displayName, String ip) { + this.trueuuid$pendingVerifiedProfile = verifiedProfile; + this.trueuuid$pendingAuthSource = source; + this.trueuuid$pendingAuthDisplayName = displayName; + this.trueuuid$pendingIp = ip; + this.trueuuid$migrationConfirmation = true; + this.trueuuid$offlineUpgradeOffered = true; + this.trueuuid$ackHandled = false; + this.trueuuid$txId = TRUEUUID$NEXT_TX_ID.getAndIncrement(); + this.trueuuid$nonce = NetIds.MIGRATION_CONFIRM_SERVER_ID; + this.trueuuid$sentAt = System.currentTimeMillis(); + + FriendlyByteBuf buf = new FriendlyByteBuf(Unpooled.buffer()); + buf.writeUtf(NetIds.MIGRATION_CONFIRM_SERVER_ID); + buf.writeBoolean(true); + buf.writeUtf(data.offlineUuid().toString()); + buf.writeUtf(data.summary()); + this.connection.send(new ClientboundCustomQueryPacket(this.trueuuid$txId, NetIds.AUTH, buf)); + } + + @Unique + private void trueuuid$sendDuplicateUuidDisconnect() { + if (this.trueuuid$pendingVerifiedProfile == null || this.trueuuid$pendingAuthSource == null) { + sendDisconnectWithReason(Component.translatable("trueuuid.disconnect.auth_denied")); + return; + } + String name = this.trueuuid$pendingVerifiedProfile.getName(); + UUID verifiedUuid = this.trueuuid$pendingVerifiedProfile.getId(); + PlayerDataMigration.OfflineData data = PlayerDataMigration.findOfflineData(this.server, name); + if (data == null || verifiedUuid == null) { + sendDisconnectWithReason(Component.translatable("trueuuid.disconnect.auth_denied")); + return; } - Component sourceName = source == AuthState.AuthSource.YGGDRASIL - ? Component.translatable("trueuuid.auth_source.skin_site.with_name", displayName == null || displayName.isBlank() ? "Yggdrasil" : displayName) + + Component sourceName = this.trueuuid$pendingAuthSource == AuthState.AuthSource.YGGDRASIL + ? Component.translatable("trueuuid.auth_source.skin_site.with_name", + this.trueuuid$pendingAuthDisplayName == null || this.trueuuid$pendingAuthDisplayName.isBlank() ? "Yggdrasil" : this.trueuuid$pendingAuthDisplayName) : Component.translatable("trueuuid.auth_source.premium"); - if (!confirmed) { - sendDisconnectWithReason(Component.translatable( - "trueuuid.disconnect.duplicate_uuid", - sourceName, - data.offlineUuid(), - verifiedUuid, - name - )); + sendDisconnectWithReason(Component.translatable( + "trueuuid.disconnect.duplicate_uuid", + sourceName, + data.offlineUuid(), + verifiedUuid, + name + )); + } + + @Unique + private static boolean trueuuid$isWhitelistedAuthEndpoint(String hasJoinedUrl, List whitelist) { + if (hasJoinedUrl == null || hasJoinedUrl.isBlank()) { return false; } + String host; try { - PlayerDataMigration.migrateOfflineToVerified(this.server, name, verifiedUuid); - if (TrueuuidConfig.debug()) { - System.out.println("[TrueUUID] migrated offline data before login: player=" + name - + ", offlineUuid=" + data.offlineUuid() + ", verifiedUuid=" + verifiedUuid); - } - return true; - } catch (Exception ex) { - System.out.println("[TrueUUID] 离线玩家数据继承失败, player=" + name + ", offlineUuid=" + data.offlineUuid() - + ", verifiedUuid=" + verifiedUuid + ", error=" + ex); - sendDisconnectWithReason(Component.translatable( - "trueuuid.disconnect.migration_failed", - name, - data.offlineUuid(), - verifiedUuid, - ex.getMessage() - )); + URI uri = URI.create(hasJoinedUrl); + host = uri.getHost(); + } catch (Throwable ignored) { + return false; + } + if (host == null || host.isBlank()) { return false; } + String normalizedHost = host.toLowerCase(java.util.Locale.ROOT); + for (String entry : whitelist) { + String allowed = trueuuid$normalizeWhitelistHost(entry); + if (allowed.isEmpty()) { + continue; + } + if (normalizedHost.equals(allowed) || normalizedHost.endsWith("." + allowed)) { + return true; + } + } + return false; + } + + @Unique + private static String trueuuid$normalizeWhitelistHost(String entry) { + if (entry == null || entry.isBlank()) { + return ""; + } + String value = entry.trim().toLowerCase(java.util.Locale.ROOT); + try { + if (value.contains("://")) { + String host = URI.create(value).getHost(); + return host == null ? "" : host.toLowerCase(java.util.Locale.ROOT); + } + } catch (Throwable ignored) { + return ""; + } + while (value.startsWith(".")) { + value = value.substring(1); + } + int slash = value.indexOf('/'); + if (slash >= 0) { + value = value.substring(0, slash); + } + int port = value.indexOf(':'); + if (port >= 0) { + value = value.substring(0, port); + } + return value; } @Unique @@ -475,5 +601,10 @@ private void reset() { this.trueuuid$nonce = null; this.trueuuid$sentAt = 0L; this.trueuuid$offlineUpgradeOffered = false; + this.trueuuid$migrationConfirmation = false; + this.trueuuid$pendingVerifiedProfile = null; + this.trueuuid$pendingAuthSource = null; + this.trueuuid$pendingAuthDisplayName = null; + this.trueuuid$pendingIp = null; } } diff --git a/src/main/java/cn/alini/trueuuid/net/NetIds.java b/src/main/java/cn/alini/trueuuid/net/NetIds.java index 32ad1b7..56fd0bd 100644 --- a/src/main/java/cn/alini/trueuuid/net/NetIds.java +++ b/src/main/java/cn/alini/trueuuid/net/NetIds.java @@ -4,6 +4,7 @@ public final class NetIds { public static final ResourceLocation AUTH = new ResourceLocation("trueuuid", "auth"); + public static final String MIGRATION_CONFIRM_SERVER_ID = "trueuuid:migration-confirm"; private NetIds() {} -} \ No newline at end of file +} diff --git a/src/main/java/cn/alini/trueuuid/server/AuthDecider.java b/src/main/java/cn/alini/trueuuid/server/AuthDecider.java index b3be792..dd06be9 100644 --- a/src/main/java/cn/alini/trueuuid/server/AuthDecider.java +++ b/src/main/java/cn/alini/trueuuid/server/AuthDecider.java @@ -68,12 +68,23 @@ public static Decision onFailure(String name, String ip, boolean explicitOffline return d; } + if (!TrueuuidConfig.allowOfflineOnFailure()) { + d.kind = Decision.Kind.DENY; + d.denyComponent = Component.translatable("trueuuid.disconnect.auth_denied"); + return d; + } + // 3) 未知名字:可允许离线兜底 (Unknown names: Allow offline fallback) if (TrueuuidConfig.allowOfflineForUnknownOnly() && !known) { d.kind = Decision.Kind.OFFLINE; return d; } + if (!TrueuuidConfig.allowOfflineForUnknownOnly()) { + d.kind = Decision.Kind.OFFLINE; + return d; + } + // 4) 否则拒绝 (Otherwise deny) d.kind = Decision.Kind.DENY; d.denyComponent = Component.translatable("trueuuid.disconnect.auth_denied");