diff --git a/MinecraftClient/Program.cs b/MinecraftClient/Program.cs index e5ced59f..d9368a95 100644 --- a/MinecraftClient/Program.cs +++ b/MinecraftClient/Program.cs @@ -390,6 +390,8 @@ namespace MinecraftClient //Asking the user to type in missing data such as Username and Password bool useBrowser = Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.browser; + bool useDeviceCode = Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.mcc; + bool skipPassword = useBrowser || useDeviceCode; if (string.IsNullOrWhiteSpace(InternalConfig.Account.Login) && !useBrowser) { ConsoleIO.WriteLine(ConsoleIO.BasicIO ? Translations.mcc_login_basic_io : Translations.mcc_login); @@ -401,7 +403,7 @@ namespace MinecraftClient } } InternalConfig.Username = InternalConfig.Account.Login; - if (string.IsNullOrWhiteSpace(InternalConfig.Account.Password) && !useBrowser && + if (string.IsNullOrWhiteSpace(InternalConfig.Account.Password) && !skipPassword && (Config.Main.Advanced.SessionCache == CacheType.none || !SessionCache.Contains(ToLowerIfNeed(InternalConfig.Account.Login)))) { RequestPassword(); @@ -475,7 +477,7 @@ namespace MinecraftClient if (result != ProtocolHandler.LoginResult.Success && string.IsNullOrWhiteSpace(InternalConfig.Account.Password) - && !(Config.Main.General.AccountType == LoginType.microsoft && Config.Main.General.Method == LoginMethod.browser)) + && !(Config.Main.General.AccountType == LoginType.microsoft)) RequestPassword(); } else ConsoleIO.WriteLineFormatted("§8" + string.Format(Translations.mcc_session_valid, session.PlayerName)); @@ -594,7 +596,7 @@ namespace MinecraftClient } if ((Config.Main.General.AccountType == LoginType.microsoft || Config.Main.General.AccountType == LoginType.yggdrasil) - && (InternalConfig.Account.Password != "-" || Config.Main.General.Method == LoginMethod.browser) + && InternalConfig.Account.Password != "-" && Config.Signature.LoginWithSecureProfile && protocolversion >= 759 /* 1.19 and above */ && !string.IsNullOrWhiteSpace(session.ID)) diff --git a/MinecraftClient/Protocol/MicrosoftAuthentication.cs b/MinecraftClient/Protocol/MicrosoftAuthentication.cs index cdd9434c..4c84ce47 100644 --- a/MinecraftClient/Protocol/MicrosoftAuthentication.cs +++ b/MinecraftClient/Protocol/MicrosoftAuthentication.cs @@ -1,13 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Collections.Specialized; using System.Diagnostics; using System.Globalization; using System.Linq; using System.Runtime.InteropServices; -using System.Text.RegularExpressions; -using static MinecraftClient.Settings; -using static MinecraftClient.Settings.MainConfigHelper.MainConfig.GeneralConfig; +using System.Threading; namespace MinecraftClient.Protocol { @@ -16,6 +14,7 @@ namespace MinecraftClient.Protocol private static readonly string clientId = "54473e32-df8f-42e9-a649-9419b0dab9d3"; private static readonly string signinUrl = string.Format("https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id={0}&response_type=code&redirect_uri=https%3A%2F%2Fmccteam.github.io%2Fredirect.html&scope=XboxLive.signin%20offline_access%20openid%20email&prompt=select_account&response_mode=fragment", clientId); private static readonly string tokenUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + private static readonly string deviceCodeUrl = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; public static string SignInUrl { get { return signinUrl; } } @@ -53,6 +52,121 @@ namespace MinecraftClient.Protocol return RequestToken(postData); } + /// + /// Initiate the OAuth 2.0 device code flow. + /// Returns a device code response containing the user code and verification URI. + /// + /// Device code response for user to complete authentication + public static DeviceCodeResponse RequestDeviceCode() + { + string postData = string.Format("client_id={0}&scope=XboxLive.signin%20offline_access%20openid%20email", clientId); + + var request = new ProxiedWebRequest(deviceCodeUrl) + { + UserAgent = "MCC/" + Program.Version + }; + var response = request.Post("application/x-www-form-urlencoded", postData); + var jsonData = Json.ParseJson(response.Body); + + if (jsonData?["error"] is not null) + { + throw new Exception(jsonData["error_description"]!.GetStringValue()); + } + + return new DeviceCodeResponse() + { + DeviceCode = jsonData!["device_code"]!.GetStringValue(), + UserCode = jsonData["user_code"]!.GetStringValue(), + VerificationUri = jsonData["verification_uri"]!.GetStringValue(), + ExpiresIn = int.Parse(jsonData["expires_in"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture), + Interval = int.Parse(jsonData["interval"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture), + Message = jsonData["message"]!.GetStringValue() + }; + } + + /// + /// Poll the token endpoint until the user completes device code authentication. + /// Handles authorization_pending, slow_down, and expiration. + /// + /// Device code from + /// Expiration time in seconds + /// Polling interval in seconds + /// Login response with access token and refresh token + public static LoginResponse PollDeviceCodeToken(string deviceCode, int expiresIn, int interval) + { + // Per OAuth 2.0 device code spec, server may respond with "slow_down" requiring + // the client to increase its polling interval by this amount + const int SlowDownIncrementSeconds = 5; + + string postData = string.Format( + "client_id={0}&grant_type=urn:ietf:params:oauth:grant-type:device_code&device_code={1}", + clientId, deviceCode); + + var stopwatch = Stopwatch.StartNew(); + int pollInterval = interval; + + while (stopwatch.Elapsed.TotalSeconds < expiresIn) + { + Thread.Sleep(pollInterval * 1000); + + var request = new ProxiedWebRequest(tokenUrl) + { + UserAgent = "MCC/" + Program.Version + }; + var response = request.Post("application/x-www-form-urlencoded", postData); + var jsonData = Json.ParseJson(response.Body); + + if (jsonData?["error"] is not null) + { + string error = jsonData["error"]!.GetStringValue(); + + if (error == "authorization_pending") + { + // User hasn't completed auth yet, keep polling + continue; + } + else if (error == "slow_down") + { + // Server asked us to slow down + pollInterval += SlowDownIncrementSeconds; + continue; + } + else if (error == "expired_token") + { + throw new Exception("Device code expired. Please try again."); + } + else if (error == "authorization_declined") + { + throw new Exception("Authorization was declined by the user."); + } + else + { + throw new Exception(jsonData["error_description"]!.GetStringValue()); + } + } + + // Success - parse the token response + string accessToken = jsonData!["access_token"]!.GetStringValue(); + string refreshToken = jsonData["refresh_token"]!.GetStringValue(); + int tokenExpiresIn = int.Parse(jsonData["expires_in"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture); + + // Extract email from JWT id_token + string payload = JwtPayloadDecode.GetPayload(jsonData["id_token"]!.GetStringValue()); + var jsonPayload = Json.ParseJson(payload); + string email = jsonPayload!["email"]!.GetStringValue(); + + return new LoginResponse() + { + Email = email, + AccessToken = accessToken, + RefreshToken = refreshToken, + ExpiresIn = tokenExpiresIn + }; + } + + throw new Exception("Device code authentication timed out."); + } + /// /// Perform request to obtain access token by code or by refresh token /// @@ -70,13 +184,13 @@ namespace MinecraftClient.Protocol // Error handling if (jsonData?["error"] is not null) { - throw new Exception(jsonData["error_description"].GetStringValue()); + throw new Exception(jsonData["error_description"]!.GetStringValue()); } else { string accessToken = jsonData!["access_token"]!.GetStringValue(); string refreshToken = jsonData["refresh_token"]!.GetStringValue(); - int expiresIn = int.Parse(jsonData["expires_in"].GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture); + int expiresIn = int.Parse(jsonData["expires_in"]!.GetStringValue(), NumberStyles.Any, CultureInfo.CurrentCulture); // Extract email from JWT string payload = JwtPayloadDecode.GetPayload(jsonData["id_token"]!.GetStringValue()); @@ -132,132 +246,25 @@ namespace MinecraftClient.Protocol public string RefreshToken; public int ExpiresIn; } + + public struct DeviceCodeResponse + { + public string DeviceCode; + public string UserCode; + public string VerificationUri; + public int ExpiresIn; + public int Interval; + public string Message; + } } static class XboxLive { - private static readonly string authorize = "https://login.live.com/oauth20_authorize.srf?client_id=000000004C12AE6F&redirect_uri=https://login.live.com/oauth20_desktop.srf&scope=service::user.auth.xboxlive.com::MBI_SSL&display=touch&response_type=token&locale=en"; private static readonly string xbl = "https://user.auth.xboxlive.com/user/authenticate"; private static readonly string xsts = "https://xsts.auth.xboxlive.com/xsts/authorize"; private static readonly string userAgent = "Mozilla/5.0 (XboxReplay; XboxLiveAuth/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36"; - private static readonly Regex ppft = new("sFTTag:'.*value=\"(.*)\"\\/>'"); - private static readonly Regex urlPost = new("urlPost:'(.+?(?=\'))"); - private static readonly Regex confirm = new("identity\\/confirm"); - private static readonly Regex invalidAccount = new("Sign in to", RegexOptions.IgnoreCase); - private static readonly Regex twoFA = new("Help us protect your account", RegexOptions.IgnoreCase); - - public static string SignInUrl { get { return authorize; } } - - /// - /// Pre-authentication - /// - /// This step is to get the login page for later use - /// - public static PreAuthResponse PreAuth() - { - var request = new ProxiedWebRequest(authorize) - { - UserAgent = userAgent - }; - var response = request.Get(); - - string html = response.Body; - - string PPFT = ppft.Match(html).Groups[1].Value; - string urlPost = XboxLive.urlPost.Match(html).Groups[1].Value; - - if (string.IsNullOrEmpty(PPFT) || string.IsNullOrEmpty(urlPost)) - { - throw new Exception("Fail to extract PPFT or urlPost"); - } - //Console.WriteLine("PPFT: {0}", PPFT); - //Console.WriteLine(); - //Console.WriteLine("urlPost: {0}", urlPost); - - return new PreAuthResponse() - { - UrlPost = urlPost, - PPFT = PPFT, - Cookie = response.Cookies - }; - } - - /// - /// Perform login request - /// - /// This step is to send the login request by using the PreAuth response - /// Microsoft account email - /// Account password - /// - /// - public static Microsoft.LoginResponse UserLogin(string email, string password, PreAuthResponse preAuth) - { - var request = new ProxiedWebRequest(preAuth.UrlPost, preAuth.Cookie) - { - UserAgent = userAgent - }; - - string postData = "login=" + Uri.EscapeDataString(email) - + "&loginfmt=" + Uri.EscapeDataString(email) - + "&passwd=" + Uri.EscapeDataString(password) - + "&PPFT=" + Uri.EscapeDataString(preAuth.PPFT); - - var response = request.Post("application/x-www-form-urlencoded", postData); - - if (Settings.Config.Logging.DebugMessages) - { - ConsoleIO.WriteLine(response.ToString()); - } - - if (response.StatusCode >= 300 && response.StatusCode <= 399) - { - string url = response.Headers.Get("Location")!; - string hash = url.Split('#')[1]; - - var request2 = new ProxiedWebRequest(url); - var response2 = request2.Get(); - - if (response2.StatusCode != 200) - { - throw new Exception("Authentication failed"); - } - - if (string.IsNullOrEmpty(hash)) - { - throw new Exception("Cannot extract access token"); - } - var dict = Request.ParseQueryString(hash); - - //foreach (var pair in dict) - //{ - // Console.WriteLine("{0}: {1}", pair.Key, pair.Value); - //} - - return new Microsoft.LoginResponse() - { - Email = email, - AccessToken = dict["access_token"], - RefreshToken = dict["refresh_token"], - ExpiresIn = int.Parse(dict["expires_in"], NumberStyles.Any, CultureInfo.CurrentCulture) - }; - } - else - { - if (twoFA.IsMatch(response.Body)) - { - // TODO: Handle 2FA - throw new Exception("2FA enabled but not supported yet. Use browser sign-in method or try to disable 2FA in Microsoft account settings"); - } - else if (invalidAccount.IsMatch(response.Body)) - { - throw new Exception("Invalid credentials. Check your credentials"); - } - else throw new Exception("Unexpected response. Check your credentials. Response code: " + response.StatusCode); - } - } - /// /// Xbox Live Authenticate /// @@ -272,13 +279,8 @@ namespace MinecraftClient.Protocol }; request.Headers.Add("x-xbl-contract-version", "0"); - var accessToken = loginResponse.AccessToken; - if (Config.Main.General.Method == LoginMethod.browser) - { - // Our own client ID must have d= in front of the token or HTTP status 400 - // "Stolen" client ID must not have d= in front of the token or HTTP status 400 - accessToken = "d=" + accessToken; - } + // OAuth tokens from our own client ID require "d=" prefix for XBL authentication + var accessToken = "d=" + loginResponse.AccessToken; string payload = "{" + "\"Properties\": {" @@ -297,8 +299,6 @@ namespace MinecraftClient.Protocol if (response.StatusCode == 200) { string jsonString = response.Body; - //Console.WriteLine(jsonString); - var json = Json.ParseJson(jsonString); string token = json!["Token"]!.GetStringValue(); string userHash = json["DisplayClaims"]!["xui"]![0]!["uhs"]!.GetStringValue(); @@ -317,7 +317,7 @@ namespace MinecraftClient.Protocol /// /// XSTS Authenticate /// - /// (Don't ask me what is XSTS, I DONT KNOW) + /// Xbox Secure Token Service - exchanges XBL token for a service-specific XSTS token /// /// public static XSTSAuthenticateResponse XSTSAuthenticate(XblAuthenticateResponse xblResponse) @@ -378,13 +378,6 @@ namespace MinecraftClient.Protocol } } - public struct PreAuthResponse - { - public string UrlPost; - public string PPFT; - public NameValueCollection Cookie; - } - public struct XblAuthenticateResponse { public string Token; diff --git a/MinecraftClient/Protocol/ProtocolHandler.cs b/MinecraftClient/Protocol/ProtocolHandler.cs index 7d395876..9dbd3be8 100644 --- a/MinecraftClient/Protocol/ProtocolHandler.cs +++ b/MinecraftClient/Protocol/ProtocolHandler.cs @@ -776,20 +776,27 @@ namespace MinecraftClient.Protocol } /// - /// Sign-in to Microsoft Account without using browser. Only works if 2FA is disabled. - /// Might not work well in some rare cases. + /// Sign-in to Microsoft Account using OAuth 2.0 device code flow. + /// Supports accounts with 2FA enabled. /// - /// - /// + /// Email hint (unused in device code flow, kept for API compatibility) + /// Password (unused in device code flow, kept for API compatibility) /// /// private static LoginResult MicrosoftMCCLogin(string email, string password, out SessionToken session) { try { - var msaResponse = XboxLive.UserLogin(email, password, XboxLive.PreAuth()); - // Remove refresh token for MCC sign method - msaResponse.RefreshToken = string.Empty; + var deviceCode = Microsoft.RequestDeviceCode(); + + ConsoleIO.WriteLineFormatted(string.Format(Translations.mcc_device_code_prompt, deviceCode.VerificationUri, deviceCode.UserCode)); + + // Try to open the verification URL in the user's browser + Microsoft.OpenBrowser(deviceCode.VerificationUri); + + ConsoleIO.WriteLineFormatted(Translations.mcc_device_code_waiting); + + var msaResponse = Microsoft.PollDeviceCodeToken(deviceCode.DeviceCode, deviceCode.ExpiresIn, deviceCode.Interval); return MicrosoftLogin(msaResponse, out session); } catch (Exception e) @@ -801,7 +808,7 @@ namespace MinecraftClient.Protocol ConsoleIO.WriteLineFormatted("§c" + e.StackTrace); } - return LoginResult.WrongPassword; // Might not always be wrong password + return LoginResult.OtherError; } } diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs index 9ccbede3..91cd1cb1 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.Designer.cs @@ -1922,7 +1922,7 @@ namespace MinecraftClient { } /// - /// Looks up a localized string similar to Microsoft Account sign-in method: "mcc" OR "browser". If the login always fails, please try to use the "browser" once.. + /// Looks up a localized string similar to Microsoft Account sign-in method: "mcc" (device code, supports 2FA) OR "browser" (manual browser login).. /// internal static string Main_General_method { get { diff --git a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx index 9c6152df..edbfc7af 100644 --- a/MinecraftClient/Resources/ConfigComments/ConfigComments.resx +++ b/MinecraftClient/Resources/ConfigComments/ConfigComments.resx @@ -724,7 +724,7 @@ Usage examples: "/tell <mybot> connect Server1", "/connect Server2"The address of the game server, "Host" can be filled in with domain name or IP address. (The "Port" field can be deleted, it will be resolved automatically) - Microsoft Account sign-in method: "mcc" OR "browser". If the login always fails, please try to use the "browser" once. + Microsoft Account sign-in method: "mcc" (device code, supports 2FA) OR "browser" (manual browser login). Account type: "mojang" OR "microsoft" OR "yggdrasil". Also affects interactive login in console. diff --git a/MinecraftClient/Resources/Translations/Translations.Designer.cs b/MinecraftClient/Resources/Translations/Translations.Designer.cs index d1f31606..0c9f0542 100644 --- a/MinecraftClient/Resources/Translations/Translations.Designer.cs +++ b/MinecraftClient/Resources/Translations/Translations.Designer.cs @@ -5498,6 +5498,24 @@ namespace MinecraftClient { } } + /// + /// Looks up a localized string similar to To sign in, open {0} in your browser and enter the code: {1}. + /// + internal static string mcc_device_code_prompt { + get { + return ResourceManager.GetString("mcc.device_code_prompt", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Waiting for authentication to complete.... + /// + internal static string mcc_device_code_waiting { + get { + return ResourceManager.GetString("mcc.device_code_waiting", resourceCulture); + } + } + /// /// Looks up a localized string similar to Login failed :. /// diff --git a/MinecraftClient/Resources/Translations/Translations.resx b/MinecraftClient/Resources/Translations/Translations.resx index b16e7620..a3e49c9e 100644 --- a/MinecraftClient/Resources/Translations/Translations.resx +++ b/MinecraftClient/Resources/Translations/Translations.resx @@ -1851,6 +1851,12 @@ You can use "/chunk status {0:0.0} {1:0.0} {2:0.0}" to check the chunk loading s Connecting to {0}... + + To sign in, open {0} in your browser and enter the code: §e{1} + + + Waiting for authentication to complete... + Login failed : diff --git a/docs/guide/README.md b/docs/guide/README.md index c1909fcf..e2d179b3 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -39,6 +39,7 @@ It was originally made by [ORelio](https://github.com/ORelio) in 2012 on the [Mi - [Get alerted on certain keywords](chat-bots.md#alerts) - [Auto Respond](chat-bots.md#auto-respond) +- Microsoft account authentication with 2FA support (OAuth 2.0 device code flow) - [Anti AFK](chat-bots.md#anti-afk) - [Auto Relog](chat-bots.md#auto-relog) - [Script Scheduler](chat-bots.md#script-scheduler) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 1876bbaa..2e0d3235 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -67,17 +67,33 @@ Coordinate = { x = 145, y = 64, z = 2045 } - **Description:** - This setting is where you need to provide your in-game name (for offline accounts) or email for Microsoft accounts (Mojang accounts do not work anymore) and your password (if using an offline account, use `-` for the password). + This setting is where you provide your account login information. + + For **Microsoft accounts**, set `Login` to your Microsoft email. You do not need to provide a password because MCC uses the OAuth 2.0 device code flow for authentication (you sign in through your browser, with full 2FA support). + + For **offline accounts**, set `Login` to your desired in-game name and `Password` to `-`. + + For **Yggdrasil accounts**, set `Login` and `Password` to the credentials for your authlib server. - **Format:** - `Account = { Login = "", Password = "" }` + `Account = { Login = "" }` - **Type:** `inline table` -- **Example:** +- **Examples:** - `Account = { Login = "some.random.player@gmail.com", Password = "myEpicPassword123" }` + Microsoft account (password not needed): + + ``` + Account = { Login = "player@example.com" } + ``` + + Offline account: + + ``` + Account = { Login = "Steve", Password = "-" } + ``` #### `Server` @@ -131,7 +147,7 @@ Coordinate = { x = 145, y = 64, z = 2045 } - **Description:** - This setting is where you define the way you will sign in with your Microsoft account, available options are `mcc` and `browser`. + This setting is where you define the way you will sign in with your Microsoft account, available options are `mcc` and `browser`. The `mcc` method uses the OAuth 2.0 device code flow: MCC will display a code and a URL, and you complete the sign-in (including 2FA) in your browser. The `browser` method opens a sign-in page in your browser and you paste the resulting code back into MCC. - **Type:** `string` diff --git a/docs/guide/usage.md b/docs/guide/usage.md index c23017c5..8c1d4077 100644 --- a/docs/guide/usage.md +++ b/docs/guide/usage.md @@ -121,11 +121,20 @@ MinecraftClient.exe --section.setting=value [--other settings] MinecraftClient.exe [--other settings] ``` +

