初始化 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 元数据");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user