这篇文章开始我们将会分三篇文章来讲解如何在网关中集成认证功能。第一篇讲解《OpenID Connect 发现端点控制器与令牌内省端点》,第二篇讲解《网关认证配置服务》,第三篇讲解《网关令牌内省服务》。主要是网关集成认证的相关内容很复杂,因此需要分三篇文章来讲解。我们先来看看第一篇文章的内容。
一、OpenID Connect 发现端点控制器
1.1 什么是OpenID Connect 发现端点
OpenID Connect 发现端点(Discovery Endpoint)是 OpenID Connect 协议中的一个重要组成部分,它允许客户端(如网关)动态发现身份提供者(如身份服务)的配置信息。OpenID Connect 发现端点的主要作用,首先是自动发现配置,客户端不需要硬编码身份提供者的各种端点地址,而是通过发现端点动态获取,常用的断点地址以及数据包括:令牌端点地址、用户信息端点地址、授权端点地址、支持的授权类型、支持的声明类型、JWT签名算法等。其次是提供标准化兼容,符合 OpenID Connect 标准,确保与各种客户端库和工具的兼容性。
1.2 实现发现端点
我们首先在SP.IdentityService
项目中创建一个OpenID Connect 发现端点控制器,这个控制器包含三个Action:openid_configuration
、jwks
和health
。其中,openid_configuration
的作用是返回OpenID Connect的配置数据,jwks
的作用是返回JSON Web Key Set(JWKS),用于验证JWT签名,health
的作用是返回服务的健康状态。
首先,我们来讲一下openid_configuration
的实现,先看代码:
csharp
/// <summary>
/// OpenID Connect 发现端点
/// </summary>
/// <returns>OpenID Connect 配置信息</returns>
[HttpGet("openid_configuration")]
public async Task<IActionResult> GetConfiguration()
{
try
{
var baseUrl = $"{Request.Scheme}://{Request.Host}";
var configuration = new
{
// 必需字段
issuer = baseUrl,
token_endpoint = $"{baseUrl}/api/auth/token",
userinfo_endpoint = $"{baseUrl}/api/auth/userinfo",
jwks_uri = $"{baseUrl}/.well-known/jwks",
// 可选字段
end_session_endpoint = $"{baseUrl}/api/auth/logout",
revocation_endpoint = $"{baseUrl}/api/auth/revoke",
introspection_endpoint = $"{baseUrl}/api/auth/introspect",
// 支持的主体类型
subject_types_supported = new[] { "public" },
// 支持的ID令牌签名算法
id_token_signing_alg_values_supported = new[]
{
"RS256", // RSA SHA-256
"HS256" // HMAC SHA-256
},
// 支持的授权范围
scopes_supported = new[]
{
"openid", // 必需
"profile", // 用户基本信息
"email", // 邮箱信息
"api", // API访问权限
"offline_access" // 离线访问(刷新令牌)
},
// 支持的令牌端点认证方法
token_endpoint_auth_methods_supported = new[]
{
"client_secret_basic", // 基本认证
"client_secret_post", // 表单认证
"client_secret_jwt", // JWT认证
"private_key_jwt" // 私钥JWT认证
},
// 支持的声明类型
claims_supported = new[]
{
"sub", // 主体标识符
"name", // 用户名
"email", // 邮箱
"role", // 角色
"iat", // 签发时间
"exp", // 过期时间
"iss", // 签发者
"aud" // 受众
},
// 支持的授权类型 - 只保留密码模式相关
grant_types_supported = new[]
{
"client_credentials", // 客户端凭证模式
"password", // 密码模式
"refresh_token" // 刷新令牌模式
}
};
_logger.LogDebug("返回OpenID Connect配置信息");
return Ok(configuration);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取OpenID Connect配置时发生错误");
return StatusCode(500, new
{
error = "server_error",
error_description = "获取配置时发生内部错误"
});
}
}
这个代码的作用是向客户端公开当前认证服务器支持的各种 OpenID Connect 配置参数,便于客户端自动发现和适配。方法体首先通过 Request.Scheme
和 Request.Host
动态获取当前请求的协议(如 http
或 https
)和主机名,拼接成 baseUrl
,这样无论部署在哪个环境都能正确生成后续的端点地址。
接下来,方法构造了一个匿名对象 configuration
,其中包含了 OpenID Connect 规范要求的各类字段。比如 issuer
表示颁发者地址,authorization_endpoint
、token_endpoint
、userinfo_endpoint
等分别对应授权、令牌、用户信息等标准端点。jwks_uri
提供公钥集合地址,供客户端验证 JWT 签名。还有一些可选端点如注销、撤销、令牌自省等。
配置对象还详细列举了服务器支持的响应类型(如 code
、token
、id_token
等)、主体类型(如 public
)、ID 令牌签名算法(如 RS256
、HS256
)、支持的授权范围(如 openid
、profile
、email
、api
、offline_access
)、令牌端点认证方法(如 client_secret_basic
、client_secret_post
等)、支持的声明类型(如 sub
、name
、email
、role
等)、授权类型(如 client_credentials
、password
、refresh_token
)以及 PKCE 支持的代码挑战方法(如 S256
)。方法最后返回配置信息。
Tip:需要注意的是,这种配置端点是 OpenID Connect 兼容认证服务器的基础,客户端(如前端 SPA、移动端、第三方应用)可以通过访问该端点自动获取所有必要的认证参数和端点地址,无需硬编码,极大提升了系统的可扩展性和互操作性。
接下来,我们来讲一下jwks
的实现,同样先看代码:
csharp
/// <summary>
/// JWKS (JSON Web Key Set) 端点
/// </summary>
/// <returns>JWT签名密钥信息</returns>
[HttpGet("jwks")]
public async Task<IActionResult> GetJwks()
{
try
{
var keys = new List<object>();
// 生成kid - 使用密钥的哈希值作为kid
var jwtSecret = _jwtConfigService.GetJwtSecret();
var kid = GenerateKeyId(jwtSecret);
var signingKey = new
{
kty = "oct", // 密钥类型:对称密钥
use = "sig", // 用途:签名
kid = kid, // 密钥ID:使用哈希值
alg = "HS256", // 算法
k = Convert.ToBase64String(Encoding.UTF8.GetBytes(jwtSecret))
};
keys.Add(signingKey);
var jwks = new
{
keys = keys
};
_logger.LogDebug("返回JWKS信息,包含 {Count} 个密钥", keys.Count);
return Ok(jwks);
}
catch (Exception ex)
{
_logger.LogError(ex, "获取JWKS时发生错误");
return StatusCode(500, new
{
error = "server_error",
error_description = "获取JWKS时发生内部错误"
});
}
}
这个代码的作用是向客户端公开当前认证服务器使用的 JSON Web Key Set(JWKS),便于客户端验证 JWT 签名。方法体首先创建一个空的密钥列表 keys
,然后构造了一个对称签名密钥对象 signingKey
,其中包含了密钥类型(kty
)、用途(use
)、密钥 ID(kid
)、签名算法(alg
)以及实际的密钥值(k
)。这里使用的是对称加密算法 HS256,因此密钥类型为 oct
,并通过 _jwtConfigService.GetJwtSecret()
获取实际的密钥字符串,并将其转换为 Base64 编码格式。最后将密钥对象添加到密钥列表中,并构造一个包含所有密钥的 JWKS 对象 jwks
,然后返回该对象。
这里需要重点说一下kid
,它是密钥的唯一标识符,用于在多个密钥中区分当前使用的密钥。通常情况下,kid
可以是一个简单的字符串,也可以是一个更复杂的标识符,取决于具体的密钥管理策略。一般来说,我们不会用到多密钥的情况,但是有些安全要求较高的系统可能会使用多密钥轮换策略,这时就需要通过 kid
来区分不同的密钥。
Tip:这部分的知识就和OAuth 2.0 相关了,我们在这里不需要深入讲解,主要是为了让大家了解如何在认证服务中实现 JWKS 端点。后续我会专门开一个系列来讲解 OAuth 2.0 相关的知识,大家可以关注一下。
最后,我们来讲一下health
的实现,还是先看代码:
csharp
/// <summary>
/// 健康检查端点
/// </summary>
/// <returns>服务健康状态</returns>
[HttpGet("health")]
public IActionResult GetHealth()
{
return Ok(new
{
status = "healthy",
timestamp = DateTime.UtcNow,
service = "SPIdentityService",
version = "1.0.0"
});
}
/// <summary>
/// 生成密钥ID
/// </summary>
/// <param name="key">密钥</param>
/// <returns>密钥ID</returns>
private string GenerateKeyId(string key)
{
using var sha256 = System.Security.Cryptography.SHA256.Create();
var hash = sha256.ComputeHash(Encoding.UTF8.GetBytes(key));
return Convert.ToBase64String(hash).Substring(0, 8);
}
这个代码的作用是提供一个简单的健康检查端点,返回服务的健康状态。方法体直接返回一个包含状态、时间戳、服务名称和版本号的匿名对象。这个端点可以用于监控和负载均衡器检查服务是否正常运行。
二、令牌内省端点控制器
2.1 什么是令牌内省端点
令牌内省端点(Token Introspection Endpoint)是 OpenID Connect 和 OAuth 2.0 协议中的一个重要组成部分,用于验证访问令牌的有效性和获取令牌信息。它允许客户端应用程序查询令牌的状态,以确定令牌是否仍然有效,以及获取与令牌相关的元数据,如用户标识、作用域、过期时间等。
令牌内省端点通常符合 RFC 7662 标准,提供一个统一的接口供客户端查询令牌状态。通过内省端点,客户端可以在不需要访问用户信息端点的情况下,直接获取令牌的详细信息。
令牌内省端点的主要作用包括:
- 验证令牌有效性:客户端可以通过内省端点检查令牌是否仍然有效,是否已被撤销或过期。
- 获取令牌元数据:内省端点可以返回与令牌相关的详细信息,如用户标识(sub)、作用域(scope)、客户端 ID(client_id)、签发时间(iat)、过期时间(exp)等。
- 支持多种令牌类型:内省端点可以处理不同类型的令牌,如访问令牌、刷新令牌等,提供统一的查询接口。
- 提高安全性:通过内省端点,客户端可以在每次使用令牌时验证其有效性,避免使用已撤销或过期的令牌,从而提高系统的安全性。
令牌内省端点通常使用 HTTP POST 方法,客户端需要将令牌作为参数发送到内省端点。响应通常是一个 JSON 对象,包含令牌的状态和相关元数据。
2.2 实现令牌内省端点
我们在SP.IdentityService
项目已有的AuthorizationController
控制器中添加introspect
控制器,这个控制器的作用是验证访问令牌的有效性和获取令牌信息。它符合 RFC 7662 标准,提供了一个统一的接口供客户端查询令牌状态。
我们先来看看代码实现:
csharp
/// <summary>
/// 令牌内省端点
/// </summary>
/// <remarks>
/// 用于验证访问令牌的有效性和获取令牌信息
/// 符合 RFC 7662 标准
/// </remarks>
[HttpPost("introspect")]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
public async Task<ActionResult> IntrospectToken()
{
try
{
var request = HttpContext.GetOpenIddictServerRequest();
if (request == null)
{
return BadRequest(new
{
error = OpenIddictConstants.Errors.InvalidRequest,
error_description = "无效的内省请求"
});
}
var token = request.Token;
if (string.IsNullOrEmpty(token))
{
return BadRequest(new
{
error = OpenIddictConstants.Errors.InvalidRequest,
error_description = "token参数不能为空"
});
}
var introspectionResult = await ValidateTokenForIntrospectionAsync(token);
if (introspectionResult == null || !introspectionResult.IsValid)
{
return Ok(new
{
active = false
});
}
return Ok(new
{
active = true,
sub = introspectionResult.Subject,
username = introspectionResult.Username,
email = introspectionResult.Email,
scope = introspectionResult.Scope,
client_id = introspectionResult.ClientId,
token_type = introspectionResult.TokenType,
iat = introspectionResult.IssuedAt,
exp = introspectionResult.ExpiresAt,
nbf = introspectionResult.NotBefore,
aud = introspectionResult.Audience,
iss = introspectionResult.Issuer,
jti = introspectionResult.JwtId,
roles = introspectionResult.Roles,
permissions = introspectionResult.Permissions
});
}
catch (Exception ex)
{
_logger.LogError(ex, "令牌内省时发生错误");
return StatusCode(500, new
{
error = "server_error",
error_description = "内省服务内部错误"
});
}
}
/// <summary>
/// 验证令牌用于内省
/// </summary>
/// <param name="token">访问令牌</param>
/// <returns>内省结果</returns>
private async Task<TokenIntrospectionResponse?> ValidateTokenForIntrospectionAsync(string token)
{
try
{
if (string.IsNullOrEmpty(token))
{
return null;
}
var isRevoked = await CheckTokenRevocationAsync(token);
if (isRevoked)
{
_logger.LogWarning("令牌已被撤销: {TokenPrefix}", token.Substring(0, Math.Min(10, token.Length)));
return null;
}
var tokenHandler = new JwtSecurityTokenHandler();
if (!tokenHandler.CanReadToken(token))
{
_logger.LogWarning("无法解析JWT令牌");
return null;
}
var jwtToken = tokenHandler.ReadJwtToken(token);
if (!await ValidateTokenSignatureAsync(jwtToken))
{
_logger.LogWarning("令牌签名验证失败");
return null;
}
var now = DateTime.UtcNow;
if (jwtToken.ValidFrom > now)
{
_logger.LogWarning("令牌尚未生效,生效时间: {ValidFrom}", jwtToken.ValidFrom);
return null;
}
if (jwtToken.ValidTo < now)
{
_logger.LogWarning("令牌已过期,过期时间: {ValidTo}", jwtToken.ValidTo);
return null;
}
var result = new TokenIntrospectionResponse
{
IsValid = true,
Subject = jwtToken.Subject,
Username = jwtToken.Claims.FirstOrDefault(c => c.Type == "name")?.Value,
Email = jwtToken.Claims.FirstOrDefault(c => c.Type == "email")?.Value,
Scope = jwtToken.Claims.FirstOrDefault(c => c.Type == "scope")?.Value,
ClientId = jwtToken.Claims.FirstOrDefault(c => c.Type == "client_id")?.Value,
TokenType = "Bearer",
IssuedAt = jwtToken.IssuedAt.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalSeconds is double iat
? (long)iat
: null,
ExpiresAt = jwtToken.ValidTo.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalSeconds is double exp
? (long)exp
: null,
NotBefore = jwtToken.ValidFrom.ToUniversalTime().Subtract(DateTime.UnixEpoch).TotalSeconds is double nbf
? (long)nbf
: null,
Audience = jwtToken.Audiences?.FirstOrDefault(),
Issuer = jwtToken.Issuer,
JwtId = jwtToken.Id
};
result.Roles = jwtToken.Claims
.Where(c => c.Type == "role")
.Select(c => c.Value)
.ToList();
result.Permissions = jwtToken.Claims
.Where(c => c.Type == "permission")
.Select(c => c.Value)
.ToList();
_logger.LogDebug("令牌内省成功,用户: {Username}, 客户端: {ClientId}",
result.Username, result.ClientId);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "验证令牌时发生错误");
return null;
}
}
/// <summary>
/// 检查令牌是否被撤销
/// </summary>
/// <param name="token">访问令牌</param>
/// <returns>是否被撤销</returns>
private async Task<bool> CheckTokenRevocationAsync(string token)
{
try
{
using var sha256 = SHA256.Create();
var tokenHash = Convert.ToBase64String(sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(token)));
var revocationKey = $"revoked_token:{tokenHash}";
var isRevoked = await _redisService.ExistsAsync(revocationKey);
if (isRevoked)
{
_logger.LogDebug("令牌在Redis中被标记为撤销");
}
return isRevoked;
}
catch (Exception ex)
{
_logger.LogError(ex, "检查令牌撤销状态时发生错误");
return false;
}
}
/// <summary>
/// 验证令牌签名
/// </summary>
/// <param name="jwtToken">JWT令牌</param>
/// <returns>签名是否有效</returns>
private async Task<bool> ValidateTokenSignatureAsync(JwtSecurityToken jwtToken)
{
try
{
if (jwtToken.SignatureAlgorithm == null)
{
_logger.LogWarning("令牌没有签名算法");
return false;
}
var supportedAlgorithms = new[] { "HS256", "RS256" };
if (!supportedAlgorithms.Contains(jwtToken.SignatureAlgorithm))
{
_logger.LogWarning("不支持的签名算法: {Algorithm}", jwtToken.SignatureAlgorithm);
return false;
}
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "验证令牌签名时发生错误");
return false;
}
}
首先,IntrospectToken
方法是一个HTTP POST接口,接收application/x-www-form-urlencoded
格式的请求,返回application/json
格式的数据。它通过HttpContext.GetOpenIddictServerRequest()
获取OpenIddict解析后的请求对象,如果请求无效(如格式不对),会返回400错误和详细的错误描述。接着,从请求中提取token
参数,如果没有提供token,同样返回400错误。
如果token存在,方法会调用ValidateTokenForIntrospectionAsync
进行令牌验证。这个方法首先检查token
是否为空,然后调用CheckTokenRevocationAsync
判断token
是否被撤销(比如用户登出或管理员手动吊销)。撤销检查通过Redis实现,将token
哈希后作为key查找,如果存在则认为已撤销。
如果token
未被撤销,接下来用JwtSecurityTokenHandler
尝试解析token
字符串为JWT对象。如果token
格式不正确或无法解析,会记录日志并返回无效。解析成功后,调用ValidateTokenSignatureAsync
检查JWT的签名算法是否受支持(这里只简单判断算法类型,没有做真正的签名校验)。如果签名算法不被支持,也会返回无效。
然后,代码检查token
的生效时间(ValidFrom
)和过期时间(ValidTo
),确保当前时间在有效期内。若token
尚未生效或已过期,同样视为无效。
如果所有检查都通过,代码会从JWT中提取各种声明(Claims
),如用户ID(Subject
)、用户名、邮箱、作用域(scope
)、客户端ID、签发时间、过期时间、受众(audience
)、签发者(issuer
)、JWT ID等,并将这些信息封装到TokenIntrospectionResponse
对象中。还会收集所有role
和permission
类型的声明,分别组成角色和权限列表。最后,返回一个包含这些信息的JSON对象,active
字段为true,表示token有效。
如果token
无效(被撤销、格式错误、签名不对、过期等),则返回active: false
。如果在处理过程中发生异常,会记录错误日志,并返回500错误和通用的错误描述。
三、总结
通过以上的实现,我们在SP.IdentityService
项目中完成了OpenID Connect 发现端点和令牌内省端点的功能。这些端点为网关集成认证提供了基础支持,使得网关可以动态获取认证服务的配置信息,并验证访问令牌的有效性。