Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@
import fr.xephi.authme.process.register.RegisterSecondaryArgument;
import fr.xephi.authme.process.register.RegistrationType;
import fr.xephi.authme.process.register.AsyncRegister;
import fr.xephi.authme.process.register.executors.ApiPasswordRegisterParams;
import fr.xephi.authme.process.register.executors.EmailRegisterParams;
import fr.xephi.authme.process.register.executors.PasswordRegisterParams;
import fr.xephi.authme.process.register.executors.RegistrationMethod;
import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.CommonService;
import fr.xephi.authme.service.DialogStateService;
import fr.xephi.authme.service.DialogWindowService;
import fr.xephi.authme.service.FloodgateService;
import fr.xephi.authme.service.PendingPremiumCache;
import fr.xephi.authme.service.PreJoinDialogService;
import fr.xephi.authme.service.PremiumLoginVerifier;
Expand All @@ -42,8 +44,10 @@
import fr.xephi.authme.settings.properties.PremiumSettings;
import fr.xephi.authme.settings.properties.RegistrationSettings;
import fr.xephi.authme.settings.properties.RestrictionSettings;
import fr.xephi.authme.settings.properties.SecuritySettings;
import fr.xephi.authme.util.InternetProtocolUtils;
import fr.xephi.authme.util.PlayerUtils;
import fr.xephi.authme.util.RandomStringUtils;
import fr.xephi.authme.util.Utils;
import org.bukkit.GameMode;
import org.bukkit.Server;
Expand Down Expand Up @@ -129,6 +133,9 @@ public class AsynchronousJoin implements AsynchronousProcess {
@Inject
private PremiumService premiumService;

@Inject
private FloodgateService floodgateService;

@Inject
private ProxyLoginRequestValidator proxyLoginRequestValidator;

Expand Down Expand Up @@ -199,6 +206,13 @@ public void processJoin(Player player) {
}
}

// Bedrock players verified by Floodgate (Xbox account) — same idea as premium below
if (floodgateService.canAutoLogin(player.getUniqueId())) {
logger.info("The Bedrock player " + player.getName() + " has been automatically logged in (Floodgate).");
bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLogin(player));
return;
}

