XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

认证 ​

本章讲调用凭据怎样变成主体:凭据与认证处理器、Authenticate 阶段、ApiKey / JWT / OpenID Connect 三种认证 Provider、401 质询,以及绕过管道的端点怎样复用同一套认证,适用于需要识别调用方身份的应用。完整 API 见 Security.Abstractions 与 Security.InMemory、Security.Jwt、Security.OpenIdConnect 包页。

凭据、处理器与主体 ​

认证分三步,分属三个角色。宿主在 Ingress 阶段把协议里的凭据原样放进调用上下文(SetCredential),自己不解释它;Authenticate 阶段的 AuthenticationMiddleware 按方案名找到 IAuthenticationHandler,由它把凭据变成主体 Principal;主体写进安全上下文 SecurityContext.Principal,之后的阶段只读。认证是能力 security.authentication@1,换认证方式就是换 Provider,宿主与业务代码不动。

认证在内核管道里完成,不依赖 ASP.NET Core 的认证中间件;同一个处理器服务 HTTP、gRPC、MCP 与命令行。ASP.NET Core 的认证只在绕过管道的端点上用到。

凭据是 AuthenticationCredential:方案(Scheme,如 Bearer、ApiKey,比较时不区分大小写)与值(Value)。AuthenticationCredential.TryParse 解析 <scheme> <value> 形式,方案或值为空都不算凭据;ToString() 把值显示为 ***。

宿主凭据从哪来
ASP.NET Core(HTTP、gRPC、MCP)Authorization 头;没有时,请求路径落在登记的查询串令牌路径下才取查询串 access_token,作为 Bearer 凭据
命令行--authorization '<scheme> <value>',或环境变量 XIHAN_AUTHORIZATION

Authorization 头格式不对(如只有 Bearer)时视为没有凭据,调用按匿名继续。

Authenticate 阶段 ​

AuthenticationMiddleware 的处理顺序:

  1. 调用上下文里没有凭据:保持匿名,进入第 5 步。
  2. 在容器里的 IAuthenticationHandler 中按方案名(不区分大小写)找第一个匹配者;找不到返回 SECURITY_VALIDATION_001。
  3. 处理器返回 Result<Principal>。凭据无效、过期、签名不符一律返回 SECURITY_DENIED_003,不区分原因对外暴露;处理器内部的基础设施故障以异常抛出,由内核转成失败结果。
  4. 认证成功:写入新的安全上下文,保留已有的租户。
  5. RequireAuthentication 为真且主体仍是匿名:返回 SECURITY_DENIED_001。

RequireAuthentication 缺省为假:匿名调用放行,由各操作的授权要求决定是否需要登录。

键(XiHan:Security)类型缺省说明
RequireAuthenticationboolfalse为真时匿名调用在 Authenticate 阶段被拒绝
RoleClaimstringrole承载角色的声明类型;RBAC 检查器与 ASP.NET Core 方案 XiHan 都读它
json
{
  "XiHan": {
    "Security": {
      "RequireAuthentication": false,
      "RoleClaim": "role"
    }
  }
}

读取主体 ​

Principal 不可变:

成员说明
Id主体 ID;匿名主体为空字符串
AuthenticationScheme认证方案;匿名主体为 null
IsAuthenticated方案不为空且 Id 非空
DisplayName显示名
Claims声明列表(KeyValuePair<string, string>),同一类型可以有多个值
ClaimValues(type)取某类型的全部声明值,类型按序数比较
HasClaim(type, value)是否有这条声明

声明按原始类型名保留(sub、role、tenant),不映射成 .NET 的长类型 URI。匿名主体是 Principal.Anonymous,匿名无租户的安全上下文是 SecurityContext.Anonymous。

操作契约的处理器从调用上下文读:

csharp
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;

public static class CallerInfo
{
    public static string Describe(InvocationContext context)
    {
        var security = context.GetSecurity();
        return security.Principal.IsAuthenticated
            ? $"{security.Principal.Id} ({security.Principal.AuthenticationScheme}) @ {security.Tenant?.Id ?? "-"}"
            : "anonymous";
    }
}

经 UseXiHan() 的普通端点用 http.GetXiHanContext() 取调用上下文,SimpleApp 的 /whoami 端点就是这样返回主体与租户的。

选择认证 Provider ​

包方案清单角色适用
Security.InMemoryApiKeydefault开发、原型、测试;Profiles.Standard 已包含
Security.JwtBearerprovider应用自己签发的 HS256 令牌
Security.OpenIdConnectBearerprovider外部身份提供方签发的令牌(RS256、ES256 等),签名密钥取自 JWKS

