feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,9 @@
using Microsoft.IdentityModel.Tokens;
using System.Text.Json.Serialization;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
public record JsonWebKeys
{
[JsonPropertyName("keys")] public required JsonWebKey[] Keys { get; init; }
}
@@ -0,0 +1,273 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Security;
using System.Text.Json;
using Microsoft.IdentityModel.Tokens;
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
using PCL.Core.Utils;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
/// <summary>
/// Json Web Token 类
/// </summary>
/// <param name="token">JWT 令牌字符串</param>
/// <param name="meta">OpenID 元数据</param>
public class JsonWebToken(string token, OpenIdMetadata meta)
{
public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata, string token, JsonWebKey? key, string? clientId);
/// <summary>
/// 安全令牌验证回调函数,默认验证签名、发行者、nbf 和 exp
/// </summary>
public TokenValidateCallback SecurityTokenValidateCallback { get; set; } = static (meta, token, key, clientId) =>
{
try
{
var handler = new JwtSecurityTokenHandler();
var parameter = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = meta.Issuer,
ValidateAudience = !string.IsNullOrEmpty(clientId),
ValidAudience = clientId,
ValidateIssuerSigningKey = key is not null,
IssuerSigningKey = key is not null ? new JsonWebKeySet { Keys = { key } }.Keys[0] : null,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(60)
};
handler.ValidateToken(token, parameter, out var secToken);
return secToken;
}
catch (Exception ex)
{
throw new SecurityException($"令牌验证失败:{ex.Message}", ex);
}
};
private bool _verified;
private JwtSecurityToken? _parsedToken;
private readonly JwtSecurityTokenHandler _tokenHandler = new();
/// <summary>
/// 解析令牌(不验证签名)
/// </summary>
/// <returns>解析后的 JWT 令牌对象</returns>
/// <exception cref="SecurityException">令牌格式无效</exception>
private JwtSecurityToken _ParseToken()
{
if (_parsedToken is not null)
return _parsedToken;
try
{
if (!_tokenHandler.CanReadToken(token))
throw new SecurityException("无法读取令牌:格式无效");
_parsedToken = _tokenHandler.ReadJwtToken(token);
return _parsedToken;
}
catch (Exception ex)
{
throw new SecurityException($"令牌解析失败:{ex.Message}", ex);
}
}
/// <summary>
/// 尝试读取 Token 中的字段
/// </summary>
/// <param name="allowUnverifyToken">是否允许在未验证的情况下读取字段,若为 false,当 Token 未验证时将抛出异常</param>
/// <typeparam name="T">声明值的目标类型</typeparam>
/// <returns>解析后的声明对象</returns>
/// <exception cref="SecurityException">未调用 VerifySignature() 且 allowUnverifyToken 为 false</exception>
/// <exception cref="InvalidOperationException">令牌中不存在 payload 数据</exception>
public T? ReadTokenPayload<T>(bool allowUnverifyToken = false)
{
if (!allowUnverifyToken && !_verified)
throw new SecurityException("不安全的令牌");
try
{
var jwtToken = _ParseToken();
if (jwtToken.Payload is null || jwtToken.Payload.Count == 0)
throw new InvalidOperationException("令牌 Payload 无效");
if (typeof(T).IsAssignableFrom(typeof(Dictionary<string, object>)))
return (T)(object)jwtToken.Payload;
if (typeof(T) == typeof(JwtPayload))
return (T)(object)jwtToken.Payload;
var payloadJson = JsonSerializer.Serialize(jwtToken.Payload, JsonCompat.SerializerOptions);
var result = JsonSerializer.Deserialize<T>(payloadJson, JsonCompat.SerializerOptions);
return result;
}
catch (SecurityException)
{
throw;
}
catch (Exception ex)
{
throw new SecurityException($"读取令牌 payload 失败:{ex.Message}", ex);
}
}
/// <summary>
/// 读取 Token 头
/// </summary>
/// <typeparam name="T">声明值的目标类型</typeparam>
/// <returns>解析后的头对象</returns>
/// <exception cref="InvalidOperationException">令牌中不存在 header 数据</exception>
public T? ReadTokenHeader<T>()
{
try
{
var jwtToken = _ParseToken();
if (jwtToken.Header is null || jwtToken.Header.Count == 0)
throw new InvalidOperationException("令牌中不存在 header 数据");
if (typeof(T).IsAssignableFrom(typeof(Dictionary<string, object>)))
return (T)(object)jwtToken.Header;
if (typeof(T) == typeof(JwtHeader))
return (T)(object)jwtToken.Header;
var headerJson = JsonSerializer.Serialize(jwtToken.Header, JsonCompat.SerializerOptions);
var result = JsonSerializer.Deserialize<T>(headerJson, JsonCompat.SerializerOptions);
return result;
}
catch (Exception ex)
{
throw new SecurityException($"读取令牌 header 失败:{ex.Message}", ex);
}
}
/// <summary>
/// 对 Token 进行签名验证 <br/>
/// 默认情况下仅对签名、iss、nbf、exp 进行验证,如果需要更细粒度验证,请设置 <see cref="SecurityTokenValidateCallback"/>
/// </summary>
/// <param name="key">用于验证签名的 JSON Web Key</param>
/// <param name="clientId">预期的受众(audience),可选</param>
/// <returns>验证成功返回 SecurityToken 对象,否则返回 null</returns>
public SecurityToken? VerifySignature(JsonWebKey key, string? clientId = null)
{
try
{
var result = SecurityTokenValidateCallback.Invoke(meta, token, key, clientId);
if (result is not null)
_verified = true;
return result;
}
catch (Exception ex)
{
throw new SecurityException($"令牌签名验证失败:{ex.Message}", ex);
}
}
/// <summary>
/// 重载方法,用于无参调用验证(仅验证基本声明)
/// </summary>
/// <returns>验证成功返回 SecurityToken 对象,否则返回 null</returns>
public SecurityToken? VerifySignature()
{
try
{
var result = SecurityTokenValidateCallback.Invoke(meta, token, null, null);
if (result is not null)
_verified = true;
return result;
}
catch (Exception ex)
{
throw new SecurityException($"令牌验证失败:{ex.Message}", ex);
}
}
/// <summary>
/// 获取令牌的过期时间
/// </summary>
/// <returns>过期时间,若不存在则返回 null</returns>
public DateTime? GetExpirationTime()
{
try
{
var jwtToken = _ParseToken();
return jwtToken.ValidTo != DateTime.MinValue ? jwtToken.ValidTo : null;
}
catch (Exception ex)
{
throw new SecurityException($"获取令牌过期时间失败:{ex.Message}", ex);
}
}
/// <summary>
/// 获取令牌的签发时间
/// </summary>
/// <returns>签发时间,若不存在则返回 null</returns>
public DateTime? GetIssuedAtTime()
{
try
{
var jwtToken = _ParseToken();
return jwtToken.ValidFrom != DateTime.MinValue ? jwtToken.ValidFrom : null;
}
catch (Exception ex)
{
throw new SecurityException($"获取令牌签发时间失败:{ex.Message}", ex);
}
}
/// <summary>
/// 检查令牌是否已过期
/// </summary>
/// <returns>若已过期返回 true,否则返回 false</returns>
public bool IsExpired()
{
try
{
var expTime = GetExpirationTime();
return expTime.HasValue && DateTime.UtcNow > expTime.Value;
}
catch
{
return true; // 如果无法解析,视为已过期
}
}
/// <summary>
/// 获取特定声明的值
/// </summary>
/// <param name="claimType">声明类型</param>
/// <param name="allowUnverifyToken">是否允许在未验证的情况下读取</param>
/// <returns>声明值,若不存在则返回 null</returns>
public string? GetClaimValue(string claimType, bool allowUnverifyToken = false)
{
try
{
var payload = ReadTokenPayload<Dictionary<string, object>>(allowUnverifyToken);
return payload?.TryGetValue(claimType, out var value) ?? false ? value.ToString() : null;
}
catch (Exception ex)
{
throw new SecurityException($"获取声明值失败({claimType}):{ex.Message}", ex);
}
}
/// <summary>
/// 获取原始令牌字符串
/// </summary>
/// <returns>JWT 令牌字符串</returns>
public string GetTokenString() => token;
/// <summary>
/// 检查令牌验证状态
/// </summary>
public bool IsVerified => _verified;
}
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
using PCL.Core.Minecraft.IdentityModel.OAuth;
using PCL.Core.Utils.Exts;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
public class OpenIdClient(OpenIdOptions options):IOAuthClient
{
private IOAuthClient? _client;
/// <summary>
/// 初始化并从网络加载 OpenId 配置
/// </summary>
/// <param name="token"></param>
/// <param name="checkAddress"></param>
/// <exception cref="IdentityModelConfigurationException">当要求检查地址并不存在任何授权端点时,将触发此错误</exception>
public async Task InitializeAsync(CancellationToken token,bool checkAddress = false)
{
await options.InitializeAsync(token);
var opt = options.BuildOAuthOptions();
if (checkAddress && opt.Meta.AuthorizeEndpoint.IsNullOrEmpty() && opt.Meta.DeviceEndpoint.IsNullOrEmpty())
throw new IdentityModelConfigurationException("OpenID 元数据缺少授权代码流端点和设备代码流端点");
_client = options.EnablePkceSupport ? new PkceClient(opt) : new SimpleOAuthClient(opt);
}
/// <summary>
/// 获取授权代码流地址
/// </summary>
/// <param name="scopes">权限列表</param>
/// <param name="state"></param>
/// <param name="extData">扩展数据</param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public string GetAuthorizeUrl(string[] scopes, string state,Dictionary<string,string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return _client.GetAuthorizeUrl(scopes, state, extData);
}
/// <summary>
/// 使用授权代码兑换 Token
/// </summary>
/// <param name="code"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.AuthorizeWithCodeAsync(code, token, extData);
}
/// <summary>
/// 获取设备代码流代码对
/// </summary>
/// <param name="scopes"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.GetCodePairAsync(scopes, token, extData);
}
/// <summary>
/// 发起一次验证,以检查认证是否成功
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
}
/// <summary>
/// 进行一次刷新调用
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 OpenID 客户端");
return await _client.AuthorizeWithSilentAsync(data, token, extData);
}
}
@@ -0,0 +1,41 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
public record OpenIdMetadata
{
[JsonPropertyName("issuer")]
public required string Issuer { get; init; }
[JsonPropertyName("authorization_endpoint")]
public string? AuthorizationEndpoint { get; init; }
[JsonPropertyName("device_authorization_endpoint")]
public string? DeviceAuthorizationEndpoint { get; init; }
[JsonPropertyName("token_endpoint")]
public required string TokenEndpoint { get; init; }
[JsonPropertyName("userinfo_endpoint")]
public required string UserInfoEndpoint { get; init; }
[JsonPropertyName("registration_endpoint")]
public string? RegistrationEndpoint { get; init; }
[JsonPropertyName("jwks_uri")]
public required string JwksUri { get; init; }
[JsonPropertyName("scopes_supported")]
public required IReadOnlyList<string> ScopesSupported { get; init; }
[JsonPropertyName("subject_types_supported")]
public required IReadOnlyList<string> SubjectTypesSupported { get; init; }
[JsonPropertyName("id_token_signing_alg_values_supported")]
public required IReadOnlyList<string> IdTokenSigningAlgValuesSupported { get; init; }
}
@@ -0,0 +1,120 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
using PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken;
using PCL.Core.Minecraft.IdentityModel.OAuth;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.IO.Net.Http;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
public record OpenIdOptions
{
/// <summary>
/// OpenId Discovery 地址
/// </summary>
public required string OpenIdDiscoveryAddress { get; set; }
/// <summary>
/// 客户端 ID(必须设置)
/// </summary>
public required string ClientId
{
get;
set;
}
// 为了让 YggdrasilConnect Client 复用代码做的逻辑
/// <summary>
/// 是否只使用设备代码流授权
/// </summary>
public bool OnlyDeviceAuthorize { get; set; }
/// <summary>
/// 回调 Uri
/// </summary>
public string? RedirectUri { get; set; }
/// <summary>
/// 发送 HTTP 请求时设置的请求头,仅适用于请求头(丢到 HttpRequestMessage 不会报错的那种)
/// </summary>
public Dictionary<string, string>? Headers { get; set; }
/// <summary>
/// 是否启用 PKCE 支持,默认启用
/// </summary>
public bool EnablePkceSupport { get; set; } = true;
/// <summary>
/// 获取 HttpClient,生命周期由调用方管理
/// </summary>
public required Func<HttpClient> GetClient { get; set; }
/// <summary>
/// OpenId 元数据,请勿自行设置此属性,而是应该调用 <see cref="InitializeAsync"/>
/// </summary>
public OpenIdMetadata? Meta { get; internal set; }
/// <summary>
/// 从互联网拉取 OpenID 配置信息
/// </summary>
/// <param name="token"></param>
public virtual async Task InitializeAsync(CancellationToken token)
{
using var response = await HttpRequest
.Create(OpenIdDiscoveryAddress)
.WithHeaders(Headers ?? [])
.SendAsync(GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
Meta = await response
.AsJsonAsync<OpenIdMetadata>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 获取 Json Web Key
/// </summary>
/// <param name="kid">密钥 ID</param>
/// <param name="token"></param>
/// <returns></returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/>,或找不到匹配的 Jwk</exception>
public async Task<JsonWebKey> GetSignatureKeyAsync(string kid,CancellationToken token)
{
if (Meta?.JwksUri is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 OpenID 元数据");
using var response = await HttpRequest.Create(Meta.JwksUri)
.WithHeaders(Headers ?? [])
.SendAsync(GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
var result = await response
.AsJsonAsync<JsonWebKeys>(cancellationToken: token)
.ConfigureAwait(false);
return result?.Keys.SingleOrDefault(k => k.Kid == kid)
?? throw new IdentityModelConfigurationException($"找不到匹配的 Jwk{kid}");
}
/// <summary>
/// 构建 OAuth 客户端配置
/// </summary>
/// <returns><see cref="OAuthClientOptions">OAuth 客户端选项</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/> 或缺少授权所需配置</exception>
public virtual OAuthClientOptions BuildOAuthOptions()
{
if (Meta is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 OpenID 元数据");
if (string.IsNullOrEmpty(Meta.TokenEndpoint))
throw new IdentityModelConfigurationException("OpenID 元数据缺少 TokenEndpoint");
if (!OnlyDeviceAuthorize && string.IsNullOrEmpty(RedirectUri))
throw new IdentityModelConfigurationException("授权代码流需要设置 RedirectUri");
return new OAuthClientOptions
{
GetClient = GetClient,
ClientId = ClientId,
RedirectUri = OnlyDeviceAuthorize ? string.Empty:RedirectUri!,
Meta = new EndpointMeta
{
AuthorizeEndpoint = Meta?.AuthorizationEndpoint??string.Empty,
DeviceEndpoint = Meta?.DeviceAuthorizationEndpoint??string.Empty,
TokenEndpoint = Meta!.TokenEndpoint,
}
};
}
}
@@ -0,0 +1,7 @@
namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
public enum PkceChallengeOptions
{
Sha256,
PlainText
}
@@ -0,0 +1,88 @@
using System;
using PCL.Core.Utils.Exts;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel.OAuth;
using PCL.Core.Utils.Hash;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce;
/// <summary>
/// 带 PKCE 支持的客户端 <br/>
/// 此客户端并非线程安全,请勿在多个线程间共享示例
/// </summary>
/// <param name="options"></param>
public class PkceClient(OAuthClientOptions options):IOAuthClient
{
private byte[] _ChallengeCode { get; set; } = new byte[32];
private bool _isCallGetAuthorizeUrl;
/// <summary>
/// 设置验证方法,支持 PlainText 和 SHA256
/// </summary>
public PkceChallengeOptions ChallengeMethod { get; private set; } = PkceChallengeOptions.Sha256;
private readonly SimpleOAuthClient _client = new(options);
/// <summary>
/// 获取授权地址
/// </summary>
/// <param name="scopes"></param>
/// <param name="state"></param>
/// <param name="extData"></param>
/// <returns></returns>
public string GetAuthorizeUrl(string[] scopes, string state, Dictionary<string, string>? extData)
{
RandomNumberGenerator.Fill(_ChallengeCode);
extData ??= [];
extData["code_challenge"] = ChallengeMethod == PkceChallengeOptions.Sha256
? SHA256Provider.Instance.ComputeHash(_ChallengeCode).ToHexString()
: _ChallengeCode.FromBytesToB64UrlSafe();
extData["code_challenge_method"] = ChallengeMethod == PkceChallengeOptions.Sha256 ? "S256":"plain";
_isCallGetAuthorizeUrl = true;
return _client.GetAuthorizeUrl(scopes, state, extData);
}
/// <summary>
/// 使用授权代码兑换令牌
/// </summary>
/// <param name="code"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="InvalidOperationException"></exception>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (!_isCallGetAuthorizeUrl) throw new InvalidOperationException("Challenge code is invalid");
var pkce = _ChallengeCode.FromBytesToB64UrlSafe();
extData ??= [];
extData["code_verifier"] = pkce;
_isCallGetAuthorizeUrl = false;
return await _client.AuthorizeWithCodeAsync(code, token, extData);
}
/// <summary>
/// 获取代码对
/// </summary>
/// <param name="scopes"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
{
return await _client.GetCodePairAsync(scopes, token, extData);
}
/// <summary>
///
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
{
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
}
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
{
return await _client.AuthorizeWithSilentAsync(data, token, extData);
}
}
@@ -0,0 +1,111 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
using PCL.Core.Minecraft.IdentityModel.OAuth;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
// Steven Qiu 说这东西完全就是 OpenId + 魔改了一部分,所以可以直接复用 OpenId 的逻辑
/// <summary>
/// Yggdrasil Connect Client 客户端
/// </summary>
public class YggdrasilClient:IOAuthClient
{
private OpenIdClient? _client;
private YggdrasilOptions _options;
public YggdrasilClient(YggdrasilOptions options)
{
_options = options;
}
/// <summary>
/// 初始化并拉取网络配置
/// </summary>
/// <exception cref="IdentityModelConfigurationException">无法获取 ClientId 或 OpenID 元数据无效</exception>
/// <param name="token"></param>
public async Task InitializeAsync(CancellationToken token)
{
_client = new OpenIdClient(_options);
await _client.InitializeAsync(token, true);
}
/// <summary>
/// 获取授权端点地址
/// </summary>
/// <param name="scopes">Yggdrasil Connect 规范所规定的权限</param>
/// <param name="state">用于关联会话</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns>OAuth 授权地址</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public string GetAuthorizeUrl(string[] scopes, string state, Dictionary<string, string>? extData)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return _client.GetAuthorizeUrl(scopes, state, extData);
}
/// <summary>
/// 使用授权代码兑换令牌
/// </summary>
/// <param name="code">授权代码</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.AuthorizeWithCodeAsync(code, token, extData);
}
/// <summary>
/// 获取代码对
/// </summary>
/// <param name="scopes">Yggdrasil Connect 规范所规定的权限</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="DeviceCodeData"> 设备流数据</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<DeviceCodeData?> GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.GetCodePairAsync(scopes, token, extData);
}
/// <summary>
/// 发起一次请求验证用户授权状态
/// </summary>
/// <param name="data"><see cref="DeviceCodeData"> 设备流数据</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.AuthorizeWithDeviceAsync(data, token, extData);
}
/// <summary>
/// 刷新登录
/// </summary>
/// <param name="data"><see cref="AuthorizeResult"> 先前的授权结果</param>
/// <param name="token">取消令牌</param>
/// <param name="extData">扩展数据 <br/> NOTE: 由 RFC 6749 预先定义的字段将会被覆盖,请避免填写</param>
/// <returns><see cref="AuthorizeResult"> 授权结果</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/></exception>
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary<string, string>? extData = null)
{
if (_client is null) throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 初始化 Yggdrasil Connect 客户端");
return await _client.AuthorizeWithSilentAsync(data, token, extData);
}
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
public record YggdrasilConnectMetaData: OpenIdMetadata
{
[JsonPropertyName("shared_client_id")]
public string? SharedClientId { get; init; }
}
@@ -0,0 +1,61 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Minecraft.IdentityModel;
using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId;
using PCL.Core.Minecraft.IdentityModel.OAuth;
using PCL.Core.Utils.Exts;
using PCL.Core.IO.Net.Http;
namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect;
public record YggdrasilOptions:OpenIdOptions
{
private string[] _scopesRequired = ["openid", "Yggdrasil.PlayerProfiles.Select", "Yggdrasil.Server.Join"];
// 重写这个鬼方法是因为 Yggdrasil Connect 有要求(
/// <summary>
/// 拉取 Yggdrasil 配置
/// </summary>
/// <param name="token"></param>
/// <exception cref="IdentityModelConfigurationException">无法加载元数据或缺少必要 scope</exception>
public override async Task InitializeAsync(CancellationToken token)
{
using var response = await HttpRequest
.Create(OpenIdDiscoveryAddress)
.WithHeaders(Headers ?? [])
.SendAsync(GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
Meta = (await response.AsJsonAsync<YggdrasilConnectMetaData>(cancellationToken: token).ConfigureAwait(false))
?? throw new IdentityModelConfigurationException("无法加载 Yggdrasil Connect 元数据");
var missingScopes = _scopesRequired.Except(Meta.ScopesSupported).ToArray();
if (missingScopes.Length > 0)
throw new IdentityModelConfigurationException($"Yggdrasil Connect 元数据缺少必要 scope{string.Join(", ", missingScopes)}");
}
/// <summary>
/// 构建 OAuth 客户端选项
/// </summary>
/// <returns><see cerf="OAuthClientOptions"> OAuth 客户端选项</returns>
/// <exception cref="IdentityModelConfigurationException">未调用 <see cref="InitializeAsync"/> 或缺少必要的客户端配置</exception>
public override OAuthClientOptions BuildOAuthOptions()
{
if (Meta is YggdrasilConnectMetaData meta)
{
var options = base.BuildOAuthOptions();
if (!options.ClientId.IsNullOrEmpty()) return options;
if (!meta.SharedClientId.IsNullOrEmpty())
{
options.ClientId = meta.SharedClientId;
return options;
}
throw new IdentityModelConfigurationException("Yggdrasil Connect 需要设置 ClientId,或由元数据提供 sharedClientId");
}
throw new IdentityModelConfigurationException("请先调用 InitializeAsync() 加载 Yggdrasil Connect 元数据");
}
}
@@ -0,0 +1,47 @@
using System;
namespace PCL.Core.Minecraft.IdentityModel;
/// <summary>
/// IdentityModel 模块异常基类。
/// </summary>
public class IdentityModelException(string message, Exception? innerException = null) : Exception(message, innerException);
/// <summary>
/// IdentityModel 配置或元数据不满足当前认证流程要求时抛出的异常。
/// </summary>
public class IdentityModelConfigurationException(string message, Exception? innerException = null)
: IdentityModelException(message, innerException);
/// <summary>
/// 认证服务器返回 OAuth/Yggdrasil 协议错误时抛出的异常。
/// </summary>
/// <param name="error">协议错误代码。</param>
/// <param name="errorDescription">协议错误描述。</param>
/// <param name="innerException">内部异常。</param>
public class IdentityModelAuthenticationException(
string? error,
string? errorDescription,
Exception? innerException = null)
: IdentityModelException(_BuildMessage(error, errorDescription), innerException)
{
/// <summary>
/// 协议错误代码。
/// </summary>
public string? Error { get; } = error;
/// <summary>
/// 协议错误描述。
/// </summary>
public string? ErrorDescription { get; } = errorDescription;
private static string _BuildMessage(string? error, string? errorDescription)
{
if (!string.IsNullOrWhiteSpace(error) && !string.IsNullOrWhiteSpace(errorDescription))
return $"认证失败:{error} - {errorDescription}";
if (!string.IsNullOrWhiteSpace(errorDescription)) return $"认证失败:{errorDescription}";
if (!string.IsNullOrWhiteSpace(error)) return $"认证失败:{error}";
return "认证失败";
}
}
@@ -0,0 +1,40 @@
using System.Text.Json.Serialization;
using PCL.Core.Utils.Exts;
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
public record AuthorizeResult
{
public bool IsError => !Error.IsNullOrEmpty();
/// <summary>
/// 错误类型 (e.g. invalid_request)
/// </summary>
[JsonPropertyName("error")] public string? Error { get; init; }
/// <summary>
/// 描述此错误的文本
/// </summary>
[JsonPropertyName("error_description")] public string? ErrorDescription { get; init; }
// 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践
/// <summary>
/// 访问令牌
/// </summary>
[JsonPropertyName("access_token")] public string? AccessToken { get; init; }
/// <summary>
/// 刷新令牌
/// </summary>
[JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; }
/// <summary>
/// ID Token
/// </summary>
[JsonPropertyName("id_token")] public string? IdToken { get; init; }
/// <summary>
/// 令牌类型
/// </summary>
[JsonPropertyName("token_type")] public string? TokenType { get; init; }
/// <summary>
/// 过期时间
/// </summary>
[JsonPropertyName("expires_in")] public int? ExpiresIn { get; init; }
}
@@ -0,0 +1,155 @@
using System;
using System.Text;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using System.Collections.Generic;
using System.Threading;
using PCL.Core.IO.Net.Http;
using PCL.Core.Minecraft.IdentityModel;
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
/// <summary>
/// OAuth 客户端实现,配合 Polly 食用效果更佳
/// </summary>
/// <param name="options">OAuth 参数</param>
public sealed class SimpleOAuthClient(OAuthClientOptions options):IOAuthClient
{
/// <summary>
/// 获取授权 Url
/// </summary>
/// <param name="scopes">访问权限列表</param>
/// <param name="state"></param>
/// <param name="extData"></param>
/// <returns></returns>
public string GetAuthorizeUrl(string[] scopes,string state,Dictionary<string,string>? extData = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(options.Meta.AuthorizeEndpoint);
var sb = new StringBuilder();
sb.Append(options.Meta.AuthorizeEndpoint);
sb.Append($"?response_type=code&scope={Uri.EscapeDataString(string.Join(" ", scopes))}");
sb.Append($"&redirect_uri={Uri.EscapeDataString(options.RedirectUri)}");
sb.Append($"&client_id={options.ClientId}&state={state}");
if (extData is null) return sb.ToString();
foreach (var kvp in extData)
sb.Append($"&{kvp.Key}={Uri.EscapeDataString(kvp.Value)}");
return sb.ToString();
}
/// <summary>
/// 使用授权代码获取令牌
/// </summary>
/// <param name="code">授权代码</param>
/// <param name="extData">附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type)</param>
/// <param name="token"></param>
/// <returns></returns>
public async Task<AuthorizeResult?> AuthorizeWithCodeAsync(
string code,CancellationToken token,Dictionary<string,string>? extData = null
)
{
extData ??= new Dictionary<string, string>();
extData["client_id"] = options.ClientId;
extData["grant_type"] = "authorization_code";
extData["code"] = code;
var client = options.GetClient.Invoke();
using var content = new FormUrlEncodedContent(extData);
using var response = await HttpRequest
.CreatePost(options.Meta.TokenEndpoint)
.WithContent(content)
.WithHeaders(options.Headers ?? [])
.SendAsync(client, cancellationToken: token)
.ConfigureAwait(false);
return await response
.AsJsonAsync<AuthorizeResult>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 获取设备代码对
/// </summary>
/// <param name="scopes"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
public async Task<DeviceCodeData?> GetCodePairAsync
(string[] scopes,CancellationToken token, Dictionary<string, string>? extData = null)
{
ArgumentException.ThrowIfNullOrEmpty(options.Meta.DeviceEndpoint);
var client = options.GetClient.Invoke();
extData ??= new Dictionary<string, string>();
extData["scope"] = string.Join(" ", scopes);
extData["client_id"] = options.ClientId;
var content = new FormUrlEncodedContent(extData);
using var response = await HttpRequest
.CreatePost(options.Meta.DeviceEndpoint)
.WithContent(content)
.WithHeaders(options.Headers ?? [])
.SendAsync(client, cancellationToken: token)
.ConfigureAwait(false);
return await response
.AsJsonAsync<DeviceCodeData>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 验证用户授权状态 <br/>
/// 注:此方法不会检查是否过去了 Interval 秒,请自行处理
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelAuthenticationException">认证服务器返回设备授权错误</exception>
public async Task<AuthorizeResult?> AuthorizeWithDeviceAsync
(DeviceCodeData data,CancellationToken token,Dictionary<string,string>? extData = null)
{
if (data.IsError) throw new IdentityModelAuthenticationException(data.Error, data.ErrorDescription);
var client = options.GetClient.Invoke();
extData ??= new Dictionary<string, string>();
extData["client_id"] = options.ClientId;
extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code";
extData["device_code"] = data.DeviceCode!;
using var content = new FormUrlEncodedContent(extData);
using var response = await HttpRequest
.CreatePost(options.Meta.TokenEndpoint)
.WithContent(content)
.WithHeaders(options.Headers ?? [])
.SendAsync(client, cancellationToken: token)
.ConfigureAwait(false);
return await response
.AsJsonAsync<AuthorizeResult>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 刷新登录
/// </summary>
/// <param name="data"></param>
/// <param name="token"></param>
/// <param name="extData"></param>
/// <returns></returns>
/// <exception cref="IdentityModelAuthenticationException">认证服务器返回刷新授权错误</exception>
public async Task<AuthorizeResult?> AuthorizeWithSilentAsync
(AuthorizeResult data,CancellationToken token,Dictionary<string,string>? extData = null)
{
var client = options.GetClient.Invoke();
if (data.IsError) throw new IdentityModelAuthenticationException(data.Error, data.ErrorDescription);
extData ??= [];
extData["refresh_token"] = data.RefreshToken!;
extData["grant_type"] = "refresh_token";
extData["client_id"] = options.ClientId;
using var content = new FormUrlEncodedContent(extData);
using var response = await HttpRequest
.CreatePost(options.Meta.TokenEndpoint)
.WithHeaders(options.Headers ?? [])
.WithContent(content)
.SendAsync(client, cancellationToken: token)
.ConfigureAwait(false);
return await response
.AsJsonAsync<AuthorizeResult>(cancellationToken: token)
.ConfigureAwait(false);
}
}
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
public record OAuthClientOptions
{
/// <summary>
/// 请求头
/// </summary>
public Dictionary<string,string>? Headers { get; set; }
/// <summary>
/// 端点数据
/// </summary>
public required EndpointMeta Meta { get; set; }
public required Func<HttpClient> GetClient { get; set; }
/// <summary>
/// 重定向 Uri
/// </summary>
public required string RedirectUri { get; set; }
/// <summary>
/// 客户端 ID
/// </summary>
public required string ClientId { get; set; }
}
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
using PCL.Core.Utils.Exts;
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
public record DeviceCodeData
{
public bool IsError => !Error.IsNullOrEmpty();
/// <summary>
/// 错误类型
/// </summary>
[JsonPropertyName("error")]
public string? Error { get; init; }
/// <summary>
/// 错误描述
/// </summary>
[JsonPropertyName("error_description")]
public string? ErrorDescription { get; init; }
/// <summary>
/// 用户授权码
/// </summary>
[JsonPropertyName("user_code")]
public string? UserCode { get; init; }
/// <summary>
/// 设备授权码
/// </summary>
[JsonPropertyName("device_code")]
public string? DeviceCode { get; init; }
/// <summary>
/// 验证 Uri
/// </summary>
[JsonPropertyName("verification_uri")]
public string? VerificationUri { get; init; }
/// <summary>
/// 验证 Uri (自动填充代码)
/// </summary>
[JsonPropertyName("verification_uri_complete")]
public string? VerificationUriComplete { get; init; }
/// <summary>
/// 轮询间隔
/// </summary>
[JsonPropertyName("interval")]
public int? Interval { get; init; }
/// <summary>
/// 过期时间
/// </summary>
[JsonPropertyName("expires_in")]
public int? ExpiresIn { get; init; }
}
@@ -0,0 +1,17 @@
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
public record EndpointMeta
{
/// <summary>
/// 设备授权端点
/// </summary>
public string? DeviceEndpoint { get; set; }
/// <summary>
/// 授权端点
/// </summary>
public required string AuthorizeEndpoint { get; set; }
/// <summary>
/// 令牌端点
/// </summary>
public required string TokenEndpoint { get; set; }
}
@@ -0,0 +1,14 @@
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
namespace PCL.Core.Minecraft.IdentityModel.OAuth;
public interface IOAuthClient
{
public string GetAuthorizeUrl(string[] scopes,string state,Dictionary<string,string>? extData);
public Task<AuthorizeResult?> AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary<string,string>? extData = null);
public Task<DeviceCodeData?> GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary<string, string>? extData = null);
public Task<AuthorizeResult?> AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary<string,string>? extData = null);
public Task<AuthorizeResult?> AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary<string,string>? extData = null);
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
/// <summary>
/// Yggdrasil Agent
/// </summary>
public record Agent
{
[JsonPropertyName("name")] public string Name { get; init; } = "minecraft";
[JsonPropertyName("version")] public int Version { get; init; } = 1;
}
@@ -0,0 +1,167 @@
using System;
using System.Net;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.IO.Net.Http;
using PCL.Core.Utils;
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
/// <summary>
/// 提供 Yggdrasil 传统认证支持
/// </summary>
/// <param name="options"><see cref="YggdrasilLegacyAuthenticateOptions" /> 认证参数</param>
public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions options)
{
/// <summary>
/// 异步向服务器发送一次登录请求
/// </summary>
/// <param name="token"></param>
/// <returns><see cref="YggdrasilAuthenticateResult" /> 认证结果</returns>
/// <exception cref="ArgumentException">用户名或密码无效</exception>
public async Task<YggdrasilAuthenticateResult?> AuthenticateAsync(CancellationToken token)
{
ArgumentException.ThrowIfNullOrEmpty(options.Username);
ArgumentException.ThrowIfNullOrEmpty(options.Password);
var credential = new YggdrasilCredential
{
User = options.Username,
Password = options.Password
};
var address = $"{options.YggdrasilApiLocation}/authserver/authenticate";
using var response = await HttpRequest
.CreatePost(address)
.WithHeaders(options.Headers ?? [])
.WithJsonContent(credential)
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
return await response
.AsJsonAsync<YggdrasilAuthenticateResult>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 异步向服务器发送一次刷新请求
/// </summary>
/// <param name="token"></param>
/// <param name="seleectedProfile">如果需要选择角色,请填写此参数</param>
public async Task<YggdrasilAuthenticateResult?> RefreshAsync(CancellationToken token, Profile? seleectedProfile)
{
ArgumentException.ThrowIfNullOrEmpty(options.AccessToken);
var refreshData = new YggdrasilRefresh
{
AccessToken = options.AccessToken
};
if (seleectedProfile is not null) refreshData.SelectedProfile = seleectedProfile;
var address = $"{options.YggdrasilApiLocation}/authserver/refresh";
using var response = await HttpRequest
.CreatePost(address)
.WithJsonContent(refreshData)
.WithHeaders(options.Headers ?? [])
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
return await response
.AsJsonAsync<YggdrasilAuthenticateResult>(cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 异步向服务器发送一次验证请求
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task<bool> ValidateAsync(CancellationToken token)
{
ArgumentException.ThrowIfNullOrEmpty(options.AccessToken);
var validateData = new YggdrasilRefresh
{
AccessToken = options.AccessToken
};
var address = $"{options.YggdrasilApiLocation}/authserver/invalidate";
using var response = await HttpRequest
.CreatePost(address)
.WithHeaders(options.Headers ?? [])
.WithJsonContent(validateData)
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
return response.StatusCode == HttpStatusCode.NoContent;
}
/// <summary>
/// 异步向服务器发送一次注销请求
/// </summary>
/// <param name="token"></param>
public async Task InvalidateAsync(CancellationToken token)
{
ArgumentException.ThrowIfNullOrEmpty(options.AccessToken);
var validateData = new YggdrasilRefresh
{
AccessToken = options.AccessToken
};
var address = $"{options.YggdrasilApiLocation}/authserver/invalidate";
using var _ = await HttpRequest
.CreatePost(address)
.WithHeaders(options.Headers ?? [])
.WithJsonContent(validateData)
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
}
/// <summary>
/// 异步向服务器发送登出请求 <br />
/// 这会立刻注销所有会话,无论当前会话是否属于调用方
/// </summary>
/// <param name="token"></param>
/// <returns></returns>
public async Task<(bool IsSuccess, string ErrorDescription)> SignOutAsync(CancellationToken token)
{
// 不想写 Model 了,就这样吧(趴
var signoutData = new JsonObject
{
["username"] = options.Username,
["password"] = options.Password
};
var address = $"{options.YggdrasilApiLocation}/authserver/signout";
using var response = await HttpRequest
.CreatePost(address)
.WithHeaders(options.Headers ?? [])
.WithJsonContent(signoutData)
.SendAsync(options.GetClient.Invoke(), cancellationToken: token)
.ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NoContent)
return (true, string.Empty);
var content = await response.AsStringAsync(token).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(content))
return (false, string.Empty);
JsonNode? data;
try
{
data = JsonCompat.ParseNode(content);
}
catch
{
// 认证服务器偶尔会返回纯文本或 HTML 错误页,保留原始响应便于上层展示和诊断。
return (false, content);
}
var error = data?["errorMessage"]?.ToString() ?? string.Empty;
return (false, error);
}
}
@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
public record YggdrasilLegacyAuthenticateOptions
{
/// <summary>
/// API 基地址 (e.g. https://api.example.com/api/yggdrasil)
/// </summary>
public required string YggdrasilApiLocation { get; set; }
/// <summary>
/// 用户名
/// </summary>
public string? Username { get; set; }
/// <summary>
/// 密码
/// </summary>
public string? Password { get; set; }
/// <summary>
/// 访问令牌
/// </summary>
public string? AccessToken { get; set; }
public required Func<HttpClient> GetClient { get; set; }
/// <summary>
/// 请求头
/// </summary>
public Dictionary<string,string>? Headers { get; set; }
}
@@ -0,0 +1,88 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
public record Profile
{
/// <summary>
/// UUID
/// </summary>
[JsonPropertyName("id")] public required string Id { get; init; }
/// <summary>
/// 档案名称
/// </summary>
[JsonPropertyName("name")] public string? Name { get; init; }
/// <summary>
/// 属性信息
/// </summary>
[JsonPropertyName("properties")] public PlayerProperty[]? Properties { get; init; }
}
public record PlayerProperty
{
/// <summary>
/// 属性名称
/// </summary>
[JsonPropertyName("name")] public required string Name { get; init; }
/// <summary>
/// 属性值
/// </summary>
[JsonPropertyName("value")] public required string Value { get; init; }
/// <summary>
/// 数字签名
/// </summary>
[JsonPropertyName("signature")] public string? Signature { get; init; }
}
public record PlayerTextureProperty
{
/// <summary>
/// Unix 时间戳
/// </summary>
[JsonPropertyName("timestamp")] public required long Timestamp { get; init; }
/// <summary>
/// 所有者的 UUID
/// </summary>
[JsonPropertyName("profileId")] public required string ProfileId { get; init; }
/// <summary>
/// 所有者名称
/// </summary>
[JsonPropertyName("profileName")] public required string ProfileName { get; init; }
/// <summary>
/// 材质信息
/// </summary>
[JsonPropertyName("textures")] public required PlayerTextures Textures { get; init; }
}
public record PlayerTextures
{
/// <summary>
/// 皮肤
/// </summary>
[JsonPropertyName("skin")] public required PlayerTexture Skin { get; init; }
/// <summary>
/// 披风
/// </summary>
[JsonPropertyName("cape")] public required PlayerTexture Cape { get; init; }
}
public record PlayerTexture
{
/// <summary>
/// 材质地址
/// </summary>
[JsonPropertyName("Url")] public required string Url { get; init; }
/// <summary>
/// 元数据
/// </summary>
[JsonPropertyName("metadata")] public required PlayerTextureMetadata Metadata { get; init; }
}
public record PlayerTextureMetadata
{
/// <summary>
/// 模型信息 e.g. Steven -> default, Alex -> Slim
/// </summary>
[JsonPropertyName("model")] public required string Model { get; init; }
}
@@ -0,0 +1,49 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil;
public record YggdrasilCredential
{
[JsonPropertyName("username")] public required string User { get; init; }
[JsonPropertyName("password")] public required string Password { get; init; }
[JsonPropertyName("agent")] public Agent Agent { get; init; } = new();
[JsonPropertyName("requestUser")] public bool RequestUser { get; set; }
}
public record YggdrasilAuthenticateResult
{
/// <summary>
/// 错误类型
/// </summary>
[JsonPropertyName("error")] public string? Error { get; init; }
/// <summary>
/// 错误消息
/// </summary>
[JsonPropertyName("errorMessage")] public string? ErrorMessage { get; init; }
/// <summary>
/// 访问令牌
/// </summary>
[JsonPropertyName("accessToken")] public string? AccessToken { get; init; }
/// <summary>
/// 客户端令牌,基本没用
/// </summary>
[JsonPropertyName("clientToken")] public string? ClientToken { get; init; }
/// <summary>
/// 选择的档案
/// </summary>
[JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; init; }
/// <summary>
/// 可用档案
/// </summary>
[JsonPropertyName("availableProfiles")] public Profile[]? AvailableProfiles { get; init; }
/// <summary>
/// 用户信息
/// </summary>
[JsonPropertyName("user")] public Profile? User { get; init; }
}
public record YggdrasilRefresh
{
[JsonPropertyName("accessToken")] public required string AccessToken { get; set; }
[JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; set; }
}