Mirror von
https://github.com/GeyserMC/Geyser.git
synchronisiert 2024-11-03 14:50:19 +01:00
Microsoft account authentication (#1808)
Microsoft accounts can now use Geyser, while maintaining full backwards compatibility with Mojang accounts. Co-authored-by: Camotoy <20743703+Camotoy@users.noreply.github.com>
Dieser Commit ist enthalten in:
Ursprung
6aa74a2322
Commit
1c0cc4622a
@ -132,6 +132,10 @@
|
||||
<groupId>com.github.steveice10</groupId>
|
||||
<artifactId>packetlib</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.github.steveice10</groupId>
|
||||
<artifactId>mcauthlib</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
@ -198,6 +202,11 @@
|
||||
<version>4.13.1</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.GeyserMC</groupId>
|
||||
<artifactId>MCAuthLib</artifactId>
|
||||
<version>0e48a094f2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
@ -86,6 +86,11 @@ public class GeyserConnector {
|
||||
public static final String GIT_VERSION = "DEV"; // A fallback for running in IDEs
|
||||
public static final String VERSION = "DEV"; // A fallback for running in IDEs
|
||||
|
||||
/**
|
||||
* Oauth client ID for Microsoft authentication
|
||||
*/
|
||||
public static final String OAUTH_CLIENT_ID = "204cefd1-4818-4de1-b98d-513fae875d88";
|
||||
|
||||
private static final String IP_REGEX = "\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\b";
|
||||
|
||||
private final List<GeyserSession> players = new ArrayList<>();
|
||||
@ -101,8 +106,8 @@ public class GeyserConnector {
|
||||
private final ScheduledExecutorService generalThreadPool;
|
||||
|
||||
private BedrockServer bedrockServer;
|
||||
private PlatformType platformType;
|
||||
private GeyserBootstrap bootstrap;
|
||||
private final PlatformType platformType;
|
||||
private final GeyserBootstrap bootstrap;
|
||||
|
||||
private Metrics metrics;
|
||||
|
||||
|
@ -118,6 +118,8 @@ public interface GeyserConfiguration {
|
||||
|
||||
String getAuthType();
|
||||
|
||||
boolean isPasswordAuthentication();
|
||||
|
||||
boolean isUseProxyProtocol();
|
||||
}
|
||||
|
||||
@ -125,6 +127,12 @@ public interface GeyserConfiguration {
|
||||
String getEmail();
|
||||
|
||||
String getPassword();
|
||||
|
||||
/**
|
||||
* Will be removed after Microsoft accounts are fully migrated
|
||||
*/
|
||||
@Deprecated
|
||||
boolean isMicrosoftAccount();
|
||||
}
|
||||
|
||||
interface IMetricsInfo {
|
||||
|
@ -149,17 +149,24 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
|
||||
@JsonProperty("auth-type")
|
||||
private String authType = "online";
|
||||
|
||||
@JsonProperty("allow-password-authentication")
|
||||
private boolean passwordAuthentication = true;
|
||||
|
||||
@JsonProperty("use-proxy-protocol")
|
||||
private boolean useProxyProtocol = false;
|
||||
}
|
||||
|
||||
@Getter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true) // DO NOT REMOVE THIS! Otherwise, after we remove microsoft-account configs will not load
|
||||
public static class UserAuthenticationInfo implements IUserAuthenticationInfo {
|
||||
@AsteriskSerializer.Asterisk()
|
||||
private String email;
|
||||
|
||||
@AsteriskSerializer.Asterisk()
|
||||
private String password;
|
||||
|
||||
@JsonProperty("microsoft-account")
|
||||
private boolean microsoftAccount = false;
|
||||
}
|
||||
|
||||
@Getter
|
||||
|
@ -161,6 +161,7 @@ public class UpstreamPacketHandler extends LoggingPacketHandler {
|
||||
|
||||
if (info != null) {
|
||||
connector.getLogger().info(LanguageUtils.getLocaleStringLog("geyser.auth.stored_credentials", session.getAuthData().getName()));
|
||||
session.setMicrosoftAccount(info.isMicrosoftAccount());
|
||||
session.authenticate(info.getEmail(), info.getPassword());
|
||||
|
||||
// TODO send a message to bedrock user telling them they are connected (if nothing like a motd
|
||||
|
@ -26,8 +26,12 @@
|
||||
package org.geysermc.connector.network.session;
|
||||
|
||||
import com.github.steveice10.mc.auth.data.GameProfile;
|
||||
import com.github.steveice10.mc.auth.exception.request.AuthPendingException;
|
||||
import com.github.steveice10.mc.auth.exception.request.InvalidCredentialsException;
|
||||
import com.github.steveice10.mc.auth.exception.request.RequestException;
|
||||
import com.github.steveice10.mc.auth.service.AuthenticationService;
|
||||
import com.github.steveice10.mc.auth.service.MojangAuthenticationService;
|
||||
import com.github.steveice10.mc.auth.service.MsaAuthenticationService;
|
||||
import com.github.steveice10.mc.protocol.MinecraftConstants;
|
||||
import com.github.steveice10.mc.protocol.MinecraftProtocol;
|
||||
import com.github.steveice10.mc.protocol.data.SubProtocol;
|
||||
@ -110,6 +114,10 @@ public class GeyserSession implements CommandSender {
|
||||
@Setter
|
||||
private BedrockClientData clientData;
|
||||
|
||||
@Deprecated
|
||||
@Setter
|
||||
private boolean microsoftAccount;
|
||||
|
||||
private final SessionPlayerEntity playerEntity;
|
||||
private PlayerInventory inventory;
|
||||
|
||||
@ -257,7 +265,6 @@ public class GeyserSession implements CommandSender {
|
||||
/**
|
||||
* Controls whether the daylight cycle gamerule has been sent to the client, so the sun/moon remain motionless.
|
||||
*/
|
||||
@Setter
|
||||
private boolean daylightCycle = true;
|
||||
|
||||
private boolean reducedDebugInfo = false;
|
||||
@ -443,11 +450,91 @@ public class GeyserSession implements CommandSender {
|
||||
new Thread(() -> {
|
||||
try {
|
||||
if (password != null && !password.isEmpty()) {
|
||||
protocol = new MinecraftProtocol(username, password);
|
||||
AuthenticationService authenticationService;
|
||||
if (microsoftAccount) {
|
||||
authenticationService = new MsaAuthenticationService(GeyserConnector.OAUTH_CLIENT_ID);
|
||||
} else {
|
||||
authenticationService = new MojangAuthenticationService();
|
||||
}
|
||||
authenticationService.setUsername(username);
|
||||
authenticationService.setPassword(password);
|
||||
authenticationService.login();
|
||||
|
||||
protocol = new MinecraftProtocol(authenticationService);
|
||||
} else {
|
||||
protocol = new MinecraftProtocol(username);
|
||||
}
|
||||
|
||||
connectDownstream();
|
||||
} catch (InvalidCredentialsException | IllegalArgumentException e) {
|
||||
connector.getLogger().info(LanguageUtils.getLocaleStringLog("geyser.auth.login.invalid", username));
|
||||
disconnect(LanguageUtils.getPlayerLocaleString("geyser.auth.login.invalid.kick", getClientData().getLanguageCode()));
|
||||
} catch (RequestException ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Present a form window to the user asking to log in with another web browser
|
||||
*/
|
||||
public void authenticateWithMicrosoftCode() {
|
||||
if (loggedIn) {
|
||||
connector.getLogger().severe(LanguageUtils.getLocaleStringLog("geyser.auth.already_loggedin", getAuthData().getName()));
|
||||
return;
|
||||
}
|
||||
|
||||
loggingIn = true;
|
||||
// new thread so clients don't timeout
|
||||
new Thread(() -> {
|
||||
try {
|
||||
MsaAuthenticationService msaAuthenticationService = new MsaAuthenticationService(GeyserConnector.OAUTH_CLIENT_ID);
|
||||
|
||||
MsaAuthenticationService.MsCodeResponse response = msaAuthenticationService.getAuthCode();
|
||||
LoginEncryptionUtils.showMicrosoftCodeWindow(this, response);
|
||||
|
||||
// This just looks cool
|
||||
SetTimePacket packet = new SetTimePacket();
|
||||
packet.setTime(16000);
|
||||
sendUpstreamPacket(packet);
|
||||
|
||||
// Wait for the code to validate
|
||||
attemptCodeAuthentication(msaAuthenticationService);
|
||||
} catch (InvalidCredentialsException | IllegalArgumentException e) {
|
||||
connector.getLogger().info(LanguageUtils.getLocaleStringLog("geyser.auth.login.invalid", getAuthData().getName()));
|
||||
disconnect(LanguageUtils.getPlayerLocaleString("geyser.auth.login.invalid.kick", getClientData().getLanguageCode()));
|
||||
} catch (RequestException ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll every second to see if the user has successfully signed in
|
||||
*/
|
||||
private void attemptCodeAuthentication(MsaAuthenticationService msaAuthenticationService) {
|
||||
if (loggedIn || closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
msaAuthenticationService.login();
|
||||
protocol = new MinecraftProtocol(msaAuthenticationService);
|
||||
|
||||
connectDownstream();
|
||||
} catch (RequestException e) {
|
||||
if (!(e instanceof AuthPendingException)) {
|
||||
e.printStackTrace();
|
||||
} else {
|
||||
// Wait one second before trying again
|
||||
connector.getGeneralThreadPool().schedule(() -> attemptCodeAuthentication(msaAuthenticationService), 1, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After getting whatever credentials needed, we attempt to join the Java server.
|
||||
*/
|
||||
private void connectDownstream() {
|
||||
boolean floodgate = connector.getAuthType() == AuthType.FLOODGATE;
|
||||
final PublicKey publicKey;
|
||||
|
||||
@ -574,15 +661,11 @@ public class GeyserSession implements CommandSender {
|
||||
}
|
||||
});
|
||||
|
||||
if (!daylightCycle) {
|
||||
setDaylightCycle(true);
|
||||
}
|
||||
downstream.getSession().connect();
|
||||
connector.addPlayer(this);
|
||||
} catch (InvalidCredentialsException | IllegalArgumentException e) {
|
||||
connector.getLogger().info(LanguageUtils.getLocaleStringLog("geyser.auth.login.invalid", username));
|
||||
disconnect(LanguageUtils.getPlayerLocaleString("geyser.auth.login.invalid.kick", getClientData().getLanguageCode()));
|
||||
} catch (RequestException ex) {
|
||||
ex.printStackTrace();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
public void disconnect(String reason) {
|
||||
@ -872,6 +955,18 @@ public class GeyserSession implements CommandSender {
|
||||
reducedDebugInfo = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the daylight cycle gamerule on the client
|
||||
* This is used in the login screen along-side normal usage
|
||||
*
|
||||
* @param doCycle If the cycle should continue
|
||||
*/
|
||||
public void setDaylightCycle(boolean doCycle) {
|
||||
sendGameRule("dodaylightcycle", doCycle);
|
||||
// Save the value so we don't have to constantly send a daylight cycle gamerule update
|
||||
this.daylightCycle = doCycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a gamerule value to the client
|
||||
*
|
||||
|
@ -81,7 +81,7 @@ public class JavaPlayerPositionRotationTranslator extends PacketTranslator<Serve
|
||||
|
||||
ChunkUtils.updateChunkPosition(session, pos.toInt());
|
||||
|
||||
session.getConnector().getLogger().info(LanguageUtils.getLocaleStringLog("geyser.entity.player.spawn", packet.getX(), packet.getY(), packet.getZ()));
|
||||
session.getConnector().getLogger().debug(LanguageUtils.getLocaleStringLog("geyser.entity.player.spawn", packet.getX(), packet.getY(), packet.getZ()));
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -46,17 +46,10 @@ public class JavaUpdateTimeTranslator extends PacketTranslator<ServerUpdateTimeP
|
||||
session.sendUpstreamPacket(setTimePacket);
|
||||
if (!session.isDaylightCycle() && time >= 0) {
|
||||
// Client thinks there is no daylight cycle but there is
|
||||
setDoDaylightCycleGamerule(session, true);
|
||||
session.setDaylightCycle(true);
|
||||
} else if (session.isDaylightCycle() && time < 0) {
|
||||
// Client thinks there is daylight cycle but there isn't
|
||||
setDoDaylightCycleGamerule(session, false);
|
||||
session.setDaylightCycle(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void setDoDaylightCycleGamerule(GeyserSession session, boolean doCycle) {
|
||||
session.sendGameRule("dodaylightcycle", doCycle);
|
||||
// Save the value so we don't have to constantly send a daylight cycle gamerule update
|
||||
session.setDaylightCycle(doCycle);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -29,19 +29,18 @@ import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeType;
|
||||
import com.github.steveice10.mc.auth.service.MsaAuthenticationService;
|
||||
import com.nimbusds.jose.JWSObject;
|
||||
import com.nukkitx.network.util.Preconditions;
|
||||
import com.nukkitx.protocol.bedrock.packet.LoginPacket;
|
||||
import com.nukkitx.protocol.bedrock.packet.ServerToClientHandshakePacket;
|
||||
import com.nukkitx.protocol.bedrock.util.EncryptionUtils;
|
||||
import org.geysermc.common.window.CustomFormBuilder;
|
||||
import org.geysermc.common.window.CustomFormWindow;
|
||||
import org.geysermc.common.window.FormWindow;
|
||||
import org.geysermc.common.window.SimpleFormWindow;
|
||||
import org.geysermc.common.window.*;
|
||||
import org.geysermc.common.window.button.FormButton;
|
||||
import org.geysermc.common.window.component.InputComponent;
|
||||
import org.geysermc.common.window.component.LabelComponent;
|
||||
import org.geysermc.common.window.response.CustomFormResponse;
|
||||
import org.geysermc.common.window.response.ModalFormResponse;
|
||||
import org.geysermc.common.window.response.SimpleFormResponse;
|
||||
import org.geysermc.connector.GeyserConnector;
|
||||
import org.geysermc.connector.network.session.GeyserSession;
|
||||
@ -156,13 +155,21 @@ public class LoginEncryptionUtils {
|
||||
session.sendUpstreamPacketImmediately(packet);
|
||||
}
|
||||
|
||||
private static int AUTH_FORM_ID = 1336;
|
||||
private static int AUTH_DETAILS_FORM_ID = 1337;
|
||||
private static final int AUTH_MSA_DETAILS_FORM_ID = 1334;
|
||||
private static final int AUTH_MSA_CODE_FORM_ID = 1335;
|
||||
private static final int AUTH_FORM_ID = 1336;
|
||||
private static final int AUTH_DETAILS_FORM_ID = 1337;
|
||||
|
||||
public static void showLoginWindow(GeyserSession session) {
|
||||
// Set DoDaylightCycle to false so the time doesn't accelerate while we're here
|
||||
session.setDaylightCycle(false);
|
||||
|
||||
String userLanguage = session.getLocale();
|
||||
SimpleFormWindow window = new SimpleFormWindow(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.title", userLanguage), LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.desc", userLanguage));
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.btn_login", userLanguage)));
|
||||
if (session.getConnector().getConfig().getRemote().isPasswordAuthentication()) {
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.btn_login.mojang", userLanguage)));
|
||||
}
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.btn_login.microsoft", userLanguage)));
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.btn_disconnect", userLanguage)));
|
||||
|
||||
session.sendForm(window, AUTH_FORM_ID);
|
||||
@ -179,12 +186,33 @@ public class LoginEncryptionUtils {
|
||||
session.sendForm(window, AUTH_DETAILS_FORM_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user between either OAuth code login or manual password authentication
|
||||
*/
|
||||
public static void showMicrosoftAuthenticationWindow(GeyserSession session) {
|
||||
String userLanguage = session.getLocale();
|
||||
SimpleFormWindow window = new SimpleFormWindow(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.btn_login.microsoft", userLanguage), "");
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.method.browser", userLanguage)));
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.method.password", userLanguage))); // This form won't show if password authentication is disabled
|
||||
window.getButtons().add(new FormButton(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.notice.btn_disconnect", userLanguage)));
|
||||
session.sendForm(window, AUTH_MSA_DETAILS_FORM_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the code that a user must input into their browser
|
||||
*/
|
||||
public static void showMicrosoftCodeWindow(GeyserSession session, MsaAuthenticationService.MsCodeResponse response) {
|
||||
ModalFormWindow msaCodeWindow = new ModalFormWindow("%xbox.signin", "%xbox.signin.website\n%xbox.signin.url\n%xbox.signin.enterCode\n" +
|
||||
response.user_code, "Done", "%menu.disconnect");
|
||||
session.sendForm(msaCodeWindow, LoginEncryptionUtils.AUTH_MSA_CODE_FORM_ID);
|
||||
}
|
||||
|
||||
public static boolean authenticateFromForm(GeyserSession session, GeyserConnector connector, int formId, String formData) {
|
||||
WindowCache windowCache = session.getWindowCache();
|
||||
if (!windowCache.getWindows().containsKey(formId))
|
||||
return false;
|
||||
|
||||
if(formId == AUTH_FORM_ID || formId == AUTH_DETAILS_FORM_ID) {
|
||||
if (formId == AUTH_MSA_DETAILS_FORM_ID || formId == AUTH_FORM_ID || formId == AUTH_DETAILS_FORM_ID || formId == AUTH_MSA_CODE_FORM_ID) {
|
||||
FormWindow window = windowCache.getWindows().remove(formId);
|
||||
window.setResponse(formData.trim());
|
||||
|
||||
@ -205,16 +233,50 @@ public class LoginEncryptionUtils {
|
||||
showLoginDetailsWindow(session);
|
||||
}
|
||||
} else if (formId == AUTH_FORM_ID && window instanceof SimpleFormWindow) {
|
||||
boolean isPasswordAuthentication = session.getConnector().getConfig().getRemote().isPasswordAuthentication();
|
||||
int microsoftButton = isPasswordAuthentication ? 1 : 0;
|
||||
int disconnectButton = isPasswordAuthentication ? 2 : 1;
|
||||
SimpleFormResponse response = (SimpleFormResponse) window.getResponse();
|
||||
if (response != null) {
|
||||
if (response.getClickedButtonId() == 0) {
|
||||
if (isPasswordAuthentication && response.getClickedButtonId() == 0) {
|
||||
session.setMicrosoftAccount(false);
|
||||
showLoginDetailsWindow(session);
|
||||
} else if(response.getClickedButtonId() == 1) {
|
||||
} else if (response.getClickedButtonId() == microsoftButton) {
|
||||
session.setMicrosoftAccount(true);
|
||||
if (isPasswordAuthentication) {
|
||||
showMicrosoftAuthenticationWindow(session);
|
||||
} else {
|
||||
// Just show the OAuth code
|
||||
session.authenticateWithMicrosoftCode();
|
||||
}
|
||||
} else if (response.getClickedButtonId() == disconnectButton) {
|
||||
session.disconnect(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.getLocale()));
|
||||
}
|
||||
} else {
|
||||
showLoginWindow(session);
|
||||
}
|
||||
} else if (formId == AUTH_MSA_DETAILS_FORM_ID && window instanceof SimpleFormWindow) {
|
||||
SimpleFormResponse response = (SimpleFormResponse) window.getResponse();
|
||||
if (response != null) {
|
||||
if (response.getClickedButtonId() == 0) {
|
||||
session.authenticateWithMicrosoftCode();
|
||||
} else if (response.getClickedButtonId() == 1) {
|
||||
showLoginDetailsWindow(session);
|
||||
} else if (response.getClickedButtonId() == 2) {
|
||||
session.disconnect(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.getLocale()));
|
||||
}
|
||||
} else {
|
||||
showLoginWindow(session);
|
||||
}
|
||||
} else if (formId == AUTH_MSA_CODE_FORM_ID && window instanceof ModalFormWindow) {
|
||||
ModalFormResponse response = (ModalFormResponse) window.getResponse();
|
||||
if (response != null) {
|
||||
if (response.getClickedButtonId() == 1) {
|
||||
session.disconnect(LanguageUtils.getPlayerLocaleString("geyser.auth.login.form.disconnect", session.getLocale()));
|
||||
}
|
||||
} else {
|
||||
showMicrosoftAuthenticationWindow(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,9 @@ remote:
|
||||
port: 25565
|
||||
# Authentication type. Can be offline, online, or floodgate (see https://github.com/GeyserMC/Geyser/wiki/Floodgate).
|
||||
auth-type: online
|
||||
# Allow for password-based authentication methods through Geyser. Only useful in online mode.
|
||||
# If this is false, users must authenticate to Microsoft using a code provided by Geyser on their desktop.
|
||||
allow-password-authentication: true
|
||||
# Whether to enable PROXY protocol or not while connecting to the server.
|
||||
# This is useful only when:
|
||||
# 1) Your server supports PROXY protocol (it probably doesn't)
|
||||
@ -52,10 +55,12 @@ floodgate-key-file: public-key.pem
|
||||
# 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
|
||||
|
||||
# Bedrock clients can freeze when opening up the command prompt for the first time if given a lot of commands.
|
||||
# Disabling this will prevent command suggestions from being sent and solve freezing for Bedrock clients.
|
||||
|
@ -1 +1 @@
|
||||
Subproject commit 1a00766840baf1f512d98f5a75c177c8bcfba6f3
|
||||
Subproject commit 6f246c24ddbd543a359d651e706da470fe53ceeb
|
Laden…
In neuem Issue referenzieren
Einen Benutzer sperren