Security.InMemory 同时提供认证与授权。装配 Security.Jwt 或 Security.OpenIdConnect 后,它让出认证、只保留授权(RBAC 授予存储)。Security.Jwt 与 Security.OpenIdConnect 都提供 security.authentication@1,同一时刻只装配其一。

JWT 与 OpenID Connect Provider 只校验令牌,不签发令牌;令牌由应用的登录服务或外部身份提供方签发。

ApiKey(内存) ​

主体与 API Key 写在配置里,凭据是 Authorization: ApiKey <key>。下面的主体来自 framework/samples/SimpleApp:

json
{
  "XiHan": {
    "Security": {
      "InMemory": {
        "Principals": {
          "alice": { "ApiKey": "dev-alice-key", "DisplayName": "Alice", "Roles": "editor", "Tenant": "acme" },
          "bob": { "ApiKey": "dev-bob-key", "DisplayName": "Bob", "Roles": "viewer", "Tenant": "acme" },
          "relay": { "ApiKey": "dev-relay-key", "DisplayName": "Event Relay", "Roles": "event-relay" }
        }
      }
    }
  }
}

节名是主体 ID;ApiKey 必填且至少 8 个字符;Roles 逗号分隔;Claims:* 下的键值作为附加声明。认证成功的主体按顺序带上:每个角色一条 RoleClaim 类型的声明、一条租户声明(有 Tenant 时,类型取 XiHan:MultiTenancy:TenantClaim,缺省 tenant)、附加声明。

ApiKeyAuthenticationHandler 逐条做常量时间比较,不用字典查找,耗时不随匹配位置泄露信息;找不到返回 SECURITY_DENIED_003。主体数上限 XiHan:Security:InMemory:MaxPrincipals,缺省 1000。

bash
curl -H "Authorization: ApiKey dev-alice-key" http://localhost:5000/whoami

内存安全只用于开发与测试

API Key 明文写在配置里,主体与授予都在进程内存中,重启即恢复为配置内容。生产环境换成 JWT 或 OpenID Connect 认证,授予换成 Security.Database。

JWT ​

Security.Jwt 校验应用自己签发、HS256 对称密钥签名的 Bearer 令牌:

csharp
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Security.Jwt;

var builder = WebApplication.CreateBuilder(args);

// 显式 Provider 顶替内存实现的认证部分;授权仍由 Security.InMemory 承担
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature<JwtSecurityFeature>());

var app = builder.Build();
app.UseXiHan();
app.Run();
json
{
  "XiHan": {
    "Security": {
      "Jwt": {
        "Secret": "dev-only-signing-key-change-me-0123456789",
        "Issuer": "https://localhost:5001",
        "Audience": "orders-api",
        "ClockSkewSeconds": 60
      }
    }
  }
}
键(XiHan:Security:Jwt)缺省说明
Secret必填对称密钥,UTF-8 编码后至少 32 字节(JwtOptions.MinSecretBytes)
Issuer不校验配置了才校验签发者
Audience不校验配置了才校验受众
ClockSkewSeconds60校验有效期时允许的时钟偏移,不能为负
NameClaimsub作为主体 ID 的声明类型

签名、exp 与有效期始终强制校验。主体 ID 取 NameClaim 声明,令牌里没有这条声明同样返回 SECURITY_DENIED_003(消息键 security.token.subject_missing);显示名取 name 声明;令牌的全部声明按原始类型名进入主体,所以角色声明的类型要与 XiHan:Security:RoleClaim 一致,租户声明的类型要与 XiHan:MultiTenancy:TenantClaim 一致。

OpenID Connect ​

Security.OpenIdConnect 校验外部身份提供方签发的 Bearer 令牌,签名密钥从签发者的发现文档与 JWKS 取得:

json
{
  "XiHan": {
    "Security": {
      "OpenIdConnect": {
        "Authority": "https://login.example.com/realms/shop",
        "Audience": "orders-api"
      }
    }
  }
}
键(XiHan:Security:OpenIdConnect)缺省说明
Authority必填签发者地址,绝对 http / https URI
MetadataAddress<Authority>/.well-known/openid-configuration发现文档地址
Issuer发现文档的 issuer期望的签发者,始终校验
Audience不校验配置了才校验受众
RequireHttpsMetadatatrue发现文档必须经 HTTPS 取得;只在本地开发时关闭
ClockSkewSeconds60有效期校验的时钟偏移
RefreshIntervalSeconds43200(12 小时)元数据自动刷新间隔
MinimumRefreshIntervalSeconds300(5 分钟)两次按需刷新的最短间隔,也是刷新失败后沿用旧密钥的时长
BackchannelTimeoutSeconds30取发现文档与 JWKS 的 HTTP 超时
NameClaimsub作为主体 ID 的声明类型

