36.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--缓存Token
今天有多个同学反映,在编写缓存Token代码时,遇到了获取生成的Token获取不到的问题。具体原因我也对私信我的同学回复了。在下班的路上,我思考了一下,决定将缓存Token的内容单独写一篇文章,方便遇到同样问题的同学查阅。
一、为什么会出现获取不到Token的问题
我查看了部分同学的代码,他们都有一个共同点,都是直接在AuthorizationController
的的GetToken()
方法中获取Token并缓存。用尽了各种办法,还是获取不到刚生成的Token。其实,这个问题我在刚开始接触OpenIddict时也遇到过,也是废了好大劲,用了各种办法,都没有解决。最后我查阅了OpenIddict的文档,以及源代码,才发现问题的根源。
在 OpenIddict 中,Token 的生成是在中间件处理过程中完成的,而不是在 SignInResult 创建时立即生成的。因此,SignInResult.Properties
中不会包含 token 值。下面我们就来看看OpenIddict 的核心原理来进一步解释这个问题出现的原因。
OpenIddict 采用了分层架构设计,遵循 OAuth2 和 OpenID Connect 标准,以下是其架构的主要组成部分:
┌─────────────────────────────────────┐
│ Application Layer │ ← 业务应用层
├─────────────────────────────────────┤
│ Controller Layer │ ← HTTP 控制器层
├─────────────────────────────────────┤
│ OpenIddict Server Layer │ ← 认证服务器层
├─────────────────────────────────────┤
│ Middleware Pipeline │ ← 中间件管道
├─────────────────────────────────────┤
│ Data Storage Layer │ ← 数据存储层
└─────────────────────────────────────┘
在这个架构中,Token 的生成和处理主要发生在 OpenIddict Server Layer 和 Middleware Pipeline 中。在讲解生成Token的过程前,我们先来了解一下 OpenIddict 的三个核心组件:OpenIddict Core、OpenIddict Server、OpenIddict Validation。OpenIddict Core 它提供基础抽象和接口,其中定义认证流程的通用模型,如用户、客户端、授权等实体。它是 OpenIddict 的核心库,提供了 OAuth2 和 OpenID Connect 的基础功能。OpenIddict Server 是 OpenIddict 的服务器端实现,负责处理 OAuth2 和 OpenID Connect 的授权请求、令牌生成和验证等功能。它提供了中间件和服务,用于集成到 ASP.NET Core 应用程序中。OpenIddict Validation 是 OpenIddict 的验证组件,提供了令牌验证功能。它支持本地和远程验证,可以与 ASP.NET Core 的认证系统集成,提供统一的令牌验证机制。
了解了 OpenIddict 的核心组件后,我们来看看 Token 的生成过程。首先进行身份验证,客户端传入凭证,如用户名和密码,OpenIddict 会验证这些凭证是否有效。如果验证成功,OpenIddict 会创建一个 ClaimsPrincipal
对象,这个对象包含了用户的身份信息和授权范围。接下来进入到授权处理阶段,OpenIddict 会检查设置授权范围并配置生命周期,然后准备生成 Token。最后进入Token生成阶段,OpenIddict 会使用配置的密钥和算法在OpenIddict 中间件里生成 JWT Access Token和Refresh Token,并将其返回给客户端。
在这个过程中,Token 的生成是延迟的,也就是说,Token 只有在 OpenIddict 中间件处理请求时才会被实际生成,而不是在 Controller 中直接获取。这就是为什么在 Controller 中获取 SignInResult 时,Token 还没有被生成。
二、为什么采用延迟生成
采用延迟生成 Token 的设计,首先实现了业务逻辑与认证流程的解耦。Controller 层只需关注业务处理,而 Token 的生成和管理则交由专门的中间件完成,这样可以有效降低各组件之间的耦合度,提升系统的可维护性和扩展性。
其次,所有 Token 的生成都通过统一的机制在中间件管道中处理。这不仅方便了配置和管理,也避免了在不同位置重复编写 Token 相关代码,确保了流程的一致性和规范性,有助于后续的统一维护和升级。
最后,延迟生成还增强了安全性。Token 的生成逻辑被封装在框架内部,开发者无需直接操作密钥或签名流程,从而减少了人为失误和安全漏洞的风险。同时,密钥管理和签名验证也由框架统一处理,进一步保障了系统的安全性。
Tip:我们在代码中看到的
SignInResult
只是一个承诺,并不包含实际的 Token。
以下是 OpenIddict 中间件的工作流程图,展示了 Token 生成的机制,只要记住这张图,以后在用 OpenIddict 时就不会再遇到获取不到 Token 的问题了。
HTTP 请求↓
Authentication Middleware (身份验证)↓
Authorization Middleware (授权)↓
OpenIddict Middleware (Token 生成)↓
Custom Middleware (自定义处理)↓
HTTP 响应
三、如何获取生成的Token
要获取生成的 Token,我们需要在 OpenIddict 中间件处理完请求后,通过解析响应来获取 Token。实现代码如下:
using SP.Common.Redis;
using System.IdentityModel.Tokens.Jwt;
using System.Text.Json;namespace SP.IdentityService.Middleware;/// <summary>
/// Token 存储中间件
/// 用于在 OpenIddict 生成 token 后将其存储到 Redis 中
/// </summary>
public class TokenStorageMiddleware
{private readonly RequestDelegate _next;private readonly IRedisService _redisService;private readonly ILogger<TokenStorageMiddleware> _logger;public TokenStorageMiddleware(RequestDelegate next, IRedisService redisService, ILogger<TokenStorageMiddleware> logger){_next = next;_redisService = redisService;_logger = logger;}public async Task InvokeAsync(HttpContext context){// 保存原始的响应流var originalBodyStream = context.Response.Body;try{// 创建一个内存流来捕获响应using var memoryStream = new MemoryStream();context.Response.Body = memoryStream;// 继续处理请求await _next(context);// 检查是否是 token 端点且响应成功if (context.Request.Path.StartsWithSegments("/api/auth/token") && context.Response.StatusCode == 200){// 重置流位置以读取内容memoryStream.Position = 0;var responseBody = await new StreamReader(memoryStream).ReadToEndAsync();try{// 解析响应以获取 tokenvar tokenResponse = JsonSerializer.Deserialize<JsonElement>(responseBody);if (tokenResponse.TryGetProperty("access_token", out var accessTokenElement)){var accessToken = accessTokenElement.GetString();var expiresIn = 3600; // 默认1小时// 尝试获取过期时间if (tokenResponse.TryGetProperty("expires_in", out var expiresInElement)){expiresIn = expiresInElement.GetInt32();}// 从 JWT token 中解析用户IDstring? userId = null;if (!string.IsNullOrEmpty(accessToken)){try{var handler = new JwtSecurityTokenHandler();var jwtToken = handler.ReadJwtToken(accessToken);userId = jwtToken.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;}catch (Exception ex){_logger.LogWarning(ex, "解析 JWT token 失败");}}// 如果无法从 JWT 中获取用户ID,尝试从响应中获取if (string.IsNullOrEmpty(userId) && tokenResponse.TryGetProperty("sub", out var subElement)){userId = subElement.GetString();}// 如果仍然没有用户ID,尝试从请求中获取客户端IDif (string.IsNullOrEmpty(userId)){var clientId = context.Request.Form["client_id"].FirstOrDefault();if (!string.IsNullOrEmpty(clientId)){userId = clientId;}}if (!string.IsNullOrEmpty(accessToken) && !string.IsNullOrEmpty(userId)){// 存储 token 到 Redisstring tokenKey = string.Format(SPRedisKey.Token, userId);await _redisService.SetStringAsync(tokenKey, accessToken, expiresIn);_logger.LogInformation("Token 已存储到 Redis,用户ID: {UserId}", userId);}else{_logger.LogWarning("无法获取用户ID或 access_token,跳过 Redis 存储");}}}catch (Exception ex){_logger.LogError(ex, "解析 token 响应时发生错误");}// 将响应内容写回原始流memoryStream.Position = 0;await memoryStream.CopyToAsync(originalBodyStream);}else{// 对于非 token 端点,直接复制响应memoryStream.Position = 0;await memoryStream.CopyToAsync(originalBodyStream);}}finally{// 恢复原始响应流context.Response.Body = originalBodyStream;}}
}/// <summary>
/// Token 存储中间件扩展
/// </summary>
public static class TokenStorageMiddlewareExtensions
{public static IApplicationBuilder UseTokenStorage(this IApplicationBuilder builder){return builder.UseMiddleware<TokenStorageMiddleware>();}
}
在这个代码中,我们创建了一个名为 TokenStorageMiddleware
的中间件,它会在 OpenIddict 生成 Token 后,将其存储到 Redis 中。我们通过检查请求路径和响应状态码来判断是否是 Token 端点,并从响应中解析出 Access Token 和用户 ID,然后将其存储到 Redis 中。
把这个中间件代码放在SP.IdentityService
项目下的 Middleware
目录中,并在Program.cs
中注册这个中间件即可,代码如下:
// more code ...// 添加 Token 存储中间件
app.UseTokenStorage();// more code ...
四、总结
通过以上的介绍,我们了解了为什么在 OpenIddict 中获取不到 Token,以及如何通过中间件来实现 Token 的缓存。使用 Redis 缓存 Token 可以提高系统的性能和响应速度,同时也能减少对数据库的访问压力。
在实际项目中,我们可以根据业务需求,灵活地调整 Token 的缓存策略和过期时间,以达到最佳的性能优化效果。
希望这篇文章能帮助到遇到同样问题的同学们,如果还有其他问题,欢迎随时交流。