Implement Microsoft account login (#1397)

* Implement Microsoft account login
* Create proxied web request class
* Whole bunch of code that doesn't work
* I finally FIXED IT
It took me 2 hours to resolve the problem
* Fill the missed method summary
* Remove some unused code
* Revert http version
* Remove JSON parsing bug workaround
Not needed anymore as per e06438b582
* Remove comment asking about clientID
Client ID is used for session token refreshes. Random UUID without hyphens
Co-authored-by: ORelio <ORelio@users.noreply.github.com>
This commit is contained in:
ReinforceZwei 2021-01-07 04:14:51 +08:00 committed by GitHub
parent 479f80ccf1
commit c04c597aba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 690 additions and 4 deletions

View file

@ -214,6 +214,8 @@
<Compile Include="Protocol\DataTypeGenerator.cs" />
<Compile Include="FileMonitor.cs" />
<Compile Include="Inventory\VillagerTrade.cs" />
<Compile Include="Protocol\MicrosoftAuthentication.cs" />
<Compile Include="Protocol\ProxiedWebRequest.cs" />
<Compile Include="Protocol\ReplayHandler.cs" />
<Compile Include="Translations.cs" />
<Compile Include="Inventory\VillagerInfo.cs" />

View file

@ -201,8 +201,8 @@ namespace MinecraftClient
if (result != ProtocolHandler.LoginResult.Success)
{
Translations.WriteLine("mcc.connecting");
result = ProtocolHandler.GetLogin(Settings.Login, Settings.Password, out session);
Translations.WriteLine("mcc.connecting", Settings.AccountType == ProtocolHandler.AccountType.Mojang ? "Minecraft.net" : "Microsoft");
result = ProtocolHandler.GetLogin(Settings.Login, Settings.Password, Settings.AccountType, out session);
if (result == ProtocolHandler.LoginResult.Success && Settings.SessionCaching != CacheType.None)
{

View file

@ -0,0 +1,345 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Collections.Specialized;
namespace MinecraftClient.Protocol
{
class XboxLive
{
private 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 readonly string xbl = "https://user.auth.xboxlive.com/user/authenticate";
private readonly string xsts = "https://xsts.auth.xboxlive.com/xsts/authorize";
private 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 Regex ppft = new Regex("sFTTag:'.*value=\"(.*)\"\\/>'");
private Regex urlPost = new Regex("urlPost:'(.+?(?=\'))");
private Regex confirm = new Regex("identity\\/confirm");
/// <summary>
/// Pre-authentication
/// </summary>
/// <remarks>This step is to get the login page for later use</remarks>
/// <returns></returns>
public PreAuthResponse PreAuth()
{
var request = new ProxiedWebRequest(authorize);
request.UserAgent = userAgent;
var response = request.Get();
string html = response.Body;
string PPFT = ppft.Match(html).Groups[1].Value;
string urlPost = this.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
};
}
/// <summary>
/// Perform login request
/// </summary>
/// <remarks>This step is to send the login request by using the PreAuth response</remarks>
/// <param name="email">Microsoft account email</param>
/// <param name="password">Account password</param>
/// <param name="preAuth"></param>
/// <returns></returns>
public UserLoginResponse UserLogin(string email, string password, PreAuthResponse preAuth)
{
var request = new ProxiedWebRequest(preAuth.UrlPost, preAuth.Cookie);
request.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.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))
{
if (confirm.IsMatch(response2.Body))
{
throw new Exception("Activity confirmation required");
}
else throw new Exception("Invalid credentials or 2FA enabled");
}
var dict = Request.ParseQueryString(hash);
//foreach (var pair in dict)
//{
// Console.WriteLine("{0}: {1}", pair.Key, pair.Value);
//}
return new UserLoginResponse()
{
AccessToken = dict["access_token"],
RefreshToken = dict["refresh_token"],
ExpiresIn = int.Parse(dict["expires_in"])
};
}
else
{
throw new Exception("Unexpected response. Check your credentials. Response code: " + response.StatusCode);
}
}
/// <summary>
/// Xbox Live Authenticate
/// </summary>
/// <param name="loginResponse"></param>
/// <returns></returns>
public XblAuthenticateResponse XblAuthenticate(UserLoginResponse loginResponse)
{
var request = new ProxiedWebRequest(xbl);
request.UserAgent = userAgent;
request.Accept = "application/json";
request.Headers.Add("x-xbl-contract-version", "0");
string payload = "{"
+ "\"Properties\": {"
+ "\"AuthMethod\": \"RPS\","
+ "\"SiteName\": \"user.auth.xboxlive.com\","
+ "\"RpsTicket\": \"" + loginResponse.AccessToken + "\""
+ "},"
+ "\"RelyingParty\": \"http://auth.xboxlive.com\","
+ "\"TokenType\": \"JWT\""
+ "}";
var response = request.Post("application/json", payload);
if (Settings.DebugMessages)
{
ConsoleIO.WriteLine(response.ToString());
}
if (response.StatusCode == 200)
{
string jsonString = response.Body;
//Console.WriteLine(jsonString);
Json.JSONData json = Json.ParseJson(jsonString);
string token = json.Properties["Token"].StringValue;
string userHash = json.Properties["DisplayClaims"].Properties["xui"].DataArray[0].Properties["uhs"].StringValue;
return new XblAuthenticateResponse()
{
Token = token,
UserHash = userHash
};
}
else
{
throw new Exception("XBL Authentication failed");
}
}
/// <summary>
/// XSTS Authenticate
/// </summary>
/// <remarks>(Don't ask me what is XSTS, I DONT KNOW)</remarks>
/// <param name="xblResponse"></param>
/// <returns></returns>
public XSTSAuthenticateResponse XSTSAuthenticate(XblAuthenticateResponse xblResponse)
{
var request = new ProxiedWebRequest(xsts);
request.UserAgent = userAgent;
request.Accept = "application/json";
request.Headers.Add("x-xbl-contract-version", "1");
string payload = "{"
+ "\"Properties\": {"
+ "\"SandboxId\": \"RETAIL\","
+ "\"UserTokens\": ["
+ "\"" + xblResponse.Token + "\""
+ "]"
+ "},"
+ "\"RelyingParty\": \"rp://api.minecraftservices.com/\","
+ "\"TokenType\": \"JWT\""
+ "}";
var response = request.Post("application/json", payload);
if (Settings.DebugMessages)
{
ConsoleIO.WriteLine(response.ToString());
}
if (response.StatusCode == 200)
{
string jsonString = response.Body;
Json.JSONData json = Json.ParseJson(jsonString);
string token = json.Properties["Token"].StringValue;
string userHash = json.Properties["DisplayClaims"].Properties["xui"].DataArray[0].Properties["uhs"].StringValue;
return new XSTSAuthenticateResponse()
{
Token = token,
UserHash = userHash
};
}
else
{
if (response.StatusCode == 401)
{
Json.JSONData json = Json.ParseJson(response.Body);
if (json.Properties["XErr"].StringValue == "2148916233")
{
throw new Exception("The account doesn't have an Xbox account");
}
else if (json.Properties["XErr"].StringValue == "2148916238")
{
throw new Exception("The account is a child (under 18) and cannot proceed unless the account is added to a Family by an adult");
}
else throw new Exception("Unknown XSTS error code: " + json.Properties["XErr"].StringValue);
}
else
{
throw new Exception("XSTS Authentication failed");
}
}
}
public struct PreAuthResponse
{
public string UrlPost;
public string PPFT;
public NameValueCollection Cookie;
}
public struct UserLoginResponse
{
public string AccessToken;
public string RefreshToken;
public int ExpiresIn;
}
public struct XblAuthenticateResponse
{
public string Token;
public string UserHash;
}
public struct XSTSAuthenticateResponse
{
public string Token;
public string UserHash;
}
}
class MinecraftWithXbox
{
private readonly string loginWithXbox = "https://api.minecraftservices.com/authentication/login_with_xbox";
private readonly string ownership = "https://api.minecraftservices.com/entitlements/mcstore";
private readonly string profile = "https://api.minecraftservices.com/minecraft/profile";
/// <summary>
/// Login to Minecraft using the XSTS token and user hash obtained before
/// </summary>
/// <param name="userHash"></param>
/// <param name="xstsToken"></param>
/// <returns></returns>
public string LoginWithXbox(string userHash, string xstsToken)
{
var request = new ProxiedWebRequest(loginWithXbox);
request.Accept = "application/json";
string payload = "{\"identityToken\": \"XBL3.0 x=" + userHash + ";" + xstsToken + "\"}";
var response = request.Post("application/json", payload);
if (Settings.DebugMessages)
{
ConsoleIO.WriteLine(response.ToString());
}
string jsonString = response.Body;
Json.JSONData json = Json.ParseJson(jsonString);
return json.Properties["access_token"].StringValue;
}
/// <summary>
/// Check if user own Minecraft by access token
/// </summary>
/// <param name="accessToken"></param>
/// <returns>True if the user own the game</returns>
public bool UserHasGame(string accessToken)
{
var request = new ProxiedWebRequest(ownership);
request.Headers.Add("Authorization", string.Format("Bearer {0}", accessToken));
var response = request.Get();
if (Settings.DebugMessages)
{
ConsoleIO.WriteLine(response.ToString());
}
string jsonString = response.Body;
Json.JSONData json = Json.ParseJson(jsonString);
return json.Properties["items"].DataArray.Count > 0;
}
public UserProfile GetUserProfile(string accessToken)
{
var request = new ProxiedWebRequest(profile);
request.Headers.Add("Authorization", string.Format("Bearer {0}", accessToken));
var response = request.Get();
if (Settings.DebugMessages)
{
ConsoleIO.WriteLine(response.ToString());
}
string jsonString = response.Body;
Json.JSONData json = Json.ParseJson(jsonString);
return new UserProfile()
{
UUID = json.Properties["id"].StringValue,
UserName = json.Properties["name"].StringValue
};
}
public struct UserProfile
{
public string UUID;
public string UserName;
}
}
/// <summary>
/// Helper class
/// </summary>
static class Request
{
static public Dictionary<string, string> ParseQueryString(string query)
{
return query.Split('&')
.ToDictionary(c => c.Split('=')[0],
c => Uri.UnescapeDataString(c.Split('=')[1]));
}
}
}