元数据按需取得:首次认证时取发现文档的 issuer 与 jwks_uri,再取 JWKS 的签名密钥并缓存,并发认证只发一次请求。令牌的 kid 不在 JWKS 里时(签发者轮换了密钥)按需刷新一次再校验,两次按需刷新至少间隔 MinimumRefreshInterval,伪造的 kid 不会让签发者被反复请求。

刷新失败时沿用最后一份密钥;从未取到元数据时返回 SECURITY_UNAVAILABLE_003(HTTP 503),5 秒(OpenIdConnectMetadataCache.FailureRetryDelay)后再试。读不出的令牌直接返回 SECURITY_DENIED_003,不为它访问签发者。RequireHttpsMetadata 为真而发现文档地址不是 HTTPS 时,启动读取配置就抛出 InvalidOperationException。主体与声明的映射与 JWT Provider 相同。

凭据无效与 401 质询 ​

认证失败与无权限在协议上分开(ADR-0042):SECURITY_DENIED_001(未认证)与 SECURITY_DENIED_003(凭据无效)是认证失败,客户端据此刷新凭据或重新登录;其余 DENIED 是已认证但无权限。

错误码HTTPWWW-AuthenticategRPC
SECURITY_DENIED_001 未认证401按已装配的认证处理器逐个质询方案名(如 Bearer、ApiKey),去重,不带错误参数Unauthenticated
SECURITY_DENIED_003 凭据无效401只质询请求出示的方案:Bearer 为 Bearer error="invalid_token",其他方案只写方案名Unauthenticated
SECURITY_DENIED_002、SECURITY_DENIED_004403无PermissionDenied
SECURITY_VALIDATION_001 方案不支持400无InvalidArgument

质询头在 UseXiHan() 写错误响应时加上(MCP 端点同样经过它),不带 error_description,与"不区分原因对外暴露"一致;具体原因只进日志。gRPC 的尾部元数据仍带 xihan-error-code。命令行宿主按错误码种类给退出码,DENIED 为 4。

绕过管道的端点 ​

SignalR Hub、Server-Sent Events 这类长连接端点不经内核管道:不受截止时间约束,内核停止时也不会被 503 打断,因此 Authenticate 阶段管不到它们。Hosting.AspNetCore 提供 ASP.NET Core 认证方案 XiHan(ADR-0051),把这类端点的凭据交给内核已装配的同一个 IAuthenticationHandler,JWT、OpenID Connect、ApiKey 都适用,校验项只有一份。

方案的规则:

  1. 只认带 PipelineBypassMetadata 的端点。只登记了这一个方案时 ASP.NET Core 会把它当缺省方案,普通请求在这里按未认证处理,仍由管道认证,不会被认证两次。
  2. 凭据按宿主的同一规则取:先 Authorization 头;没有时,请求路径(不含 PathBase)按路径段、不区分大小写落在 QueryTokenPaths 登记的前缀下,且 access_token 只有一个非空值,才作为 Bearer 凭据。没登记的路径带查询串令牌一律忽略,请求按匿名处理。
  3. 没有凭据是未认证,不是失败;方案不支持、凭据无效都是认证失败(401),质询与管道一致,方案不支持时按已装配的方案质询。
  4. 认证成功后按登记顺序调用应用的 IInvocationAuthenticationFilter,任一返回失败即认证失败。框架不判定会话,会话门禁在这里由应用实现。
  5. 主体转成 ClaimsPrincipal:身份的认证类型为 XiHan,声明按原始类型名保留,另加一条 ClaimTypes.NameIdentifier 承载主体 ID 并作为名称声明(SignalR 的用户标识随之可用),角色声明类型取 XiHan:Security:RoleClaim。
csharp
using Microsoft.AspNetCore.Authorization;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Authentication;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Profiles.Standard;
using XiHan.Framework.Security.Jwt;

var builder = WebApplication.CreateBuilder(args);
builder.AddXiHan(xihan => xihan.UseStandardProfile().UseFeature<JwtSecurityFeature>());

