Skip to content

Commit

Permalink
添加微软验证器,优化代码
Browse files Browse the repository at this point in the history
  • Loading branch information
YangSpring114 committed Dec 9, 2023
1 parent c35f58e commit d4aeb2a
Show file tree
Hide file tree
Showing 21 changed files with 407 additions and 137 deletions.
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[*.cs]

# IDE0005: Using 指令是不需要的。
dotnet_diagnostic.IDE0005.severity = none
14 changes: 14 additions & 0 deletions MinecraftLaunch.Test/MinecraftLaunch.Test.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\MinecraftLaunch\MinecraftLaunch.csproj" />
</ItemGroup>

</Project>
15 changes: 15 additions & 0 deletions MinecraftLaunch.Test/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using MinecraftLaunch.Components.Authenticator;

MicrosoftAuthenticator authenticator = new("9fd44410-8ed7-4eb3-a160-9f1cc62c824c");

var result = await authenticator.DeviceFlowAuthAsync(x => {
Console.WriteLine(x.UserCode);
});

if(result != null) {
var account = await authenticator.AuthenticateAsync();
Console.WriteLine(account.Name);
Console.WriteLine(account.Uuid);
}

Console.ReadKey();
31 changes: 18 additions & 13 deletions MinecraftLaunch.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ VisualStudioVersion = 17.8.34309.116
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftLaunch", "MinecraftLaunch\MinecraftLaunch.csproj", "{695112FD-D509-48BE-B050-0A4C1FFAC382}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Test", "Test", "{F9385EFD-59A2-459A-98BA-D86144303BEF}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Natsurainko.FluentCore", "..\..\..\Code\Natsurainko.FluentCore.New\Natsurainko.FluentCore\Natsurainko.FluentCore.csproj", "{510C557E-E4EB-4E28-BB05-969373030054}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftLaunch.Test", "Tests\MinecraftLaunch.Test.csproj", "{72F099ED-FE08-4B26-A595-0EAF56F66C2B}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinecraftLaunch.Test", "MinecraftLaunch.Test\MinecraftLaunch.Test.csproj", "{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand All @@ -25,21 +25,26 @@ Global
{695112FD-D509-48BE-B050-0A4C1FFAC382}.Release|Any CPU.Build.0 = Release|Any CPU
{695112FD-D509-48BE-B050-0A4C1FFAC382}.Release|x64.ActiveCfg = Release|Any CPU
{695112FD-D509-48BE-B050-0A4C1FFAC382}.Release|x64.Build.0 = Release|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Debug|x64.ActiveCfg = Debug|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Debug|x64.Build.0 = Debug|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Release|Any CPU.Build.0 = Release|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Release|x64.ActiveCfg = Release|Any CPU
{72F099ED-FE08-4B26-A595-0EAF56F66C2B}.Release|x64.Build.0 = Release|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Debug|Any CPU.Build.0 = Debug|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Debug|x64.ActiveCfg = Debug|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Debug|x64.Build.0 = Debug|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Release|Any CPU.ActiveCfg = Release|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Release|Any CPU.Build.0 = Release|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Release|x64.ActiveCfg = Release|Any CPU
{510C557E-E4EB-4E28-BB05-969373030054}.Release|x64.Build.0 = Release|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Debug|Any CPU.Build.0 = Debug|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Debug|x64.ActiveCfg = Debug|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Debug|x64.Build.0 = Debug|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Release|Any CPU.ActiveCfg = Release|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Release|Any CPU.Build.0 = Release|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Release|x64.ActiveCfg = Release|Any CPU
{76E3BD50-5A2C-43D0-A5B5-CD139EB60C94}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{72F099ED-FE08-4B26-A595-0EAF56F66C2B} = {F9385EFD-59A2-459A-98BA-D86144303BEF}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {83925857-C3A4-4952-B8E4-1FEB591DC9F8}
EndGlobalSection
Expand Down
4 changes: 1 addition & 3 deletions MinecraftLaunch/Classes/Interfaces/IAuthenticator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
/// <summary>
/// 验证器统一接口(IoC 适应)
/// </summary>
public interface IAuthenticator<TAccount> {
public interface IAuthenticator<out TAccount> {
TAccount Authenticate();

ValueTask<TAccount> AuthenticateAsync();
}
}
23 changes: 23 additions & 0 deletions MinecraftLaunch/Classes/Models/Auth/DeviceCodeResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;

namespace MinecraftLaunch.Classes.Models.Auth {
public record DeviceCodeResponse {
[JsonPropertyName("user_code")]
public string UserCode { get; set; }

[JsonPropertyName("device_code")]
public string DeviceCode { get; set; }

[JsonPropertyName("verification_uri")]
public string VerificationUrl { get; set; }

[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }

[JsonPropertyName("interval")]
public int Interval { get; set; }

[JsonPropertyName("message")]
public string Message { get; set; }
}
}
31 changes: 31 additions & 0 deletions MinecraftLaunch/Classes/Models/Auth/OAuth2TokenResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

namespace MinecraftLaunch.Classes.Models.Auth {
public record OAuth2TokenResponse {
[JsonPropertyName("token_type")]
public string TokenType { get; set; }

[JsonPropertyName("expires_in")]
public int ExpiresIn { get; set; }

[JsonPropertyName("scope")]
public string Scope { get; set; }

[JsonPropertyName("access_token")]
public string AccessToken { get; set; }

[JsonPropertyName("refresh_token")]
public string RefreshToken { get; set; }

[JsonPropertyName("user_id")]
public string UserId { get; set; }

[JsonPropertyName("foci")]
public string Foci { get; set; }
}
}
203 changes: 198 additions & 5 deletions MinecraftLaunch/Components/Authenticator/MicrosoftAuthenticator.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,207 @@
using MinecraftLaunch.Classes.Interfaces;
using Flurl.Http;
using MinecraftLaunch.Classes.Enums;
using MinecraftLaunch.Classes.Interfaces;
using MinecraftLaunch.Classes.Models.Auth;
using MinecraftLaunch.Extensions;
using System.Reflection.Metadata;
using System.Text.Json;
using System.Text.Json.Nodes;
using static System.Formats.Asn1.AsnWriter;

namespace MinecraftLaunch.Components.Authenticator {//W.I.P
public class MicrosoftAuthenticator : IAuthenticator<MicrosoftAccount> {
public class MicrosoftAuthenticator(string clientId) : IAuthenticator<MicrosoftAccount> {
private MicrosoftAccount _account;

private string _clientId = clientId;

private OAuth2TokenResponse _oAuth2TokenResponse;

private IEnumerable<string> _scopes => ["XboxLive.signin", "offline_access", "openid", "profile", "email"];

public bool IsCheckOwnership { get; set; }

public MicrosoftAuthenticator(MicrosoftAccount account) : this(string.Empty) {
_account = account;
}

public MicrosoftAccount Authenticate() {
return AuthenticateAsync().GetAwaiter().GetResult();
var task = AuthenticateAsync();
if (task.IsCompleted) {
return task.GetAwaiter().GetResult();
}

return null;
}

public async ValueTask<MicrosoftAccount> AuthenticateAsync() {
/*
* Refresh token
*/
if (_account is not null) {
var url = "https://login.live.com/oauth20_token.srf";

var content = new {
client_id = _clientId,
refresh_token = _account.RefreshToken,
grant_type = "refresh_token",
};

var result = await url.PostUrlEncodedAsync(content);
_oAuth2TokenResponse = await result.GetJsonAsync<OAuth2TokenResponse>();
}

/*
* Get Xbox live token
*/
var xblContent = new {
Properties = new {
AuthMethod = "RPS",
SiteName = "user.auth.xboxlive.com",
RpsTicket = $"d={_oAuth2TokenResponse.AccessToken}"
},
RelyingParty = "http://auth.xboxlive.com",
TokenType = "JWT"
};

using var xblJsonReq = await $"https://user.auth.xboxlive.com/user/authenticate"
.PostJsonAsync(xblContent);

var xblTokenNode = JsonNode.Parse(await xblJsonReq.GetStringAsync());

/*
* Get Xbox security token service token
*/
var xstsContent = new {
Properties = new {
SandboxId = "RETAIL",
UserTokens = new[] {
xblTokenNode.GetString("Token")
}
},
RelyingParty = "rp://api.minecraftservices.com/",
TokenType = "JWT"
};

using var xstsJsonReq = await $"https://xsts.auth.xboxlive.com/xsts/authorize"
.PostJsonAsync(xstsContent);

var xstsTokenNode = JsonNode.Parse(await xstsJsonReq.GetStringAsync());

/*
* Authenticate minecraft account
*/
var authenticateMinecraftContent = new {
identityToken = $"XBL3.0 x={xblTokenNode["DisplayClaims"]["xui"].AsArray()
.FirstOrDefault()
.GetString("uhs")};{xstsTokenNode!
.GetString("Token")}"
};

using var authenticateMinecraftPostRes = await $"https://api.minecraftservices.com/authentication/login_with_xbox"
.PostJsonAsync(authenticateMinecraftContent);

string access_token = JsonNode.Parse(await authenticateMinecraftPostRes.GetStringAsync())!["access_token"]!
.GetValue<string>();

/*
* Check player's minecraft ownership (optional steps)
*/
if (IsCheckOwnership) {
using var gameHasRes = await "https://api.minecraftservices.com/entitlements/mcstore"
.WithHeader("Authorization", $"Bearer {access_token}")
.GetAsync();

var ownNode = JsonNode.Parse(await gameHasRes.GetStringAsync());
if (!ownNode["items"].AsArray().Any()) {
throw new OperationCanceledException("Game not purchased, login terminated");
}
}

/*
* Get player's minecraft profile
*/
using var profileRes = await "https://api.minecraftservices.com/minecraft/profile"
.WithHeader("Authorization", $"Bearer {access_token}")
.GetAsync();

var profileNode = JsonNode.Parse(await profileRes.GetStringAsync());
string refreshToken = _oAuth2TokenResponse is null && string
.IsNullOrEmpty(_oAuth2TokenResponse.RefreshToken)
? "None"
: _oAuth2TokenResponse.RefreshToken;

return new MicrosoftAccount {
AccessToken = access_token,
Type = AccountType.Microsoft,
Name = profileNode.GetString("name"),
Uuid = Guid.Parse(profileNode.GetString("id")),
RefreshToken = refreshToken
};
}

public ValueTask<MicrosoftAccount> AuthenticateAsync() {
throw new NotImplementedException();
public async Task<OAuth2TokenResponse> DeviceFlowAuthAsync(Action<DeviceCodeResponse> deviceCode,
CancellationTokenSource source = default) {
if (string.IsNullOrEmpty(_clientId)) {
throw new ArgumentNullException("ClientId is empty!");
}

var token = source?.Token;
string tenant = "/consumers";
var parameters = new Dictionary<string, string> {
["client_id"] = _clientId,
["tenant"] = tenant,
["scope"] = string.Join(" ", _scopes)
};

string json = await "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"
.PostUrlEncodedAsync(parameters)
.ReceiveString();

var codeResponse = JsonSerializer.Deserialize<DeviceCodeResponse>(json);
deviceCode.Invoke(codeResponse);

//Polling
TimeSpan pollingInterval = TimeSpan.FromSeconds(codeResponse.Interval);
DateTimeOffset codeExpiresOn = DateTimeOffset.UtcNow.AddSeconds(codeResponse.ExpiresIn);
TimeSpan timeRemaining = codeExpiresOn - DateTimeOffset.UtcNow;
OAuth2TokenResponse tokenResponse = default!;

while (timeRemaining.TotalSeconds > 0) {
if (token.HasValue && token.Value.IsCancellationRequested) {
break;
}

parameters = new Dictionary<string, string> {
["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code",
["device_code"] = codeResponse.DeviceCode,
["client_id"] = _clientId,
["tenant"] = tenant
};

string tokenJson = await "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"
.PostUrlEncodedAsync(new FormUrlEncodedContent(parameters))
.ReceiveString();

var tempTokenResponse = JsonNode.Parse(tokenJson);

if (tempTokenResponse["error"] == null) {
tokenResponse = new() {
AccessToken = tempTokenResponse.GetString("access_token"),
RefreshToken = tempTokenResponse.GetString("refresh_token"),
ExpiresIn = tempTokenResponse.GetInt32("expires_in"),
};
}

if (tempTokenResponse["token_type"]?.GetValue<string>() is "Bearer") {
_oAuth2TokenResponse = tokenResponse;
return tokenResponse;
}

await Task.Delay(pollingInterval);
timeRemaining = codeExpiresOn - DateTimeOffset.UtcNow;
}

throw new TimeoutException("登录操作已超时");
}
}
}
Loading

0 comments on commit d4aeb2a

Please sign in to comment.