View file

@ -328,6 +328,7 @@ namespace MinecraftClient.Protocol
}
public enum LoginResult { OtherError, ServiceUnavailable, SSLError, Success, WrongPassword, AccountMigrated, NotPremium, LoginRequired, InvalidToken, InvalidResponse, NullError };
public enum AccountType { Mojang, Microsoft };
/// <summary>
/// Allows to login to a premium Minecraft account using the Yggdrasil authentication scheme.
@ -336,7 +337,20 @@ namespace MinecraftClient.Protocol
/// <param name="pass">Password</param>
/// <param name="session">In case of successful login, will contain session information for multiplayer</param>
/// <returns>Returns the status of the login (Success, Failure, etc.)</returns>
public static LoginResult GetLogin(string user, string pass, out SessionToken session)
public static LoginResult GetLogin(string user, string pass, AccountType type, out SessionToken session)
{
if (type == AccountType.Microsoft)
{
return MicrosoftLogin(user, pass, out session);
}
else if (type == AccountType.Mojang)
{
return MojangLogin(user, pass, out session);
}
else throw new InvalidOperationException("Account type must be Mojang or Microsoft");
}
private static LoginResult MojangLogin(string user, string pass, out SessionToken session)
{
session = new SessionToken() { ClientID = Guid.NewGuid().ToString().Replace("-", "") };
@ -415,6 +429,44 @@ namespace MinecraftClient.Protocol
}
}
private static LoginResult MicrosoftLogin(string email, string password, out SessionToken session)
{
session = new SessionToken() { ClientID = Guid.NewGuid().ToString().Replace("-", "") };
var ms = new XboxLive();
var mc = new MinecraftWithXbox();
try
{
var msaResponse = ms.UserLogin(email, password, ms.PreAuth());
var xblResponse = ms.XblAuthenticate(msaResponse);
var xsts = ms.XSTSAuthenticate(xblResponse); // Might throw even password correct
string accessToken = mc.LoginWithXbox(xsts.UserHash, xsts.Token);
bool hasGame = mc.UserHasGame(accessToken);
if (hasGame)
{
var profile = mc.GetUserProfile(accessToken);
session.PlayerName = profile.UserName;
session.PlayerID = profile.UUID;
session.ID = accessToken;
return LoginResult.Success;
}
else
{
return LoginResult.NotPremium;
}
}
catch (Exception e)
{
ConsoleIO.WriteLineFormatted("§cMicrosoft authenticate failed: " + e.Message);
if (Settings.DebugMessages)
{
ConsoleIO.WriteLineFormatted("§c" + e.StackTrace);
}
return LoginResult.WrongPassword; // Might not always be wrong password
}
}
/// <summary>
/// Validates whether accessToken must be refreshed
/// </summary>