// 浏览器的 WebSocket 与 EventSource 带不了请求头,只在 /hubs 下接受查询串令牌
builder.Services.AddAuthentication().AddXiHan(options => options.QueryTokenPaths.Add("/hubs"));
builder.Services.AddAuthorization();
builder.Services.AddSignalR();
// ISessionStatus 的实现由应用登记
builder.Services.AddSingleton<IInvocationAuthenticationFilter, ActiveSessionFilter>();

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseXiHan();

var xihanScheme = new AuthorizeAttribute { AuthenticationSchemes = InvocationAuthenticationDefaults.AuthenticationScheme };
PipelineBypassMetadata.BypassXiHanPipeline(app.MapHub<OrdersHub>("/hubs/orders").RequireAuthorization(xihanScheme));

app.Run();

AddXiHan 可以多次调用:方案只登记一次,各次的配置依次生效,各模块可以各自添加查询串令牌路径。前缀不能为空也不能是 /,方案初始化时校验。查询串会进入访问日志与代理日志,只给带不了请求头的客户端用。

挂点核对令牌所属的会话:

csharp
using XiHan.Framework.Hosting.AspNetCore.Authentication;
using XiHan.Framework.Kernel.Security;
using XiHan.Framework.Primitives;
using XiHan.Framework.Security.Abstractions;

// 应用自己的会话表
public interface ISessionStatus
{
    ValueTask<bool> IsActiveAsync(string sessionId, CancellationToken cancellationToken);
}

public sealed class ActiveSessionFilter(ISessionStatus sessions) : IInvocationAuthenticationFilter
{
    public async ValueTask<Result> AuthenticatedAsync(Principal principal, InvocationContext context, CancellationToken cancellationToken)
    {
        // 会话注销后令牌在有效期内仍能通过签名校验,长连接的握手要在这里拒绝
        var sessionId = principal.ClaimValues("sid").FirstOrDefault();
        return sessionId is not null && await sessions.IsActiveAsync(sessionId, cancellationToken)
            ? Result.Success()
            : Result.Failure(Error.Denied(SecurityErrors.InvalidCredential, SecurityMessages.InvalidToken));
    }
}

在这类端点里发起内核调用,用 HttpInvocation.CreateContext(httpContext, services) 以请求建调用上下文,凭据按同一规则带上,调用经管道时照常重新认证与授权;长连接上的令牌过期后,新的调用会被拒绝。下面的 Hub 调用 SimpleApp 的 orders.list 操作:

csharp
using Microsoft.AspNetCore.SignalR;
using SimpleApp.Orders;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.Kernel.Operations;

public sealed class OrdersHub(IOperationInvoker operations) : Hub
{
    public async Task<int> CountOrders()
    {
        var http = Context.GetHttpContext() ?? throw new InvalidOperationException("The hub connection has no HTTP context.");

        // 经完整管道调用:Authorize 阶段照常按契约的 orders.read 判定
        var context = HttpInvocation.CreateContext(http, http.RequestServices);
        var result = await operations.InvokeAsync<ListOrdersRequest, OrderList>(context, ListOrders.Id, new ListOrdersRequest());
        return result.IsSuccess ? result.ValueOrThrow().Items.Count : throw new HubException(result.Error?.Code);
    }
}

管道内的会话门禁仍由应用写成 Authenticate 阶段的中间件;挂点只作用于方案 XiHan。

错误码 ​

错误码含义
SECURITY_DENIED_001未认证:RequireAuthentication 为真时的匿名调用,或要求认证的策略遇到匿名主体
SECURITY_DENIED_003凭据无效、过期、签名不符,或令牌缺少主体声明
SECURITY_VALIDATION_001认证方案不受支持
SECURITY_UNAVAILABLE_002内存主体存储容量已满(InMemoryPrincipalStore.Set 与健康项 security.inmemory.capacity)
SECURITY_UNAVAILABLE_003OpenID Connect 签发者的发现文档或 JWKS 不可达,且没有缓存

与其他能力的关系 ​

  • 多租户:Tenant 阶段在认证之后,从主体的租户声明解析租户。
  • 授权:授权策略读认证写入的主体;匿名主体遇到权限策略得到 SECURITY_DENIED_001。
  • 宿主与 Web 应用:UseXiHan() 把请求头翻译成调用上下文,并在 401 响应上写质询头。
  • 执行管道:Authenticate 阶段在 Resilience 之后、Tenant 之前。

相关 ​

Released under The MIT License