初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
+9
@@ -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; }
|
||||
}
|
||||
+273
@@ -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,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
+111
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
@@ -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; }
|
||||
}
|
||||
+61
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user