Tip

+ +**Microsoft accounts use the OAuth 2.0 device code flow and do not require a password on the command line. MCC will display a code and a URL for you to sign in through your browser (with full 2FA support). You can simply omit the password or use `""` as a placeholder.** + +
+ Examples: ```bash -# Logging in as a user: notch, with a password: password123 onto a server with the ip: mc.someserver.com:25565 -MinecraftClient.exe notch password123 mc.someserver.com:25565 +# Microsoft account: connect to a server (you will sign in via device code in your browser) +MinecraftClient.exe player@example.com "" mc.someserver.com:25565 + +# Offline account: connect with a chosen username +MinecraftClient.exe Steve - mc.someserver.com:25565 # Overriding a setting from MinecraftClient.ini using a command-line parameter MinecraftClient.exe --debugmessages=false @@ -150,7 +159,7 @@ MinecraftClient.exe - This will automatically connect you to the chosen server. - You may omit password and/or server to specify e.g. only the login -- To specify a server but ask password interactively, use `""` as password. +- For Microsoft accounts, password is not required (device code flow is used). Use `""` as a placeholder if you need to specify a server. - To specify offline mode with no password, use `-` as password. ```bash @@ -165,7 +174,8 @@ MinecraftClient.exe ``` - This will load the specified configuration file -- If the file contains login / password / server ip, it will automatically connect. +- If the file contains login / server ip, it will automatically connect. +- For Microsoft accounts, authentication happens through the device code flow (no password needed in the file). ```bash MinecraftClient.exe --setting=value [--other settings]