认证
本章讲调用凭据怎样变成主体:凭据与认证处理器、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 的处理顺序:
- 调用上下文里没有凭据:保持匿名,进入第 5 步。
- 在容器里的
IAuthenticationHandler中按方案名(不区分大小写)找第一个匹配者;找不到返回SECURITY_VALIDATION_001。 - 处理器返回
Result<Principal>。凭据无效、过期、签名不符一律返回SECURITY_DENIED_003,不区分原因对外暴露;处理器内部的基础设施故障以异常抛出,由内核转成失败结果。 - 认证成功:写入新的安全上下文,保留已有的租户。
RequireAuthentication为真且主体仍是匿名:返回SECURITY_DENIED_001。
RequireAuthentication 缺省为假:匿名调用放行,由各操作的授权要求决定是否需要登录。
键(XiHan:Security) | 类型 | 缺省 | 说明 |
|---|---|---|---|
RequireAuthentication | bool | false | 为真时匿名调用在 Authenticate 阶段被拒绝 |
RoleClaim | string | role | 承载角色的声明类型;RBAC 检查器与 ASP.NET Core 方案 XiHan 都读它 |
{
"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。
操作契约的处理器从调用上下文读:
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.InMemory | ApiKey | default | 开发、原型、测试;Profiles.Standard 已包含 |
Security.Jwt | Bearer | provider | 应用自己签发的 HS256 令牌 |
Security.OpenIdConnect | Bearer | provider | 外部身份提供方签发的令牌(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:
{
"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。
curl -H "Authorization: ApiKey dev-alice-key" http://localhost:5000/whoami内存安全只用于开发与测试
API Key 明文写在配置里,主体与授予都在进程内存中,重启即恢复为配置内容。生产环境换成 JWT 或 OpenID Connect 认证,授予换成 Security.Database。
JWT
Security.Jwt 校验应用自己签发、HS256 对称密钥签名的 Bearer 令牌:
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();{
"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 | 不校验 | 配置了才校验受众 |
ClockSkewSeconds | 60 | 校验有效期时允许的时钟偏移,不能为负 |
NameClaim | sub | 作为主体 ID 的声明类型 |
签名、exp 与有效期始终强制校验。主体 ID 取 NameClaim 声明,令牌里没有这条声明同样返回 SECURITY_DENIED_003(消息键 security.token.subject_missing);显示名取 name 声明;令牌的全部声明按原始类型名进入主体,所以角色声明的类型要与 XiHan:Security:RoleClaim 一致,租户声明的类型要与 XiHan:MultiTenancy:TenantClaim 一致。
OpenID Connect
Security.OpenIdConnect 校验外部身份提供方签发的 Bearer 令牌,签名密钥从签发者的发现文档与 JWKS 取得:
{
"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 | 不校验 | 配置了才校验受众 |
RequireHttpsMetadata | true | 发现文档必须经 HTTPS 取得;只在本地开发时关闭 |
ClockSkewSeconds | 60 | 有效期校验的时钟偏移 |
RefreshIntervalSeconds | 43200(12 小时) | 元数据自动刷新间隔 |
MinimumRefreshIntervalSeconds | 300(5 分钟) | 两次按需刷新的最短间隔,也是刷新失败后沿用旧密钥的时长 |
BackchannelTimeoutSeconds | 30 | 取发现文档与 JWKS 的 HTTP 超时 |
NameClaim | sub | 作为主体 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 是已认证但无权限。
| 错误码 | HTTP | WWW-Authenticate | gRPC |
|---|---|---|---|
SECURITY_DENIED_001 未认证 | 401 | 按已装配的认证处理器逐个质询方案名(如 Bearer、ApiKey),去重,不带错误参数 | Unauthenticated |
SECURITY_DENIED_003 凭据无效 | 401 | 只质询请求出示的方案:Bearer 为 Bearer error="invalid_token",其他方案只写方案名 | Unauthenticated |
SECURITY_DENIED_002、SECURITY_DENIED_004 | 403 | 无 | 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 都适用,校验项只有一份。
方案的规则:
- 只认带
PipelineBypassMetadata的端点。只登记了这一个方案时 ASP.NET Core 会把它当缺省方案,普通请求在这里按未认证处理,仍由管道认证,不会被认证两次。 - 凭据按宿主的同一规则取:先
Authorization头;没有时,请求路径(不含 PathBase)按路径段、不区分大小写落在QueryTokenPaths登记的前缀下,且access_token只有一个非空值,才作为Bearer凭据。没登记的路径带查询串令牌一律忽略,请求按匿名处理。 - 没有凭据是未认证,不是失败;方案不支持、凭据无效都是认证失败(401),质询与管道一致,方案不支持时按已装配的方案质询。
- 认证成功后按登记顺序调用应用的
IInvocationAuthenticationFilter,任一返回失败即认证失败。框架不判定会话,会话门禁在这里由应用实现。 - 主体转成
ClaimsPrincipal:身份的认证类型为XiHan,声明按原始类型名保留,另加一条ClaimTypes.NameIdentifier承载主体 ID 并作为名称声明(SignalR 的用户标识随之可用),角色声明类型取XiHan:Security:RoleClaim。
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 可以多次调用:方案只登记一次,各次的配置依次生效,各模块可以各自添加查询串令牌路径。前缀不能为空也不能是 /,方案初始化时校验。查询串会进入访问日志与代理日志,只给带不了请求头的客户端用。
挂点核对令牌所属的会话:
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 操作:
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_003 | OpenID Connect 签发者的发现文档或 JWKS 不可达,且没有缓存 |
与其他能力的关系
- 多租户:Tenant 阶段在认证之后,从主体的租户声明解析租户。
- 授权:授权策略读认证写入的主体;匿名主体遇到权限策略得到
SECURITY_DENIED_001。 - 宿主与 Web 应用:
UseXiHan()把请求头翻译成调用上下文,并在 401 响应上写质询头。 - 执行管道:Authenticate 阶段在 Resilience 之后、Tenant 之前。
相关
- Security.Abstractions
- Security.InMemory
- Security.Jwt
- Security.OpenIdConnect
- Hosting.AspNetCore
- 规范:RFC-0008《认证与授权》;ADR-0042、ADR-0051