// Premium players are always verified by Mojang — skip the session mechanism entirely,
// which would send a misleading SESSION_RECONNECTION message and run session commands.
if (canBypassWithPremium(player)) {
Expand Down Expand Up @@ -242,6 +256,11 @@ public void processJoin(Player player) {
bukkitService.runTaskOptionallyAsync(() -> asynchronousLogin.forceLogin(player));
return;
}
} else if (pendingRegistration == null && floodgateService.canAutoRegister(player.getUniqueId())) {
logger.info("The Bedrock player " + player.getName() + " has been automatically registered (Floodgate).");
asyncRegister.register(RegistrationMethod.API_REGISTRATION,
ApiPasswordRegisterParams.of(player, generateFloodgatePassword(), true));
return;
} else if (!service.getProperty(RegistrationSettings.FORCE) && pendingRegistration == null) {
bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player, () -> {
limboService.restorePendingOperator(player);
Expand Down Expand Up @@ -277,6 +296,16 @@ public void processJoin(Player player) {
processJoinSync(player, isAuthAvailable, pendingLoginPassword, pendingRecoveryEmail, pendingRegistration, shouldSkipPostJoinDialog, pendingForceLogin);
}

/**
* Generates a random password for a Bedrock player registered automatically through Floodgate.
* The player never types it; it only has to pass the configured password restrictions.
*/
private String generateFloodgatePassword() {
int length = Math.max(service.getProperty(SecuritySettings.MIN_PASSWORD_LENGTH),
Math.min(24, service.getProperty(SecuritySettings.MAX_PASSWORD_LENGTH)));
return RandomStringUtils.generate(length);
}

private void handlePlayerWithUnmetNameRestriction(Player player, String ip) {
bukkitService.scheduleSyncTaskFromOptionallyAsyncTask(player,
() -> player.kickPlayer(service.retrieveSingleMessage(player, MessageKey.NOT_OWNER_ERROR)));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package fr.xephi.authme.service;

import fr.xephi.authme.ConsoleLogger;
import fr.xephi.authme.output.ConsoleLoggerFactory;
import fr.xephi.authme.settings.Settings;
import fr.xephi.authme.settings.properties.HooksSettings;
import org.bukkit.plugin.PluginManager;

import javax.inject.Inject;
import java.lang.reflect.Method;
import java.util.UUID;

/**
* Detects Bedrock players connected through Floodgate (Geyser). Floodgate has already verified
* their Xbox account, so they can optionally be logged in (and registered) without a password.
* <p>
* Floodgate is accessed by reflection so that it stays an optional, runtime-only dependency.
*/
public class FloodgateService {

private final ConsoleLogger logger = ConsoleLoggerFactory.get(FloodgateService.class);

@Inject
private PluginManager pluginManager;

@Inject
private Settings settings;

FloodgateService() {
}

/**
* Returns whether the player can be logged in automatically: the auto-login option is enabled
* and Floodgate reports the UUID as a Bedrock player.
*
* @param playerId the UUID of the connecting player
* @return true if the player should be logged in without a password
*/
public boolean canAutoLogin(UUID playerId) {
return settings.getProperty(HooksSettings.FLOODGATE_AUTO_LOGIN) && isFloodgatePlayer(playerId);
}

/**
* Returns whether an unregistered player can be registered automatically (with a random password).
*
* @param playerId the UUID of the connecting player
* @return true if the player should be registered and logged in without a password
*/
public boolean canAutoRegister(UUID playerId) {
return settings.getProperty(HooksSettings.FLOODGATE_AUTO_REGISTER) && canAutoLogin(playerId);
}

/**
* Returns whether Floodgate reports the given UUID as a Bedrock player.
*
* @param playerId the UUID to check
* @return true if Floodgate is installed and knows the player as a Bedrock player
*/
public boolean isFloodgatePlayer(UUID playerId) {
if (playerId == null || pluginManager.getPlugin("floodgate") == null) {
return false;
}
try {
Class<?> apiClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi");
Object api = apiClass.getMethod("getInstance").invoke(null);
Method isFloodgatePlayer = apiClass.getMethod("isFloodgatePlayer", UUID.class);
return (boolean) isFloodgatePlayer.invoke(api, playerId);
} catch (ReflectiveOperationException | RuntimeException e) {
logger.logException("Could not check Floodgate for player " + playerId + ":", e);
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ public final class HooksSettings implements SettingsHolder {
public static final Property<String> PROXY_SHARED_SECRET =
newProperty("Hooks.proxySharedSecret", "");

@Comment({
"Log in Bedrock players connected through Floodgate (Geyser) without asking for a password.",
"Floodgate has already verified their Xbox account. Java players are not affected."
})
public static final Property<Boolean> FLOODGATE_AUTO_LOGIN =
newProperty("Hooks.floodgate.autoLogin", false);

@Comment({
"Also register Bedrock players (Floodgate) that have no account yet,",
"with a random password, and log them in. Requires Hooks.floodgate.autoLogin."
})
public static final Property<Boolean> FLOODGATE_AUTO_REGISTER =
newProperty("Hooks.floodgate.autoRegister", false);

@Comment("Do we need to disable Essentials SocialSpy on join?")
public static final Property<Boolean> DISABLE_SOCIAL_SPY =
newProperty("Hooks.disableSocialSpy", false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,13 @@
import fr.xephi.authme.platform.DialogWindowSpec;
import fr.xephi.authme.process.login.AsynchronousLogin;
import fr.xephi.authme.process.register.AsyncRegister;
import fr.xephi.authme.process.register.executors.ApiPasswordRegisterParams;
import fr.xephi.authme.process.register.executors.RegistrationMethod;
import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.CommonService;
import fr.xephi.authme.service.DialogStateService;
import fr.xephi.authme.service.DialogWindowService;
import fr.xephi.authme.service.FloodgateService;
import fr.xephi.authme.service.PendingPremiumCache;
import fr.xephi.authme.service.PluginHookService;
import fr.xephi.authme.service.PreJoinDialogService;
Expand All @@ -29,11 +32,13 @@
import fr.xephi.authme.settings.properties.PremiumSettings;
import fr.xephi.authme.settings.properties.RegistrationSettings;
import fr.xephi.authme.settings.properties.RestrictionSettings;
import fr.xephi.authme.settings.properties.SecuritySettings;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
Expand All @@ -47,6 +52,8 @@
import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToRunTaskLater;
import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToRunTaskOptionallyAsync;
import static fr.xephi.authme.service.BukkitServiceTestHelper.setBukkitServiceToScheduleSyncEntityTaskFromOptionallyAsyncTask;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
Expand Down Expand Up @@ -107,6 +114,8 @@ public class AsynchronousJoinTest {
@Mock
private PremiumService premiumService;
@Mock
private FloodgateService floodgateService;
@Mock
private ProxyLoginRequestValidator proxyLoginRequestValidator;

@BeforeAll
Expand Down Expand Up @@ -417,6 +426,52 @@ public void shouldRestoreOperatorOfUnregisteredPlayerWhenRegistrationIsOptional(
verify(limboService, never()).createLimboPlayer(eq(player), anyBoolean());
}

@Test
public void shouldAutoLoginRegisteredFloodgatePlayerWithoutCreatingLimbo() {
// given
Player player = mockPlayer(".Bobby");
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
given(player.getUniqueId()).willReturn(playerId);
setUpRegisteredJoin(player);
given(floodgateService.canAutoLogin(playerId)).willReturn(true);

// when
asynchronousJoin.processJoin(player);

// then
verify(asynchronousLogin).forceLogin(player);
verify(limboService, never()).createLimboPlayer(eq(player), anyBoolean());
verify(dialogAdapter, never()).showLoginDialog(eq(player), any(DialogWindowSpec.class));
}

@Test
public void shouldAutoRegisterUnregisteredFloodgatePlayer() {
// given
Player player = mockPlayer(".Bobby");
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
given(player.getUniqueId()).willReturn(playerId);
given(validationService.fulfillsNameRestrictions(player)).willReturn(true);
given(service.getProperty(RestrictionSettings.UNRESTRICTED_NAMES)).willReturn(Set.of());
given(service.getProperty(RestrictionSettings.FORCE_SURVIVAL_MODE)).willReturn(false);
given(service.getProperty(HooksSettings.DISABLE_SOCIAL_SPY)).willReturn(false);
given(service.getProperty(RestrictionSettings.MAX_JOIN_PER_IP)).willReturn(0);
given(database.isAuthAvailable(".bobby")).willReturn(false);
given(floodgateService.canAutoRegister(playerId)).willReturn(true);
given(service.getProperty(SecuritySettings.MIN_PASSWORD_LENGTH)).willReturn(5);
given(service.getProperty(SecuritySettings.MAX_PASSWORD_LENGTH)).willReturn(30);

// when
asynchronousJoin.processJoin(player);

// then
ArgumentCaptor<ApiPasswordRegisterParams> params = ArgumentCaptor.forClass(ApiPasswordRegisterParams.class);
verify(asyncRegister).register(eq(RegistrationMethod.API_REGISTRATION), params.capture());
assertThat(params.getValue().getPlayer(), is(player));
assertThat(params.getValue().getPassword().length(), is(24));
assertThat(params.getValue().getLoginAfterRegister(), is(true));
verify(limboService, never()).createLimboPlayer(eq(player), anyBoolean());
}

private void givenPendingDialogState(PreJoinDialogService.PendingDialogState state) {
given(preJoinDialogService.consumeSession("bobby")).willReturn(state);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.CommonService;
import fr.xephi.authme.service.DialogWindowService;
import fr.xephi.authme.service.FloodgateService;
import fr.xephi.authme.service.PendingPremiumCache;
import fr.xephi.authme.service.PreJoinDialogService;
import fr.xephi.authme.service.PremiumLoginVerifier;
Expand Down Expand Up @@ -95,6 +96,9 @@ public class PaperDialogFlowListener implements Listener {
@Inject
private PremiumLoginVerifier premiumLoginVerifier;

@Inject
private FloodgateService floodgateService;

@Inject
private LoginCaptchaManager loginCaptchaManager;

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

PlayerAuth auth = dataSource.getAuth(normalizedName);
// Bedrock players (Floodgate) are logged in or registered on join, without a password
if (auth != null ? floodgateService.canAutoLogin(playerId) : floodgateService.canAutoRegister(playerId)) {
preJoinDialogService.markSkipPostJoinDialog(sessionId);
return;
}
if (auth != null) {
if (shouldSkipPreJoinDialogForPremium(auth, playerName, playerId)) {
preJoinDialogService.markSkipPostJoinDialog(sessionId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import fr.xephi.authme.service.BukkitService;
import fr.xephi.authme.service.CommonService;
import fr.xephi.authme.service.DialogWindowService;
import fr.xephi.authme.service.FloodgateService;
import fr.xephi.authme.service.PreJoinDialogService;
import fr.xephi.authme.service.PremiumLoginVerifier;
import fr.xephi.authme.service.SessionService;
Expand Down Expand Up @@ -464,6 +465,7 @@ public void shouldSkipPreJoinDialogsForVerifiedPremiumPlayer() throws Exception
setField(listener, "sessionService", sessionService);
setField(listener, "proxySessionManager", proxySessionManager);
setField(listener, "premiumLoginVerifier", premiumLoginVerifier);
setField(listener, "floodgateService", mock(FloodgateService.class));

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

@Test
public void shouldSkipPreJoinDialogsForRegisteredFloodgatePlayer() throws Exception {
PlayerAuth auth = PlayerAuth.builder().name(".bobby").password(new HashedPassword("hash")).build();
FloodgateService floodgateService = mock(FloodgateService.class);
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
given(floodgateService.canAutoLogin(playerId)).willReturn(true);

PreJoinDialogService preJoinDialogService = mock(PreJoinDialogService.class);
Audience audience = mock(Audience.class);

configureFloodgateJoin(floodgateService, preJoinDialogService, audience, playerId, auth);

verify(preJoinDialogService).markSkipPostJoinDialog(SESSION_ID);
verifyNoInteractions(audience);
}

@Test
public void shouldSkipPreJoinDialogsForUnregisteredFloodgatePlayerWithAutoRegister() throws Exception {
FloodgateService floodgateService = mock(FloodgateService.class);
UUID playerId = UUID.fromString("00000000-0000-0000-0009-01f163ba2c3a");
given(floodgateService.canAutoRegister(playerId)).willReturn(true);

PreJoinDialogService preJoinDialogService = mock(PreJoinDialogService.class);
Audience audience = mock(Audience.class);

configureFloodgateJoin(floodgateService, preJoinDialogService, audience, playerId, null);

verify(preJoinDialogService).markSkipPostJoinDialog(SESSION_ID);
verifyNoInteractions(audience);
}

/** Runs onPlayerConfigure for the Bedrock player ".Bobby" (stored auth, or null if unregistered). */
private static void configureFloodgateJoin(FloodgateService floodgateService,
PreJoinDialogService preJoinDialogService, Audience audience,
UUID playerId, PlayerAuth auth) throws Exception {
PaperDialogFlowListener listener = new PaperDialogFlowListener();
CommonService commonService = mock(CommonService.class);
PlayerCache playerCache = mock(PlayerCache.class);
DataSource dataSource = mock(DataSource.class);
SessionService sessionService = mock(SessionService.class);
ProxySessionManager proxySessionManager = mock(ProxySessionManager.class);
setField(listener, "commonService", commonService);
setField(listener, "playerCache", playerCache);
setField(listener, "dataSource", dataSource);
setField(listener, "preJoinDialogService", preJoinDialogService);
setField(listener, "sessionService", sessionService);
setField(listener, "proxySessionManager", proxySessionManager);
setField(listener, "floodgateService", floodgateService);

given(commonService.getProperty(RegistrationSettings.USE_PREJOIN_DIALOG_UI)).willReturn(true);
given(commonService.getProperty(RestrictionSettings.UNRESTRICTED_NAMES)).willReturn(Set.of());
given(dataSource.getAuth(".bobby")).willReturn(auth);
given(preJoinDialogService.openSession(".bobby")).willReturn(SESSION_ID);

PlayerProfile profile = mock(PlayerProfile.class);
given(profile.getId()).willReturn(playerId);
given(profile.getName()).willReturn(".Bobby");

PlayerConfigurationConnection connection = mock(PlayerConfigurationConnection.class);
given(connection.getProfile()).willReturn(profile);
given(connection.getAudience()).willReturn(audience);

AsyncPlayerConnectionConfigureEvent event = mock(AsyncPlayerConnectionConfigureEvent.class);
given(event.getConnection()).willReturn(connection);

listener.onPlayerConfigure(event);
}

@Test
public void shouldNotSkipPreJoinDialogsForUnverifiedPremiumPlayer() throws Exception {
PaperDialogFlowListener listener = new PaperDialogFlowListener();
Expand Down
Loading