Deprecate userAuths in favor of a saved token system

This commit is contained in:
Camotoy 2022-03-03 18:52:26 -05:00
parent 37c854b5ac
commit c977e36368
No known key found for this signature in database
GPG key ID: 7EEFB66FE798081F
15 changed files with 251 additions and 33 deletions

5
.gitignore vendored
View file

@ -239,8 +239,9 @@ nbdist/
run/
config.yml
logs/
public-key.pem
key.pem
locales/
/cache/
/packs/
/dump.json
/dump.json
/saved-refresh-tokens.json

View file

@ -275,6 +275,12 @@ public class GeyserStandaloneBootstrap implements GeyserBootstrap {
return Paths.get(System.getProperty("user.dir"));
}
@Override
public Path getSavedUserLoginsFolder() {
// Return the location of the config
return new File(configFilename).getAbsoluteFile().getParentFile().toPath();
}
@Override
public BootstrapDumpInfo getDumpInfo() {
return new GeyserStandaloneDumpInfo(this);

View file

@ -149,7 +149,7 @@
<dependency>
<groupId>com.github.GeyserMC</groupId>
<artifactId>MCAuthLib</artifactId>
<version>6c99331</version>
<version>d9d773e</version>
<scope>compile</scope>
</dependency>
<dependency>

View file

@ -37,6 +37,8 @@ public final class Constants {
public static final String FLOODGATE_DOWNLOAD_LOCATION = "https://ci.opencollab.dev/job/GeyserMC/job/Floodgate/job/master/";
static final String SAVED_REFRESH_TOKEN_FILE = "saved-refresh-tokens.json";
static {
URI wsUri = null;
try {

View file

@ -97,6 +97,13 @@ public interface GeyserBootstrap {
*/
Path getConfigFolder();
/**
* @return the folder where user tokens are saved. This should always point to the location of the config.
*/
default Path getSavedUserLoginsFolder() {
return getConfigFolder();
}
/**
* Information used for the bootstrap section of the debug dump
*

View file

@ -26,6 +26,7 @@
package org.geysermc.geyser;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.steveice10.packetlib.tcp.TcpSession;
@ -37,6 +38,7 @@ import io.netty.channel.kqueue.KQueue;
import io.netty.util.NettyRuntime;
import io.netty.util.concurrent.DefaultThreadFactory;
import io.netty.util.internal.SystemPropertyUtil;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import org.checkerframework.checker.nullness.qual.NonNull;
@ -72,6 +74,9 @@ import org.geysermc.geyser.util.*;
import javax.naming.directory.Attribute;
import javax.naming.directory.InitialDirContext;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.InetSocketAddress;
@ -79,6 +84,7 @@ import java.net.UnknownHostException;
import java.security.Key;
import java.text.DecimalFormat;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.regex.Matcher;
@ -127,6 +133,8 @@ public class GeyserImpl implements GeyserApi {
private Metrics metrics;
private PendingMicrosoftAuthentication pendingMicrosoftAuthentication;
@Getter(AccessLevel.NONE)
private Map<String, String> savedRefreshTokens;
private static GeyserImpl instance;
@ -325,7 +333,7 @@ public class GeyserImpl implements GeyserApi {
metrics = new Metrics(this, "GeyserMC", config.getMetrics().getUniqueId(), false, java.util.logging.Logger.getLogger(""));
metrics.addCustomChart(new Metrics.SingleLineChart("players", sessionManager::size));
// Prevent unwanted words best we can
metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase()));
metrics.addCustomChart(new Metrics.SimplePie("authMode", () -> config.getRemote().getAuthType().toString().toLowerCase(Locale.ROOT)));
metrics.addCustomChart(new Metrics.SimplePie("platform", platformType::getPlatformName));
metrics.addCustomChart(new Metrics.SimplePie("defaultLocale", GeyserLocale::getDefaultLocale));
metrics.addCustomChart(new Metrics.SimplePie("version", () -> GeyserImpl.VERSION));
@ -409,6 +417,47 @@ public class GeyserImpl implements GeyserApi {
metrics = null;
}
if (config.getRemote().getAuthType() == AuthType.ONLINE) {
if (config.getUserAuths() != null && !config.getUserAuths().isEmpty()) {
getLogger().warning("The 'userAuths' config section is now deprecated, and will be removed in the near future! " +
"Please migrate to the new 'saved-user-logins' config option: " +
"https://wiki.geysermc.org/geyser/understanding-the-config/");
}
// May be written/read to on multiple threads from each GeyserSession as well as writing the config
savedRefreshTokens = new ConcurrentHashMap<>();
File tokensFile = bootstrap.getSavedUserLoginsFolder().resolve(Constants.SAVED_REFRESH_TOKEN_FILE).toFile();
if (tokensFile.exists()) {
TypeReference<Map<String, String>> type = new TypeReference<>() { };
Map<String, String> refreshTokenFile = null;
try {
refreshTokenFile = JSON_MAPPER.readValue(tokensFile, type);
} catch (IOException e) {
logger.error("Cannot load saved user tokens!", e);
}
if (refreshTokenFile != null) {
List<String> validUsers = config.getSavedUserLogins();
boolean doWrite = false;
for (Map.Entry<String, String> entry : refreshTokenFile.entrySet()) {
String user = entry.getKey();
if (!validUsers.contains(user)) {
// Perform a write to this file to purge the now-unused name
doWrite = true;
continue;
}
savedRefreshTokens.put(user, entry.getValue());
}
if (doWrite) {
scheduleRefreshTokensWrite();
}
}
}
} else {
savedRefreshTokens = null;
}
newsHandler.handleNews(null, NewsItemAction.ON_SERVER_STARTED);
}
@ -516,6 +565,39 @@ public class GeyserImpl implements GeyserApi {
return bootstrap.getWorldManager();
}
@Nullable
public String refreshTokenFor(@NonNull String bedrockName) {
return savedRefreshTokens.get(bedrockName);
}
public void saveRefreshToken(@NonNull String bedrockName, @NonNull String refreshToken) {
if (!getConfig().getSavedUserLogins().contains(bedrockName)) {
// Do not save this login
return;
}
// We can safely overwrite old instances because MsaAuthenticationService#getLoginResponseFromRefreshToken
// refreshes the token for us
if (!Objects.equals(refreshToken, savedRefreshTokens.put(bedrockName, refreshToken))) {
scheduleRefreshTokensWrite();
}
}
private void scheduleRefreshTokensWrite() {
scheduledThread.execute(() -> {
// Ensure all writes are handled on the same thread
File savedTokens = getBootstrap().getSavedUserLoginsFolder().resolve(Constants.SAVED_REFRESH_TOKEN_FILE).toFile();
TypeReference<Map<String, String>> type = new TypeReference<>() { };
try (FileWriter writer = new FileWriter(savedTokens)) {
JSON_MAPPER.writerFor(type)
.withDefaultPrettyPrinter()
.writeValue(writer, savedRefreshTokens);
} catch (IOException e) {
getLogger().error("Unable to write saved refresh tokens!", e);
}
});
}
public static GeyserImpl getInstance() {
return instance;
}

View file

@ -44,6 +44,9 @@ public interface GeyserConfiguration {
IRemoteConfiguration getRemote();
List<String> getSavedUserLogins();
@Deprecated
Map<String, ? extends IUserAuthenticationInfo> getUserAuths();
boolean isCommandSuggestions();

View file

@ -62,6 +62,9 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
private BedrockConfiguration bedrock = new BedrockConfiguration();
private RemoteConfiguration remote = new RemoteConfiguration();
@JsonProperty("saved-user-logins")
private List<String> savedUserLogins = Collections.emptyList();
@JsonProperty("floodgate-key-file")
private String floodgateKeyFile = "key.pem";

View file

@ -190,6 +190,14 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
}
private boolean couldLoginUserByName(String bedrockUsername) {
if (geyser.getConfig().getSavedUserLogins().contains(bedrockUsername)) {
String refreshToken = geyser.refreshTokenFor(bedrockUsername);
if (refreshToken != null) {
geyser.getLogger().info(GeyserLocale.getLocaleStringLog("geyser.auth.stored_credentials", session.getAuthData().name()));
session.authenticateWithRefreshToken(refreshToken);
return true;
}
}
if (geyser.getConfig().getUserAuths() != null) {
GeyserConfiguration.IUserAuthenticationInfo info = geyser.getConfig().getUserAuths().get(bedrockUsername);

View file

@ -637,7 +637,6 @@ public class GeyserSession implements GeyserConnection, CommandSender {
loggingIn = true;
// Use a future to prevent timeouts as all the authentication is handled sync
// This will be changed with the new protocol library.
CompletableFuture.supplyAsync(() -> {
try {
if (password != null && !password.isEmpty()) {
@ -694,10 +693,58 @@ public class GeyserSession implements GeyserConnection, CommandSender {
});
}
public void authenticateWithRefreshToken(String refreshToken) {
if (loggedIn) {
geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name()));
return;
}
loggingIn = true;
CompletableFuture.supplyAsync(() -> {
MsaAuthenticationService service = new MsaAuthenticationService(GeyserImpl.OAUTH_CLIENT_ID);
service.setRefreshToken(refreshToken);
try {
service.login();
} catch (RequestException e) {
geyser.getLogger().error("Error while attempting to use refresh token for " + name() + "!", e);
return Boolean.FALSE;
}
GameProfile profile = service.getSelectedProfile();
if (profile == null) {
// Java account is offline
disconnect(GeyserLocale.getPlayerLocaleString("geyser.network.remote.invalid_account", clientData.getLanguageCode()));
return null;
}
protocol = new MinecraftProtocol(profile, service.getAccessToken());
geyser.saveRefreshToken(name(), service.getRefreshToken());
return Boolean.TRUE;
}).whenComplete((successful, ex) -> {
if (this.closed) {
return;
}
if (successful == Boolean.FALSE) {
// The player is waiting for a spawn packet, so let's spawn them in now to show them forms
connect();
// Will be cached for after login
LoginEncryptionUtils.buildAndShowTokenExpiredWindow(this);
return;
}
connectDownstream();
});
}
public void authenticateWithMicrosoftCode() {
authenticateWithMicrosoftCode(false);
}
/**
* Present a form window to the user asking to log in with another web browser
*/
public void authenticateWithMicrosoftCode() {
public void authenticateWithMicrosoftCode(boolean offlineAccess) {
if (loggedIn) {
geyser.getLogger().severe(GeyserLocale.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().name()));
return;
@ -719,7 +766,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
if (task.getAuthentication().isDone()) {
onMicrosoftLoginComplete(task);
} else {
task.getCode().whenComplete((response, ex) -> {
task.getCode(offlineAccess).whenComplete((response, ex) -> {
boolean connected = !closed;
if (ex != null) {
if (connected) {
@ -735,6 +782,9 @@ public class GeyserSession implements GeyserConnection, CommandSender {
}
}
/**
* If successful, also begins connecting to the Java server.
*/
public boolean onMicrosoftLoginComplete(PendingMicrosoftAuthentication.AuthenticationTask task) {
if (closed) {
return false;
@ -745,7 +795,8 @@ public class GeyserSession implements GeyserConnection, CommandSender {
geyser.getLogger().error("Failed to log in with Microsoft code!", ex);
disconnect(ex.toString());
} else {
GameProfile selectedProfile = task.getMsaAuthenticationService().getSelectedProfile();
MsaAuthenticationService service = task.getMsaAuthenticationService();
GameProfile selectedProfile = service.getSelectedProfile();
if (selectedProfile == null) {
disconnect(GeyserLocale.getPlayerLocaleString(
"geyser.network.remote.invalid_account",
@ -754,9 +805,12 @@ public class GeyserSession implements GeyserConnection, CommandSender {
} else {
this.protocol = new MinecraftProtocol(
selectedProfile,
task.getMsaAuthenticationService().getAccessToken()
service.getAccessToken()
);
connectDownstream();
// Save our refresh token for later use
geyser.saveRefreshToken(name(), service.getRefreshToken());
return true;
}
}

View file

@ -90,8 +90,6 @@ public class PendingMicrosoftAuthentication {
@Setter
private boolean online = true;
@Getter
private final CompletableFuture<MsaAuthenticationService.MsCodeResponse> code;
@Getter
private final CompletableFuture<MsaAuthenticationService> authentication;
@ -103,11 +101,7 @@ public class PendingMicrosoftAuthentication {
this.timeoutMs = timeoutMs;
this.remainingTimeMs = timeoutMs;
// Request the code
this.code = CompletableFuture.supplyAsync(this::tryGetCode);
this.authentication = new CompletableFuture<>();
// Once the code is received, continuously try to request the access token, profile, etc
this.code.thenRun(() -> performLoginAttempt(System.currentTimeMillis()));
this.authentication.whenComplete((r, ex) -> {
this.loginException = ex;
// avoid memory leak, in case player doesn't connect again
@ -127,9 +121,20 @@ public class PendingMicrosoftAuthentication {
authentications.invalidate(userKey);
}
private MsaAuthenticationService.MsCodeResponse tryGetCode() throws CompletionException {
public CompletableFuture<MsaAuthenticationService.MsCodeResponse> getCode(boolean offlineAccess) {
// Request the code
CompletableFuture<MsaAuthenticationService.MsCodeResponse> code = CompletableFuture.supplyAsync(() -> tryGetCode(offlineAccess));
// Once the code is received, continuously try to request the access token, profile, etc
code.thenRun(() -> performLoginAttempt(System.currentTimeMillis()));
return code;
}
/**
* @param offlineAccess whether we want a refresh token for later use.
*/
private MsaAuthenticationService.MsCodeResponse tryGetCode(boolean offlineAccess) throws CompletionException {
try {
return msaAuthenticationService.getAuthCode();
return msaAuthenticationService.getAuthCode(offlineAccess);
} catch (RequestException e) {
throw new CompletionException(e);
}

View file

@ -43,7 +43,17 @@ public class BedrockSetLocalPlayerAsInitializedTranslator extends PacketTranslat
if (session.getRemoteAuthType() == AuthType.ONLINE) {
if (!session.isLoggedIn()) {
LoginEncryptionUtils.buildAndShowLoginWindow(session);
if (session.getGeyser().getConfig().getSavedUserLogins().contains(session.name())) {
if (session.getGeyser().refreshTokenFor(session.name()) == null) {
LoginEncryptionUtils.buildAndShowConsentWindow(session);
} else {
// If the refresh token is not null and we're here, then the refresh token expired
// and the expiration form has been cached
session.getFormCache().resendAllForms();
}
} else {
LoginEncryptionUtils.buildAndShowLoginWindow(session);
}
}
// else we were able to log the user in
}

View file

@ -262,6 +262,48 @@ public class LoginEncryptionUtils {
}));
}
/**
* Build a window that explains the user's credentials will be saved to the system.
*/
public static void buildAndShowConsentWindow(GeyserSession session) {
String locale = session.getLocale();
session.sendForm(
SimpleForm.builder()
.title("%gui.signIn")
.content(GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.warning", locale) +
"\n\n" +
GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.proceed", locale))
.button("%gui.ok")
.button("%gui.decline")
.responseHandler((form, responseData) -> {
SimpleFormResponse response = form.parseResponse(responseData);
if (response.isCorrect() && response.getClickedButtonId() == 0) {
session.authenticateWithMicrosoftCode(true);
} else {
session.disconnect("%disconnect.quitting");
}
}));
}
public static void buildAndShowTokenExpiredWindow(GeyserSession session) {
String locale = session.getLocale();
session.sendForm(
SimpleForm.builder()
.title(GeyserLocale.getPlayerLocaleString("geyser.auth.login.form.expired", locale))
.content(GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.expired", locale) +
"\n\n" +
GeyserLocale.getPlayerLocaleString("geyser.auth.login.save_token.proceed", locale))
.button("%gui.ok")
.responseHandler((form, responseData) -> {
SimpleFormResponse response = form.parseResponse(responseData);
if (response.isCorrect()) {
session.authenticateWithMicrosoftCode(true);
} else {
session.disconnect("%disconnect.quitting");
}
}));
}
public static void buildAndShowLoginDetailsWindow(GeyserSession session) {
session.sendForm(
CustomForm.builder()

View file

@ -66,20 +66,15 @@ remote:
# If you're using a plugin version of Floodgate on the same server, the key will automatically be picked up from Floodgate.
floodgate-key-file: key.pem
# The Xbox/Minecraft Bedrock username is the key for the Java server auth-info.
# This allows automatic configuration/login to the remote Java server.
# If you are brave enough to put your Mojang account info into a config file.
# Uncomment the lines below to enable this feature.
#userAuths:
# BedrockAccountUsername: # Your Minecraft: Bedrock Edition username
# email: javaccountemail@example.com # Your Minecraft: Java Edition email
# password: javaccountpassword123 # Your Minecraft: Java Edition password
# microsoft-account: true # Whether the account is a Mojang or Microsoft account.
#
# bluerkelp2:
# email: not_really_my_email_address_mr_minecrafter53267@gmail.com
# password: "this isn't really my password"
# microsoft-account: false
# For online mode authentication type only.
# Stores a list of Bedrock players that should have their Java Edition account saved after login.
# This saves a token that can be reused to authenticate the player later. This does not save emails or passwords,
# but you should still be cautious when adding to this list and giving others access to this Geyser instance's files.
# Removing a name from this list will delete its cached login information on the next Geyser startup.
# The file for this is in the same folder as this config, named "saved-refresh-tokens.json".
saved-user-logins:
- ThisExampleUsernameShouldBeLongEnoughToNeverBeAnXboxUsername
- ThisOtherExampleUsernameShouldAlsoBeLongEnough
# Specify how many seconds to wait while user authorizes Geyser to access their Microsoft account.
# User is allowed to disconnect from the server during this period.

@ -1 +1 @@
Subproject commit 5db9d29ece0b3d810ae42f6bdc9eeefd76e3d99d
Subproject commit c03eea033cb61ece135cd795ce04b34dd39a02f8