Skip to content

Commit 423384c

Browse files
feat(hooks): optional auto login and registration for Floodgate (Bedrock) players
Bedrock players connecting through Geyser/Floodgate have already been verified by their Xbox account, but AuthMe still asks them for a password. Typing it on a phone keyboard is a common source of timeouts and wrong passwords, especially for young players. Add two options, both disabled by default: - Hooks.floodgate.autoLogin: log in registered Bedrock players on join and skip the Paper pre-join dialog. - Hooks.floodgate.autoRegister: register Bedrock players that have no account yet with a random password and log them in. Floodgate is detected at runtime by reflection (like ViaVersion), so it stays an optional dependency. Java players are never affected: only UUIDs that Floodgate reports as Bedrock players qualify. Refs #3112 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d672940 commit 423384c

7 files changed

Lines changed: 259 additions & 2 deletions

File tree

‎authme-core/src/main/java/fr/xephi/authme/process/join/AsynchronousJoin.java‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@
1616
import fr.xephi.authme.process.register.RegisterSecondaryArgument;
1717
import fr.xephi.authme.process.register.RegistrationType;
1818
import fr.xephi.authme.process.register.AsyncRegister;
19+
import fr.xephi.authme.process.register.executors.ApiPasswordRegisterParams;
1920
import fr.xephi.authme.process.register.executors.EmailRegisterParams;
2021
import fr.xephi.authme.process.register.executors.PasswordRegisterParams;
2122
import fr.xephi.authme.process.register.executors.RegistrationMethod;
2223
import fr.xephi.authme.service.BukkitService;
2324
import fr.xephi.authme.service.CommonService;
2425
import fr.xephi.authme.service.DialogStateService;
2526
import fr.xephi.authme.service.DialogWindowService;
27+
import fr.xephi.authme.service.FloodgateService;
2628
import fr.xephi.authme.service.PendingPremiumCache;
2729
import fr.xephi.authme.service.PreJoinDialogService;
2830
import fr.xephi.authme.service.PremiumLoginVerifier;
@@ -42,8 +44,10 @@
4244
import fr.xephi.authme.settings.properties.PremiumSettings;
4345
import fr.xephi.authme.settings.properties.RegistrationSettings;
4446
import fr.xephi.authme.settings.properties.RestrictionSettings;
47+
import fr.xephi.authme.settings.properties.SecuritySettings;
4548
import fr.xephi.authme.util.InternetProtocolUtils;
4649
import fr.xephi.authme.util.PlayerUtils;
50+
import fr.xephi.authme.util.RandomStringUtils;
4751
import fr.xephi.authme.util.Utils;
4852
import org.bukkit.GameMode;
4953
import org.bukkit.Server;
@@ -129,6 +133,9 @@ public class AsynchronousJoin implements AsynchronousProcess {
129133
@Inject
130134
private PremiumService premiumService;
131135

136+
@Inject
137+
private FloodgateService floodgateService;
138+
132139
@Inject
133140
private ProxyLoginRequestValidator proxyLoginRequestValidator;
134141

@@ -199,6 +206,13 @@ public void processJoin(Player player) {
199206
}
200207
}
201208