View file

@ -0,0 +1,282 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.IO;
using System.Collections.Specialized;
using System.Net.Sockets;
using MinecraftClient.Proxy;
using System.Net.Security;
namespace MinecraftClient.Protocol
{
/// <summary>
/// Create a new http request and optionally with proxy according to setting
/// </summary>
public class ProxiedWebRequest
{
private readonly string httpVersion = "HTTP/1.0"; // Use 1.0 here because 1.1 server may send chunked data
private Uri uri;
private string host { get => uri.Host; }
private int port { get => uri.Port; }
private string path { get => uri.PathAndQuery; }
private bool isSecure { get => uri.Scheme == "https"; }
public NameValueCollection Headers = new NameValueCollection();
public string UserAgent { get => Headers.Get("User-Agent"); set => Headers.Set("User-Agent", value); }
public string Accept { get => Headers.Get("Accept"); set => Headers.Set("Accept", value); }
public string Cookie { set => Headers.Set("Cookie", value); }
/// <summary>
/// Create a new http request
/// </summary>
/// <param name="url">Target URL</param>
public ProxiedWebRequest(string url)
{
uri = new Uri(url);
SetupBasicHeaders();
}
/// <summary>
/// Create a new http request with cookies
/// </summary>
/// <param name="url">Target URL</param>
/// <param name="cookies">Cookies to use</param>
public ProxiedWebRequest(string url, NameValueCollection cookies)
{
uri = new Uri(url);
Headers.Add("Cookie", GetCookieString(cookies));
SetupBasicHeaders();
}
/// <summary>
/// Setup some basic headers
/// </summary>
private void SetupBasicHeaders()
{
Headers.Add("Host", host);
Headers.Add("User-Agent", "MCC/" + Program.Version);
Headers.Add("Accept", "*/*");
Headers.Add("Connection", "close");
}
/// <summary>
/// Perform GET request and get the response. Proxy is handled automatically
/// </summary>
/// <returns></returns>
public Response Get()
{
return Send("GET");
}
/// <summary>
/// Perform POST request and get the response. Proxy is handled automatically
/// </summary>
/// <param name="contentType">The content type of request body</param>
/// <param name="body">Request body</param>
/// <returns></returns>
public Response Post(string contentType, string body)
{
Headers.Add("Content-Type", contentType);
// Calculate length
Headers.Add("Content-Length", Encoding.UTF8.GetBytes(body).Length.ToString());
return Send("POST", body);
}
/// <summary>
/// Send a http request to the server. Proxy is handled automatically
/// </summary>
/// <param name="method">Method in string representation</param>
/// <param name="body">Optional request body</param>
/// <returns></returns>
private Response Send(string method, string body = "")
{
List<string> requestMessage = new List<string>()
{
string.Format("{0} {1} {2}", method.ToUpper(), path, httpVersion) // Request line
};
foreach (string key in Headers) // Headers
{
var value = Headers[key];
requestMessage.Add(string.Format("{0}: {1}", key, value));
}
requestMessage.Add(""); // <CR><LF>
if (body != "")
{
requestMessage.Add(body);
}
else requestMessage.Add(""); // <CR><LF>
if (Settings.DebugMessages)
{
foreach (string l in requestMessage)
{
ConsoleIO.WriteLine("< " + l);
}
}
Response response = Response.Empty();
AutoTimeout.Perform(() =>
{
TcpClient client = ProxyHandler.newTcpClient(host, port, true);
Stream stream;
if (isSecure)
{
stream = new SslStream(client.GetStream());
((SslStream)stream).AuthenticateAsClient(host);
}
else
{
stream = client.GetStream();
}
string h = string.Join("\r\n", requestMessage.ToArray());
byte[] data = Encoding.ASCII.GetBytes(h);
stream.Write(data, 0, data.Length);
stream.Flush();
StreamReader sr = new StreamReader(stream);
string rawResult = sr.ReadToEnd();
response = ParseResponse(rawResult);
try
{
sr.Close();
stream.Close();
client.Close();
} catch { }
},
TimeSpan.FromSeconds(30));
return response;
}
/// <summary>
/// Parse a raw response string to response object
/// </summary>
/// <param name="raw">raw response string</param>
/// <returns></returns>
private Response ParseResponse(string raw)
{
int statusCode;
string responseBody = "";
NameValueCollection headers = new NameValueCollection();
NameValueCollection cookies = new NameValueCollection();
if (raw.StartsWith("HTTP/1.1") || raw.StartsWith("HTTP/1.0"))
{
Queue<string> msg = new Queue<string>(raw.Split(new string[] { "\r\n" }, StringSplitOptions.None));
statusCode = int.Parse(msg.Dequeue().Split(' ')[1]);
while (msg.Peek() != "")
{
string[] header = msg.Dequeue().Split(new char[] { ':' }, 2); // Split first ':' only
string key = header[0].ToLower(); // Key is case-insensitive
string value = header[1];
if (key == "set-cookie")
{
string[] cookie = value.Split(';'); // cookie options are ignored
string[] tmp = cookie[0].Split(new char[] { '=' }, 2); // Split first '=' only
string cname = tmp[0].Trim();
string cvalue = tmp[1].Trim();
cookies.Add(cname, cvalue);
}
else
{
headers.Add(key, value.Trim());
}
}
msg.Dequeue();
if (msg.Count > 0)
responseBody = msg.Dequeue();
return new Response()
{
StatusCode = statusCode,
Body = responseBody,
Headers = headers,
Cookies = cookies
};
}
else
{
return new Response()
{
StatusCode = 520, // 502 - Web Server Returned an Unknown Error
Body = "",
Headers = headers,
Cookies = cookies
};
}
}
/// <summary>
/// Get the cookie string representation to use in header
/// </summary>
/// <param name="cookies"></param>
/// <returns></returns>
private static string GetCookieString(NameValueCollection cookies)
{
var sb = new StringBuilder();
foreach (string key in cookies)
{
var value = cookies[key];
sb.Append(string.Format("{0}={1}; ", key, value));
}
string result = sb.ToString();
return result.Remove(result.Length - 2); // Remove "; " at the end
}
/// <summary>
/// Basic response object
/// </summary>
public class Response
{
public int StatusCode;
public string Body;
public NameValueCollection Headers;
public NameValueCollection Cookies;
/// <summary>
/// Get an empty response object
/// </summary>
/// <returns></returns>
public static Response Empty()
{
return new Response()
{
StatusCode = 204, // 204 - No content
Body = "",
Headers = new NameValueCollection(),
Cookies = new NameValueCollection()
};
}
public override string ToString()
{
var sb = new StringBuilder();
sb.AppendLine("Status code: " + StatusCode);
sb.AppendLine("Headers:");
foreach (string key in Headers)
{
sb.AppendLine(string.Format(" {0}: {1}", key, Headers[key]));
}
if (Cookies.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Cookies: ");
foreach (string key in Cookies)
{
sb.AppendLine(string.Format(" {0}={1}", key, Cookies[key]));
}
}
if (Body != "")
{
sb.AppendLine();
if (Body.Length > 200)
{
sb.AppendLine("Body: (Truncated to 200 characters)");
}
else sb.AppendLine("Body: ");
sb.AppendLine(Body.Length > 200 ? Body.Substring(0, 200) + "..." : Body);
}
return sb.ToString();
}
}
}
}

