diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs new file mode 100644 index 000000000..e04862ee9 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebKeys.cs @@ -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; +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs new file mode 100644 index 000000000..dcb63fb15 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/JsonWebToken/JsonWebToken.cs @@ -0,0 +1,272 @@ +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; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken; + +/// +/// Json Web Token 类 +/// +/// JWT 令牌字符串 +/// OpenID 元数据 +public class JsonWebToken(string token, OpenIdMetadata meta) +{ + public delegate SecurityToken? TokenValidateCallback(OpenIdMetadata metadata, string token, JsonWebKey? key, string? clientId); + + /// + /// 安全令牌验证回调函数,默认验证签名、发行者、nbf 和 exp + /// + 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 != null, + IssuerSigningKey = key != 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(); + + /// + /// 解析令牌(不验证签名) + /// + /// 解析后的 JWT 令牌对象 + /// 令牌格式无效 + private JwtSecurityToken _ParseToken() + { + if (_parsedToken != 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); + } + } + + /// + /// 尝试读取 Token 中的字段 + /// + /// 是否允许在未验证的情况下读取字段,若为 false,当 Token 未验证时将抛出异常 + /// 声明值的目标类型 + /// 解析后的声明对象 + /// 未调用 VerifySignature() 且 allowUnverifyToken 为 false + /// 令牌中不存在 payload 数据 + public T? ReadTokenPayload(bool allowUnverifyToken = false) + { + if (!allowUnverifyToken && !_verified) + throw new SecurityException("不安全的令牌"); + + try + { + var jwtToken = _ParseToken(); + + if (jwtToken.Payload == null || jwtToken.Payload.Count == 0) + throw new InvalidOperationException("令牌 Payload 无效"); + + if (typeof(T).IsAssignableFrom(typeof(Dictionary))) + return (T)(object)jwtToken.Payload; + + if (typeof(T) == typeof(JwtPayload)) + return (T)(object)jwtToken.Payload; + + var payloadJson = JsonSerializer.Serialize(jwtToken.Payload); + var result = JsonSerializer.Deserialize(payloadJson); + + return result; + } + catch (SecurityException) + { + throw; + } + catch (Exception ex) + { + throw new SecurityException($"读取令牌 payload 失败:{ex.Message}", ex); + } + } + + /// + /// 读取 Token 头 + /// + /// 声明值的目标类型 + /// 解析后的头对象 + /// 令牌中不存在 header 数据 + public T? ReadTokenHeader() + { + try + { + var jwtToken = _ParseToken(); + + if (jwtToken.Header == null || jwtToken.Header.Count == 0) + throw new InvalidOperationException("令牌中不存在 header 数据"); + + if (typeof(T).IsAssignableFrom(typeof(Dictionary))) + return (T)(object)jwtToken.Header; + + if (typeof(T) == typeof(JwtHeader)) + return (T)(object)jwtToken.Header; + + var headerJson = JsonSerializer.Serialize(jwtToken.Header); + var result = JsonSerializer.Deserialize(headerJson); + + return result; + } + catch (Exception ex) + { + throw new SecurityException($"读取令牌 header 失败:{ex.Message}", ex); + } + } + + /// + /// 对 Token 进行签名验证
+ /// 默认情况下仅对签名、iss、nbf、exp 进行验证,如果需要更细粒度验证,请设置 + ///
+ /// 用于验证签名的 JSON Web Key + /// 预期的受众(audience),可选 + /// 验证成功返回 SecurityToken 对象,否则返回 null + public SecurityToken? VerifySignature(JsonWebKey key, string? clientId = null) + { + try + { + var result = SecurityTokenValidateCallback.Invoke(meta, token, key, clientId); + if (result != null) + _verified = true; + return result; + } + catch (Exception ex) + { + throw new SecurityException($"令牌签名验证失败:{ex.Message}", ex); + } + } + + /// + /// 重载方法,用于无参调用验证(仅验证基本声明) + /// + /// 验证成功返回 SecurityToken 对象,否则返回 null + public SecurityToken? VerifySignature() + { + try + { + var result = SecurityTokenValidateCallback.Invoke(meta, token, null, null); + if (result != null) + _verified = true; + return result; + } + catch (Exception ex) + { + throw new SecurityException($"令牌验证失败:{ex.Message}", ex); + } + } + + /// + /// 获取令牌的过期时间 + /// + /// 过期时间,若不存在则返回 null + public DateTime? GetExpirationTime() + { + try + { + var jwtToken = _ParseToken(); + return jwtToken.ValidTo != DateTime.MinValue ? jwtToken.ValidTo : null; + } + catch (Exception ex) + { + throw new SecurityException($"获取令牌过期时间失败:{ex.Message}", ex); + } + } + + /// + /// 获取令牌的签发时间 + /// + /// 签发时间,若不存在则返回 null + public DateTime? GetIssuedAtTime() + { + try + { + var jwtToken = _ParseToken(); + return jwtToken.ValidFrom != DateTime.MinValue ? jwtToken.ValidFrom : null; + } + catch (Exception ex) + { + throw new SecurityException($"获取令牌签发时间失败:{ex.Message}", ex); + } + } + + /// + /// 检查令牌是否已过期 + /// + /// 若已过期返回 true,否则返回 false + public bool IsExpired() + { + try + { + var expTime = GetExpirationTime(); + return expTime.HasValue && DateTime.UtcNow > expTime.Value; + } + catch + { + return true; // 如果无法解析,视为已过期 + } + } + + /// + /// 获取特定声明的值 + /// + /// 声明类型 + /// 是否允许在未验证的情况下读取 + /// 声明值,若不存在则返回 null + public string? GetClaimValue(string claimType, bool allowUnverifyToken = false) + { + try + { + var payload = ReadTokenPayload>(allowUnverifyToken); + return payload?.TryGetValue(claimType, out var value) ?? false ? value.ToString() : null; + } + catch (Exception ex) + { + throw new SecurityException($"获取声明值失败({claimType}):{ex.Message}", ex); + } + } + + /// + /// 获取原始令牌字符串 + /// + /// JWT 令牌字符串 + public string GetTokenString() => token; + + /// + /// 检查令牌验证状态 + /// + public bool IsVerified => _verified; +} diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs new file mode 100644 index 000000000..d03539a08 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdClient.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Documents; +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; + /// + /// 初始化并从网络加载 OpenId 配置 + /// + /// + /// + /// 当要求检查地址并不存在任何授权端点时,将触发此错误 + public async Task InitializeAsync(CancellationToken token,bool checkAddress = false) + { + var opt = await options.BuildOAuthOptionsAsync(token); + if (!checkAddress || opt.Meta.AuthorizeEndpoint.IsNullOrEmpty() || opt.Meta.DeviceEndpoint.IsNullOrEmpty()) + { + _client = options.EnablePkceSupport ? new PkceClient(opt) : new SimpleOAuthClient(opt); + return; + } + + throw new InvalidOperationException(); + } + /// + /// 获取授权代码流地址 + /// + /// 权限列表 + /// + /// 扩展数据 + /// + /// 未调用 + public string GetAuthorizeUrl(string[] scopes, string state,Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return _client.GetAuthorizeUrl(scopes, state, extData); + } + /// + /// 使用授权代码兑换 Token + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithCodeAsync(code, token, extData); + } + /// + /// 获取设备代码流代码对 + /// + /// + /// + /// + /// + /// + public async Task GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.GetCodePairAsync(scopes, token, extData); + } + /// + /// 发起一次验证,以检查认证是否成功 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithDeviceAsync(data, token, extData); + } + /// + /// 进行一次刷新调用 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithSilentAsync(data, token, extData); + } +} diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs new file mode 100644 index 000000000..151a5de0a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdMetaData.cs @@ -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 ScopesSupported { get; init; } + + [JsonPropertyName("subject_types_supported")] + public required IReadOnlyList SubjectTypesSupported { get; init; } + + [JsonPropertyName("id_token_signing_alg_values_supported")] + public required IReadOnlyList IdTokenSigningAlgValuesSupported { get; init; } + + +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs new file mode 100644 index 000000000..e9b5f0b74 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/OpenId/OpenIdOptions.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using PCL.Core.Minecraft.IdentityModel.Extensions.JsonWebToken; +using PCL.Core.Minecraft.IdentityModel.OAuth; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; + +public record OpenIdOptions +{ + /// + /// OpenId Discovery 地址 + /// + public required string OpenIdDiscoveryAddress { get; set; } + /// + /// 客户端 ID(必须设置) + /// + public required string ClientId + { + get; + set; + } + + // 为了让 YggdrasilConnect Client 复用代码做的逻辑 + + /// + /// 是否只使用设备代码流授权 + /// + public bool OnlyDeviceAuthorize { get; set; } + /// + /// 回调 Uri + /// + public string? RedirectUri { get; set; } + /// + /// 发送 HTTP 请求时设置的请求头,仅适用于请求头(丢到 HttpRequestMessage 不会报错的那种) + /// + public Dictionary? Headers { get; set; } + /// + /// 是否启用 PKCE 支持,默认启用 + /// + public bool EnablePkceSupport { get; set; } = true; + /// + /// 获取 HttpClient,生命周期由调用方管理 + /// + public required Func GetClient { get; set; } + /// + /// OpenId 元数据,请勿自行设置此属性,而是应该调用 + /// + public OpenIdMetadata? Meta { get; internal set; } + + /// + /// 从互联网拉取 OpenID 配置信息 + /// + /// + public virtual async Task InitializeAsync(CancellationToken token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, OpenIdDiscoveryAddress); + if (Headers is not null) + foreach (var kvp in Headers) + { + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + + var requestTask = GetClient.Invoke().SendAsync(request, token); + using var response = await requestTask; + var task = response.Content.ReadAsStringAsync(token); + Meta = JsonSerializer.Deserialize(await task); + } + /// + /// 获取 Json Web Key + /// + /// 密钥 ID + /// + /// + /// 未调用 + /// 找不到 Jwk 或 Jwk 配置无效 + public async Task GetSignatureKeyAsync(string kid,CancellationToken token) + { + if (Meta?.JwksUri is null) throw new InvalidOperationException(); + using var request = new HttpRequestMessage(HttpMethod.Get, Meta.JwksUri); + if (Headers is not null) + foreach (var kvp in Headers) + { + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + using var response = await GetClient.Invoke().SendAsync(request, token); + var result = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + return result?.Keys.Single(k => k.Kid == kid) + ?? throw new FormatException(); + } + /// + /// 构建 OAuth 客户端配置 + /// + /// + /// + /// 未调用 + public virtual async Task BuildOAuthOptionsAsync(CancellationToken token) + { + if (Meta is null) throw new InvalidOperationException(); + if(!OnlyDeviceAuthorize) ArgumentException.ThrowIfNullOrEmpty(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, + } + }; + } + +} diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs new file mode 100644 index 000000000..20d39ac80 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceChallengeOptions.cs @@ -0,0 +1,7 @@ +namespace PCL.Core.Minecraft.IdentityModel.Extensions.Pkce; + +public enum PkceChallengeOptions +{ + Sha256, + PlainText +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs new file mode 100644 index 000000000..4ef2e8d38 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/Pkce/PkceClient.cs @@ -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; + +/// +/// 带 PKCE 支持的客户端
+/// 此客户端并非线程安全,请勿在多个线程间共享示例 +///
+/// +public class PkceClient(OAuthClientOptions options):IOAuthClient +{ + private byte[] _ChallengeCode { get; set; } = new byte[32]; + private bool _isCallGetAuthorizeUrl; + /// + /// 设置验证方法,支持 PlainText 和 SHA256 + /// + public PkceChallengeOptions ChallengeMethod { get; private set; } = PkceChallengeOptions.Sha256; + private readonly SimpleOAuthClient _client = new(options); + /// + /// 获取授权地址 + /// + /// + /// + /// + /// + public string GetAuthorizeUrl(string[] scopes, string state, Dictionary? 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); + } + /// + /// 使用授权代码兑换令牌 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary? 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); + } + /// + /// 获取代码对 + /// + /// + /// + /// + /// + public async Task GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary? extData = null) + { + return await _client.GetCodePairAsync(scopes, token, extData); + } + /// + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary? extData = null) + { + return await _client.AuthorizeWithDeviceAsync(data, token, extData); + } + + public async Task AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary? extData = null) + { + return await _client.AuthorizeWithSilentAsync(data, token, extData); + } +} diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs new file mode 100644 index 000000000..c072581a8 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilClient.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; +using PCL.Core.Minecraft.IdentityModel.OAuth; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect; + +// Steven Qiu 说这东西完全就是 OpenId + 魔改了一部分,所以可以直接复用 OpenId 的逻辑 + +/// +/// +/// +public class YggdrasilClient:IOAuthClient +{ + + private OpenIdClient? _client; + + private YggdrasilOptions _options; + + public YggdrasilClient(YggdrasilOptions options) + { + _options = options; + } + /// + /// 初始化并拉取网络配置 + /// + /// 当无法获取 ClientId 时抛出,调用方应该设置 ClientId 并重新实例化 Client + /// + public async Task InitializeAsync(CancellationToken token) + { + _client = new OpenIdClient(_options); + await _client.InitializeAsync(token,true); + } + /// + /// 获取授权端点地址 + /// + /// + /// + /// + /// + /// 未调用 + public string GetAuthorizeUrl(string[] scopes, string state, Dictionary? extData) + { + if (_client is null) throw new InvalidOperationException(); + return _client.GetAuthorizeUrl(scopes, state, extData); + } + /// + /// 使用授权代码兑换令牌 + /// + /// + /// + /// + /// + /// 未调用 + public async Task AuthorizeWithCodeAsync(string code, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithCodeAsync(code, token, extData); + + } + /// + /// 获取代码对 + /// + /// + /// + /// + /// + /// 未调用 + public async Task GetCodePairAsync(string[] scopes, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.GetCodePairAsync(scopes, token, extData); + + } + /// + /// 发起一次请求验证用户授权状态 + /// + /// + /// + /// + /// + /// 未调用 + public async Task AuthorizeWithDeviceAsync(DeviceCodeData data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithDeviceAsync(data, token, extData); + + } + /// + /// 刷新登录 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithSilentAsync(AuthorizeResult data, CancellationToken token, Dictionary? extData = null) + { + if (_client is null) throw new InvalidOperationException(); + return await _client.AuthorizeWithSilentAsync(data, token, extData); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs new file mode 100644 index 000000000..539915432 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilConnectMetaData.cs @@ -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; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs new file mode 100644 index 000000000..90dac453a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Extensions/YggdrasilConnect/YggdrasilOptions.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using PCL.Core.Minecraft.IdentityModel.Extensions.OpenId; +using PCL.Core.Minecraft.IdentityModel.OAuth; +using PCL.Core.Utils.Exts; + +namespace PCL.Core.Minecraft.IdentityModel.Extensions.YggdrasilConnect; + +public record YggdrasilOptions:OpenIdOptions +{ + private string[] _scopesRequired = ["openid", "Yggdrasil.PlayerProfiles.Select", "Yggdrasil.Server.Join"]; + + // 重写这个鬼方法是因为 Yggdrasil Connect 有要求( + + /// + /// 拉取 Yggdrasil 配置 + /// + /// + /// + public override async Task InitializeAsync(CancellationToken token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, OpenIdDiscoveryAddress); + if (Headers is not null) + foreach (var kvp in Headers) + { + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + + using var response = await GetClient.Invoke().SendAsync(request, token); + Meta = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + if (Meta is null) throw new InvalidOperationException(); + if (_scopesRequired.Except(Meta.ScopesSupported).Any()) throw new InvalidOperationException(); + } + /// + /// 构建 OAuth 客户端选项 + /// + /// + /// + /// 未调用 + /// + /// + public override async Task BuildOAuthOptionsAsync(CancellationToken token) + { + if (Meta is YggdrasilConnectMetaData meta) + { + var options = await base.BuildOAuthOptionsAsync(token); + if (!options.ClientId.IsNullOrEmpty()) return options; + if (meta is null) throw new InvalidOperationException(); + if (!meta.SharedClientId.IsNullOrEmpty()) + { + options.ClientId = meta.SharedClientId; + } + + throw new ArgumentException(); + } + + throw new InvalidCastException(); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs new file mode 100644 index 000000000..4b46d4508 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/AuthorizeResult.cs @@ -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(); + /// + /// 错误类型 (e.g. invalid_request) + /// + [JsonPropertyName("error")] public string? Error { get; init; } + /// + /// 描述此错误的文本 + /// + [JsonPropertyName("error_description")] public string? ErrorDescription { get; init; } + + // 不用 SecureString,因为这东西依赖 DPAPI,不是最佳实践 + + /// + /// 访问令牌 + /// + [JsonPropertyName("access_token")] public string? AccessToken { get; init; } + /// + /// 刷新令牌 + /// + [JsonPropertyName("refresh_token")] public string? RefreshToken { get; init; } + /// + /// ID Token + /// + [JsonPropertyName("id_token")] public string? IdToken { get; init; } + /// + /// 令牌类型 + /// + [JsonPropertyName("token_type")] public string? TokenType { get; init; } + /// + /// 过期时间 + /// + [JsonPropertyName("expires_in")] public int? ExpiresIn { get; init; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs new file mode 100644 index 000000000..b9a9a83de --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/Client.cs @@ -0,0 +1,144 @@ +using System; +using System.Text; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using System.Collections.Generic; +using System.Threading; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +/// +/// OAuth 客户端实现,配合 Polly 食用效果更佳 +/// +/// OAuth 参数 +public sealed class SimpleOAuthClient(OAuthClientOptions options):IOAuthClient +{ + /// + /// 获取授权 Url + /// + /// 访问权限列表 + /// + /// + /// + public string GetAuthorizeUrl(string[] scopes,string state,Dictionary? 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(); + } + + /// + /// 使用授权代码获取令牌 + /// + /// 授权代码 + /// 附加属性,不应该包含必须参数和预定义字段 (e.g. client_id、grant_type) + /// + /// + public async Task AuthorizeWithCodeAsync( + string code,CancellationToken token,Dictionary? extData = null + ) + { + extData ??= new Dictionary(); + 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 request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 获取设备代码对 + /// + /// + /// + /// + /// + public async Task GetCodePairAsync + (string[] scopes,CancellationToken token, Dictionary? extData = null) + { + ArgumentException.ThrowIfNullOrEmpty(options.Meta.DeviceEndpoint); + var client = options.GetClient.Invoke(); + extData ??= new Dictionary(); + extData["scope"] = string.Join(" ", scopes); + extData["client_id"] = options.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post, options.Meta.DeviceEndpoint); + var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 验证用户授权状态
+ /// 注:此方法不会检查是否过去了 Interval 秒,请自行处理 + ///
+ /// + /// + /// + /// + /// + public async Task AuthorizeWithDeviceAsync + (DeviceCodeData data,CancellationToken token,Dictionary? extData = null) + { + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + var client = options.GetClient.Invoke(); + extData ??= new Dictionary(); + extData["client_id"] = options.ClientId; + extData["grant_type"] = "urn:ietf:params:oauth:grant-type:device_code"; + extData["device_code"] = data.DeviceCode!; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } + /// + /// 刷新登录 + /// + /// + /// + /// + /// + /// + public async Task AuthorizeWithSilentAsync + (AuthorizeResult data,CancellationToken token,Dictionary? extData = null) + { + var client = options.GetClient.Invoke(); + if (data.IsError) throw new OperationCanceledException(data.ErrorDescription); + extData ??= new Dictionary(); + extData["refresh_token"] = data.RefreshToken!; + extData["grant_type"] = "refresh_token"; + extData["client_id"] = options.ClientId; + using var request = new HttpRequestMessage(HttpMethod.Post,options.Meta.TokenEndpoint); + using var content = new FormUrlEncodedContent(extData); + request.Content = content; + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key,kvp.Value); + using var response = await client.SendAsync(request,token); + var result = await response.Content.ReadAsStringAsync(token); + return JsonSerializer.Deserialize(result); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs new file mode 100644 index 000000000..24835ee20 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/ClientOptions.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public record OAuthClientOptions +{ + /// + /// 请求头 + /// + public Dictionary? Headers { get; set; } + /// + /// 端点数据 + /// + public required EndpointMeta Meta { get; set; } + public required Func GetClient { get; set; } + /// + /// 重定向 Uri + /// + public required string RedirectUri { get; set; } + /// + /// 客户端 ID + /// + public required string ClientId { get; set; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs new file mode 100644 index 000000000..0dd8b2f22 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/DeviceCodeData.cs @@ -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(); + /// + /// 错误类型 + /// + [JsonPropertyName("error")] + public string? Error { get; init; } + /// + /// 错误描述 + /// + [JsonPropertyName("error_description")] + public string? ErrorDescription { get; init; } + /// + /// 用户授权码 + /// + [JsonPropertyName("user_code")] + public string? UserCode { get; init; } + /// + /// 设备授权码 + /// + [JsonPropertyName("device_code")] + public string? DeviceCode { get; init; } + /// + /// 验证 Uri + /// + [JsonPropertyName("verification_uri")] + public string? VerificationUri { get; init; } + /// + /// 验证 Uri (自动填充代码) + /// + [JsonPropertyName("verification_uri_complete")] + public string? VerificationUriComplete { get; init; } + /// + /// 轮询间隔 + /// + [JsonPropertyName("interval")] + public int? Interval { get; init; } + /// + /// 过期时间 + /// + [JsonPropertyName("expires_in")] + public int? ExpiresIn { get; init; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs new file mode 100644 index 000000000..c26c9ec8a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/EndpointMeta.cs @@ -0,0 +1,17 @@ +namespace PCL.Core.Minecraft.IdentityModel.OAuth; + +public record EndpointMeta +{ + /// + /// 设备授权端点 + /// + public string? DeviceEndpoint { get; set; } + /// + /// 授权端点 + /// + public required string AuthorizeEndpoint { get; set; } + /// + /// 令牌端点 + /// + public required string TokenEndpoint { get; set; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs b/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs new file mode 100644 index 000000000..dd035367a --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/OAuth/IOAuthClient.cs @@ -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? extData); + public Task AuthorizeWithCodeAsync(string code,CancellationToken token,Dictionary? extData = null); + public Task GetCodePairAsync(string[] scopes,CancellationToken token, Dictionary? extData = null); + public Task AuthorizeWithDeviceAsync(DeviceCodeData data,CancellationToken token,Dictionary? extData = null); + public Task AuthorizeWithSilentAsync(AuthorizeResult data,CancellationToken token,Dictionary? extData = null); +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs new file mode 100644 index 000000000..06ed1a007 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Agent.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json.Serialization; +using System.Threading.Tasks; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +/// +/// Yggdrasil Agent +/// +public record Agent +{ + [JsonPropertyName("name")] public string Name { get; init; } = "minecraft"; + [JsonPropertyName("version")] public int Version { get; init; } = 1; +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs new file mode 100644 index 000000000..13c6a0654 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Client.cs @@ -0,0 +1,152 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +/// +/// 提供 Yggdrasil 传统认证支持 +/// +/// 认证参数 +public sealed class YggdrasilLegacyClient(YggdrasilLegacyAuthenticateOptions options) +{ + /// + /// 异步向服务器发送一次登录请求 + /// + /// + /// 认证结果 + /// 用户名或密码无效 + public async Task 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 request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + + using var content = + new StringContent(JsonSerializer.Serialize(credential), Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request,token); + return + JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + + } + /// + /// 异步向服务器发送一次刷新请求 + /// + /// + /// 如果需要选择角色,请填写此参数 + public async Task 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 request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + using var content = new StringContent( + JsonSerializer.Serialize(refreshData), Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request, token); + return JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync(token)); + } + /// + /// 异步向服务器发送一次验证请求 + /// + /// + /// + public async Task ValidateAsync(CancellationToken token) + { + ArgumentException.ThrowIfNullOrEmpty(options.AccessToken); + + var validateData = new YggdrasilRefresh() + { + AccessToken = options.AccessToken + }; + var address = $"{options.YggdrasilApiLocation}/authserver/invalidate"; + + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + + using var content = new StringContent( + JsonSerializer.Serialize(validateData), Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request, token); + return response.StatusCode == HttpStatusCode.NoContent; + } + + /// + /// 异步向服务器发送一次注销请求 + /// + /// + 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 request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + + using var content = new StringContent( + JsonSerializer.Serialize(validateData), Encoding.UTF8, "application/json"); + request.Content = content; + await options.GetClient.Invoke().SendAsync(request, token); + } + /// + /// 异步向服务器发送登出请求
+ /// 这会立刻注销所有会话,无论当前会话是否属于调用方 + ///
+ /// + /// + public async Task<(bool IsSuccess,string ErrorDescription)> SignOutAsync(CancellationToken token) + { + // 不想写 Model 了,就这样吧(趴 + var signoutData = new JsonObject + { + ["username"] = options.Username, + ["password"] = options.Password + }.ToJsonString(); + var address = $"{options.YggdrasilApiLocation}/authserver/signout"; + + using var request = new HttpRequestMessage(HttpMethod.Post, address); + if(options.Headers is not null) + foreach (var kvp in options.Headers) + _ = request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + using var content = new StringContent(signoutData, Encoding.UTF8, "application/json"); + request.Content = content; + using var response = await options.GetClient.Invoke().SendAsync(request, token); + var data = JsonNode.Parse(await response.Content.ReadAsStringAsync(token)); + return (response.StatusCode == HttpStatusCode.NoContent, data?["errorMessage"]?.ToString()!); + } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs new file mode 100644 index 000000000..90581a8c9 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Options.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + +public record YggdrasilLegacyAuthenticateOptions +{ + /// + /// API 基地址 (e.g. https://api.example.com/api/yggdrasil) + /// + public required string YggdrasilApiLocation { get; set; } + /// + /// 用户名 + /// + public string? Username { get; set; } + /// + /// 密码 + /// + public string? Password { get; set; } + /// + /// 访问令牌 + /// + public string? AccessToken { get; set; } + public required Func GetClient { get; set; } + /// + /// 请求头 + /// + public Dictionary? Headers { get; set; } +} diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs new file mode 100644 index 000000000..fe580ac96 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/Profile.cs @@ -0,0 +1,88 @@ +using System.Text.Json.Serialization; + +namespace PCL.Core.Minecraft.IdentityModel.Yggdrasil; + + +public record Profile +{ + /// + /// UUID + /// + [JsonPropertyName("id")] public required string Id { get; init; } + /// + /// 档案名称 + /// + [JsonPropertyName("name")] public string? Name { get; init; } + /// + /// 属性信息 + /// + [JsonPropertyName("properties")] public PlayerProperty[]? Properties { get; init; } +} + +public record PlayerProperty +{ + /// + /// 属性名称 + /// + [JsonPropertyName("name")] public required string Name { get; init; } + /// + /// 属性值 + /// + [JsonPropertyName("value")] public required string Value { get; init; } + /// + /// 数字签名 + /// + [JsonPropertyName("signature")] public string? Signature { get; init; } +} + +public record PlayerTextureProperty +{ + /// + /// Unix 时间戳 + /// + [JsonPropertyName("timestamp")] public required long Timestamp { get; init; } + /// + /// 所有者的 UUID + /// + [JsonPropertyName("profileId")] public required string ProfileId { get; init; } + /// + /// 所有者名称 + /// + [JsonPropertyName("profileName")] public required string ProfileName { get; init; } + /// + /// 材质信息 + /// + [JsonPropertyName("textures")] public required PlayerTextures Textures { get; init; } +} + +public record PlayerTextures +{ + /// + /// 皮肤 + /// + [JsonPropertyName("skin")] public required PlayerTexture Skin { get; init; } + /// + /// 披风 + /// + [JsonPropertyName("cape")] public required PlayerTexture Cape { get; init; } +} + +public record PlayerTexture +{ + /// + /// 材质地址 + /// + [JsonPropertyName("Url")] public required string Url { get; init; } + /// + /// 元数据 + /// + [JsonPropertyName("metadata")] public required PlayerTextureMetadata Metadata { get; init; } +} + +public record PlayerTextureMetadata +{ + /// + /// 模型信息 (e.g. Steven -> default, Alex -> Slim) + /// + [JsonPropertyName("model")] public required string Model { get; init; } +} \ No newline at end of file diff --git a/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs new file mode 100644 index 000000000..415cab4a1 --- /dev/null +++ b/PCL.Core/Minecraft/IdentityModel/Yggdrasil/YggdrasilCredential.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; +using PCL.Core.Link.Scaffolding.Client.Models; + +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 = new(); + [JsonPropertyName("requestUser")] public bool RequestUser { get; set; } +} + +public record YggdrasilAuthenticateResult +{ + /// + /// 错误类型 + /// + [JsonPropertyName("error")] public string? Error { get; init; } + /// + /// 错误消息 + /// + [JsonPropertyName("errorMessage")] public string? ErrorMessage { get; init; } + /// + /// 访问令牌 + /// + [JsonPropertyName("accessToken")] public string? AccessToken { get; init; } + /// + /// 客户端令牌,基本没用 + /// + [JsonPropertyName("clientToken")] public string? ClientToken { get; init; } + /// + /// 选择的档案 + /// + [JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; init; } + /// + /// 可用档案 + /// + [JsonPropertyName("availableProfiles")] public required Profile[]? AvailableProfiles { get; init; } + /// + /// 用户信息 + /// + [JsonPropertyName("user")] public Profile? User; +} + +public record YggdrasilRefresh +{ + [JsonPropertyName("accessToken")] public required string AccessToken { get; set; } + [JsonPropertyName("selectedProfile")] public Profile? SelectedProfile { get; set; } +} \ No newline at end of file diff --git a/PCL.Core/PCL.Core.csproj b/PCL.Core/PCL.Core.csproj index 32fe6e52c..b8afc18c3 100644 --- a/PCL.Core/PCL.Core.csproj +++ b/PCL.Core/PCL.Core.csproj @@ -1,95 +1,97 @@ - - - - PCL.Core - PCL.Core - PCL Community 为 PCL 开发的启动器核心库 - PCL Community - PCL.Core - Copyright © PCL Community - 1.0.0.0 - 1.0.0.0 - - - Debug - AnyCPU - Debug;CI;Release;Beta - AnyCPU;x64;ARM64 - {A0C2209D-64FB-4C11-9459-8E86304B6F94} - PCL.Core - net8.0-windows - true - true - true - 14.0 - enable - prompt - 4 - bin\$(Configuration)-$(Platform)\ - $(Platform) - $(NoWarn);CS9113 - - - - true - full - false - DEBUG;TRACE - CI;TRACE - - - none - true - RELEASE;PUBLISH - BETA;PUBLISH - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - false - false - false - false - false - false - - - - - - - - - - - - + + + + PCL.Core + PCL.Core + PCL Community 为 PCL 开发的启动器核心库 + PCL Community + PCL.Core + Copyright © PCL Community + 1.0.0.0 + 1.0.0.0 + + + Debug + AnyCPU + Debug;CI;Release;Beta + AnyCPU;x64;ARM64 + {A0C2209D-64FB-4C11-9459-8E86304B6F94} + PCL.Core + net8.0-windows + true + true + true + 14.0 + enable + prompt + 4 + bin\$(Configuration)-$(Platform)\ + $(Platform) + $(NoWarn);CS9113 + + + + true + full + false + DEBUG;TRACE + CI;TRACE + + + none + true + RELEASE;PUBLISH + BETA;PUBLISH + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + false + false + false + false + false + false + + + + + + + + + + + + \ No newline at end of file