209+
// Bedrock players verified by Floodgate (Xbox account) — same idea as premium below
210+
if (floodgateService.canAutoLogin(player.getUniqueId())) {
211+
logger.info("The Bedrock player " + player.getName() + " has been automatically logged in (Floodgate).");
212+
bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLogin(player));
213+
return;
214+
}
215+
202216
// Premium players are always verified by Mojang — skip the session mechanism entirely,
203217
// which would send a misleading SESSION_RECONNECTION message and run session commands.
204218
if (canBypassWithPremium(player)) {
@@ -242,6 +256,11 @@ public void processJoin(Player player) {
242256
bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLogin(player));
243257
return;
244258
}
259+
} else if (pendingRegistration == null && floodgateService.canAutoRegister(player.getUniqueId())) {
260+
logger.info("The Bedrock player " + player.getName() + " has been automatically registered (Floodgate).");
261+
asyncRegister.register(RegistrationMethod.API_REGISTRATION,
262+
ApiPasswordRegisterParams.of(player, generateFloodgatePassword(), true));
263+
return;
245264
} else if (!service.getProperty(RegistrationSettings.FORCE) && pendingRegistration == null) {
246265
bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, () -> {
247266
limboService.restorePendingOperator(player);
@@ -277,6 +296,16 @@ public void processJoin(Player player) {
277296
processJoinSync(player, isAuthAvailable, pendingLoginPassword, pendingRecoveryEmail, pendingRegistration, shouldSkipPostJoinDialog, pendingForceLogin);
278297
}
279298

299+
/**
300+
* Generates a random password for a Bedrock player registered automatically through Floodgate.
301+
* The player never types it; it only has to pass the configured password restrictions.
302+
*/
303+
private String generateFloodgatePassword() {
304+
int length = Math.max(service.getProperty(SecuritySettings.MIN_PASSWORD_LENGTH),
305+
Math.min(24, service.getProperty(SecuritySettings.MAX_PASSWORD_LENGTH)));
306+
return RandomStringUtils.generate(length);
307+
}
308+
280309
private void handlePlayerWithUnmetNameRestriction(Player player, String ip) {
281310
bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player,
282311
() -> player.kickPlayer(service.retrieveSingleMessage(player, MessageKey.NOT_OWNER_ERROR)));
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package fr.xephi.authme.service;
2+
3+
import fr.xephi.authme.ConsoleLogger;
4+
import fr.xephi.authme.output.ConsoleLoggerFactory;
5+
import fr.xephi.authme.settings.Settings;
6+
import fr.xephi.authme.settings.properties.HooksSettings;
7+
import org.bukkit.plugin.PluginManager;
8+
9+
import javax.inject.Inject;
10+
import java.lang.reflect.Method;
11+
import java.util.UUID;
12+
13+
/**
14+
* Detects Bedrock players connected through Floodgate (Geyser). Floodgate has already verified
15+
* their Xbox account, so they can optionally be logged in (and registered) without a password.
16+
* <p>
17+
* Floodgate is accessed by reflection so that it stays an optional, runtime-only dependency.
18+
*/
19+
public class FloodgateService {
20+
21+
private final ConsoleLogger logger = ConsoleLoggerFactory.get(FloodgateService.class);
22+
23+
@Inject
24+
private PluginManager pluginManager;
25+
26+
@Inject
27+
private Settings settings;
28+
29+
FloodgateService() {
30+
}
31+
32+
/**
33+
* Returns whether the player can be logged in automatically: the auto-login option is enabled
34+
* and Floodgate reports the UUID as a Bedrock player.
35+
*
36+
* @param playerId the UUID of the connecting player
37+
* @return true if the player should be logged in without a password
38+
*/
39+
public boolean canAutoLogin(UUID playerId) {
40+
return settings.getProperty(HooksSettings.FLOODGATE_AUTO_LOGIN) && isFloodgatePlayer(playerId);
41+
}
42+
43+
/**
44+
* Returns whether an unregistered player can be registered automatically (with a random password).
45+
*
46+
* @param playerId the UUID of the connecting player
47+
* @return true if the player should be registered and logged in without a password
48+
*/
49+
public boolean canAutoRegister(UUID playerId) {
50+
return settings.getProperty(HooksSettings.FLOODGATE_AUTO_REGISTER) && canAutoLogin(playerId);
51+
}
52+
53+
/**
54+
* Returns whether Floodgate reports the given UUID as a Bedrock player.
55+
*
56+
* @param playerId the UUID to check
57+
* @return true if Floodgate is installed and knows the player as a Bedrock player
58+
*/
59+
public boolean isFloodgatePlayer(UUID playerId) {
60+
if (playerId == null || pluginManager.getPlugin("floodgate") == null) {
61+
return false;
62+
}
63+
try {
64+
Class<?> apiClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi");
65+
Object api = apiClass.getMethod("getInstance").invoke(null);
66+
Method isFloodgatePlayer = apiClass.getMethod("isFloodgatePlayer", UUID.class);
67+
return (boolean) isFloodgatePlayer.invoke(api, playerId);
68+
} catch (ReflectiveOperationException | RuntimeException e) {
69+
logger.logException("Could not check Floodgate for player " + playerId + ":", e);
70+
return false;
71+
}
72+
}
73+
}

‎authme-core/src/main/java/fr/xephi/authme/settings/properties/HooksSettings.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ public final class HooksSettings implements SettingsHolder {
3232
public static final Property<String> PROXY_SHARED_SECRET =
3333
newProperty("Hooks.proxySharedSecret", "");
3434

35+
@Comment({
36+
"Log in Bedrock players connected through Floodgate (Geyser) without asking for a password.",
37+
"Floodgate has already verified their Xbox account. Java players are not affected."
38+
})
39+
public static final Property<Boolean> FLOODGATE_AUTO_LOGIN =
40+
newProperty("Hooks.floodgate.autoLogin", false);
41+
42+
@Comment({
43+
"Also register Bedrock players (Floodgate) that have no account yet,",
44+
"with a random password, and log them in. Requires Hooks.floodgate.autoLogin."
45+
})
46+
public static final Property<Boolean> FLOODGATE_AUTO_REGISTER =
47+
newProperty("Hooks.floodgate.autoRegister", false);
48+
3549
@Comment("Do we need to disable Essentials SocialSpy on join?")
3650
public static final Property<Boolean> DISABLE_SOCIAL_SPY =
3751
newProperty("Hooks.disableSocialSpy", false);

‎authme-core/src/test/java/fr/xephi/authme/process/join/AsynchronousJoinTest.java‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
import fr.xephi.authme.platform.DialogWindowSpec;
1111
import fr.xephi.authme.process.login.AsynchronousLogin;
1212
import fr.xephi.authme.process.register.AsyncRegister;
13+
import fr.xephi.authme.process.register.executors.ApiPasswordRegisterParams;
14+
import fr.xephi.authme.process.register.executors.RegistrationMethod;
1315
import fr.xephi.authme.service.BukkitService;
1416
import fr.xephi.authme.service.CommonService;
1517
import fr.xephi.authme.service.DialogStateService;
1618
import fr.xephi.authme.service.DialogWindowService;
19+
import fr.xephi.authme.service.FloodgateService;
1720
import fr.xephi.authme.service.PendingPremiumCache;
1821
import fr.xephi.authme.service.PluginHookService;
1922
import fr.xephi.authme.service.PreJoinDialogService;
@@ -29,11 +32,13 @@
2932
import fr.xephi.authme.settings.properties.PremiumSettings;
3033
import fr.xephi.authme.settings.properties.RegistrationSettings;
3134
import fr.xephi.authme.settings.properties.RestrictionSettings;
35+
import fr.xephi.authme.settings.properties.SecuritySettings;
3236
import org.bukkit.entity.Player;
3337
import org.junit.jupiter.api.BeforeAll;
3438
import org.junit.jupiter.api.BeforeEach;
3539
import org.junit.jupiter.api.Test;
3640
import org.junit.jupiter.api.extension.ExtendWith;
41+
import org.mockito.ArgumentCaptor;
3742
import org.mockito.InjectMocks;
3843
import org.mockito.Mock;
3944
import org.mockito.junit.jupiter.MockitoExtension;
@@ -47,6 +52,8 @@
4752
import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToRunTaskLater;
4853
import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToRunTaskOptionallyAsync;
4954
import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToScheduleSyncEntityTaskFromOptionallyAsyncTask;
55+
import static org.hamcrest.MatcherAssert.assertThat;
56+
import static org.hamcrest.Matchers.is;
5057
import static org.mockito.ArgumentMatchers.any;
5158
import static org.mockito.ArgumentMatchers.anyBoolean;
5259
import static org.mockito.ArgumentMatchers.anyString;
@@ -107,6 +114,8 @@ public class AsynchronousJoinTest {
107114
@Mock
108115
private PremiumService premiumService;
109116
@Mock
117+
private FloodgateService floodgateService;
118+
@Mock
110119
private ProxyLoginRequestValidator proxyLoginRequestValidator;
111120

112121
@BeforeAll
@@ -417,6 +426,52 @@ public void shouldRestoreOperatorOfUnregisteredPlayerWhenRegistrationIsOptional(
417426
verify(limboService, never()).createLimboPlayer(eq(player), anyBoolean());
418427
}
419428

429+
@Test
430+
public void shouldAutoLoginRegisteredFloodgatePlayerWithoutCreatingLimbo() {
431+
// given
432+
Player player = mockPlayer(".Bobby");
433+
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
434+
given(player.getUniqueId()).willReturn(playerId);
435+
setUpRegisteredJoin(player);
436+
given(floodgateService.canAutoLogin(playerId)).willReturn(true);
437+
438+
// when
439+
asynchronousJoin.processJoin(player);
440+
441+
// then
442+
verify(asynchronousLogin).forceLogin(player);
443+
verify(limboService, never()).createLimboPlayer(eq(player), anyBoolean());
444+
verify(dialogAdapter, never()).showLoginDialog(eq(player), any(DialogWindowSpec.class));
445+
}
446+
447+
@Test
448+
public void shouldAutoRegisterUnregisteredFloodgatePlayer() {
449+
// given
450+
Player player = mockPlayer(".Bobby");
451+
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
452+
given(player.getUniqueId()).willReturn(playerId);
453+
given(validationService.fulfillsNameRestrictions(player)).willReturn(true);
454+
given(service.getProperty(RestrictionSettings.UNRESTRICTED_NAMES)).willReturn(Set.of());
455+
given(service.getProperty(RestrictionSettings.FORCE_SURVIVAL_MODE)).willReturn(false);
456+
given(service.getProperty(HooksSettings.DISABLE_SOCIAL_SPY)).willReturn(false);
457+
given(service.getProperty(RestrictionSettings.MAX_JOIN_PER_IP)).willReturn(0);
458+
given(database.isAuthAvailable(".bobby")).willReturn(false);
459+
given(floodgateService.canAutoRegister(playerId)).willReturn(true);
460+
given(service.getProperty(SecuritySettings.MIN_PASSWORD_LENGTH)).willReturn(5);
461+
given(service.getProperty(SecuritySettings.MAX_PASSWORD_LENGTH)).willReturn(30);
462+
463+
// when
464+
asynchronousJoin.processJoin(player);
465+
466+
// then
467+
ArgumentCaptor<ApiPasswordRegisterParams> params = ArgumentCaptor.forClass(ApiPasswordRegisterParams.class);
468+
verify(asyncRegister).register(eq(RegistrationMethod.API_REGISTRATION), params.capture());
469+
assertThat(params.getValue().getPlayer(), is(player));
470+
assertThat(params.getValue().getPassword().length(), is(24));
471+
assertThat(params.getValue().getLoginAfterRegister(), is(true));
472+
verify(limboService, never()).createLimboPlayer(eq(player), anyBoolean());
473+
}
474+
420475
private void givenPendingDialogState(PreJoinDialogService.PendingDialogState state) {
421476
given(preJoinDialogService.consumeSession("bobby")).willReturn(state);
422477
}

‎authme-paper-common/src/main/java/fr/xephi/authme/listener/PaperDialogFlowListener.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import fr.xephi.authme.service.BukkitService;
2020
import fr.xephi.authme.service.CommonService;
2121
import fr.xephi.authme.service.DialogWindowService;
22+
import fr.xephi.authme.service.FloodgateService;
2223
import fr.xephi.authme.service.PendingPremiumCache;
2324
import fr.xephi.authme.service.PreJoinDialogService;
2425
import fr.xephi.authme.service.PremiumLoginVerifier;
@@ -95,6 +96,9 @@ public class PaperDialogFlowListener implements Listener {
9596
@Inject
9697
private PremiumLoginVerifier premiumLoginVerifier;
9798

99+
@Inject
100+
private FloodgateService floodgateService;
101+
98102
@Inject
99103
private LoginCaptchaManager loginCaptchaManager;
100104

@@ -133,6 +137,11 @@ public void onPlayerConfigure(AsyncPlayerConnectionConfigureEvent event) {
133137
connectionSessions.put(connection, sessionId);
134138

135139
PlayerAuth auth = dataSource.getAuth(normalizedName);
140+
// Bedrock players (Floodgate) are logged in or registered on join, without a password
141+
if (auth != null ? floodgateService.canAutoLogin(playerId) : floodgateService.canAutoRegister(playerId)) {
142+
preJoinDialogService.markSkipPostJoinDialog(sessionId);
143+
return;
144+
}
136145
if (auth != null) {
137146
if (shouldSkipPreJoinDialogForPremium(auth, playerName, playerId)) {
138147
preJoinDialogService.markSkipPostJoinDialog(sessionId);

‎authme-paper-common/src/test/java/fr/xephi/authme/listener/PaperDialogFlowListenerTest.java‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import fr.xephi.authme.service.BukkitService;
2222
import fr.xephi.authme.service.CommonService;
2323
import fr.xephi.authme.service.DialogWindowService;
24+
import fr.xephi.authme.service.FloodgateService;
2425
import fr.xephi.authme.service.PreJoinDialogService;
2526
import fr.xephi.authme.service.PremiumLoginVerifier;
2627
import fr.xephi.authme.service.SessionService;
@@ -464,6 +465,7 @@ public void shouldSkipPreJoinDialogsForVerifiedPremiumPlayer() throws Exception
464465
setField(listener, "sessionService", sessionService);
465466
setField(listener, "proxySessionManager", proxySessionManager);
466467
setField(listener, "premiumLoginVerifier", premiumLoginVerifier);
468+
setField(listener, "floodgateService", mock(FloodgateService.class));
467469

468470
UUID premiumUuid = UUID.randomUUID();
469471
// Offline (v3) UUID simulates an offline-mode backend without proxy
@@ -502,6 +504,74 @@ public void shouldSkipPreJoinDialogsForVerifiedPremiumPlayer() throws Exception
502504
verifyNoInteractions(audience);
503505
}
504506

507+
@Test
508+
public void shouldSkipPreJoinDialogsForRegisteredFloodgatePlayer() throws Exception {
509+
PlayerAuth auth = PlayerAuth.builder().name(".bobby").password(new HashedPassword("hash")).build();
510+
FloodgateService floodgateService = mock(FloodgateService.class);
511+
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
512+
given(floodgateService.canAutoLogin(playerId)).willReturn(true);
513+
514+
PreJoinDialogService preJoinDialogService = mock(PreJoinDialogService.class);
515+
Audience audience = mock(Audience.class);
516+
517+
configureFloodgateJoin(floodgateService, preJoinDialogService, audience, playerId, auth);
518+
519+
verify(preJoinDialogService).markSkipPostJoinDialog(SESSION_ID);
520+
verifyNoInteractions(audience);
521+
}
522+
523+
@Test
524+
public void shouldSkipPreJoinDialogsForUnregisteredFloodgatePlayerWithAutoRegister() throws Exception {
525+
FloodgateService floodgateService = mock(FloodgateService.class);
526+
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
527+
given(floodgateService.canAutoRegister(playerId)).willReturn(true);
528+
529+
PreJoinDialogService preJoinDialogService = mock(PreJoinDialogService.class);
530+
Audience audience = mock(Audience.class);
531+
532+
configureFloodgateJoin(floodgateService, preJoinDialogService, audience, playerId, null);
533+
534+
verify(preJoinDialogService).markSkipPostJoinDialog(SESSION_ID);
535+
verifyNoInteractions(audience);
536+
}
537+
538+
/** Runs onPlayerConfigure for the Bedrock player ".Bobby" (stored auth, or null if unregistered). */
539+
private static void configureFloodgateJoin(FloodgateService floodgateService,
540+
PreJoinDialogService preJoinDialogService, Audience audience,
541+
UUID playerId, PlayerAuth auth) throws Exception {
542+
PaperDialogFlowListener listener = new PaperDialogFlowListener();
543+
CommonService commonService = mock(CommonService.class);
544+
PlayerCache playerCache = mock(PlayerCache.class);
545+
DataSource dataSource = mock(DataSource.class);
546+
SessionService sessionService = mock(SessionService.class);
547+
ProxySessionManager proxySessionManager = mock(ProxySessionManager.class);
548+
setField(listener, "commonService", commonService);
549+
setField(listener, "playerCache", playerCache);
550+
setField(listener, "dataSource", dataSource);
551+
setField(listener, "preJoinDialogService", preJoinDialogService);
552+
setField(listener, "sessionService", sessionService);
553+
setField(listener, "proxySessionManager", proxySessionManager);
554+
setField(listener, "floodgateService", floodgateService);
555+
556+
given(commonService.getProperty(RegistrationSettings.USE_PREJOIN_DIALOG_UI)).willReturn(true);
557+
given(commonService.getProperty(RestrictionSettings.UNRESTRICTED_NAMES)).willReturn(Set.of());
558+
given(dataSource.getAuth(".bobby")).willReturn(auth);
559+
given(preJoinDialogService.openSession(".bobby")).willReturn(SESSION_ID);
560+
561+
PlayerProfile profile = mock(PlayerProfile.class);
562+
given(profile.getId()).willReturn(playerId);
563+
given(profile.getName()).willReturn(".Bobby");
564+
565+
PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class);
566+
given(connection.getProfile()).willReturn(profile);
567+
given(connection.getAudience()).willReturn(audience);
568+
569+
AsyncPlayerConnectionConfigureEvent event = mock(AsyncPlayerConnectionConfigureEvent.class);
570+
given(event.getConnection()).willReturn(connection);
571+
572+
listener.onPlayerConfigure(event);
573+
}
574+
505575
@Test
506576
public void shouldNotSkipPreJoinDialogsForUnverifiedPremiumPlayer() throws Exception {
507577
PaperDialogFlowListener listener = new PaperDialogFlowListener();

0 commit comments

Comments
 (0)