View file

@ -9,6 +9,7 @@
login=
password=
serverip=
type=mojang # Account type. mojang or microsoft
# Advanced settings

View file

@ -8,7 +8,7 @@ mcc.password_hidden=Password : {0}
mcc.offline=§8You chose to run in offline mode.
mcc.session_invalid=§8Cached session is invalid or expired.
mcc.session_valid=§8Cached session is still valid for {0}.
mcc.connecting=Connecting to Minecraft.net...
mcc.connecting=Connecting to {0}...
mcc.ip=Server IP :
mcc.use_version=§8Using Minecraft version {0} (protocol v{1})
mcc.unknown_version=§8Unknown or not supported MC version {0}.\nSwitching to autodetection mode.

View file

@ -25,6 +25,7 @@ namespace MinecraftClient
public static string Login = "";
public static string Username = "";
public static string Password = "";
public static ProtocolHandler.AccountType AccountType = ProtocolHandler.AccountType.Mojang;
public static string ServerIP = "";
public static ushort ServerPort = 25565;
public static string ServerVersion = "";
@ -262,6 +263,9 @@ namespace MinecraftClient
{
case "login": Login = argValue; break;
case "password": Password = argValue; break;
case "type": AccountType = argValue == "mojang"
? ProtocolHandler.AccountType.Mojang
: ProtocolHandler.AccountType.Microsoft; break;
case "serverip": if (!SetServerIP(argValue)) serverAlias = argValue; ; break;
case "singlecommand": SingleCommand = argValue; break;
case "language": Language = argValue; break;