44.【.NET8 实战--孢子记账--从单体到微服务--转向微服务】--扩展功能--集成网关--网关集成认证(三)

发布于:2025-08-18 ⋅ 阅读:(17) ⋅ 点赞:(0)

这篇文章我们讲解《网关令牌内省服务》,涉及到网关令牌内省服务、认证中间件内容,我们现在开始吧。

一、网关令牌内省服务

令牌内省服务已经在前面的文章介绍过了,忘记的同学可以回顾一下《网关集成认证(一)》的内容。在网关中创建令牌内省服务接口,并实现该接口。令牌内省服务的作用是验证令牌的有效性,并返回令牌的相关信息,我们来看一下接口如何定义:

using SP.Gateway.Models;

namespace SP.Gateway.Services;

/// <summary>
/// 令牌内省服务接口
/// </summary>
public interface ITokenIntrospectionService
{
    /// <summary>
    /// 验证令牌
    /// </summary>
    /// <param name="token">访问令牌</param>
    /// <param name="identityServiceUrl">身份服务URL</param>
    /// <returns>令牌信息</returns>
    Task<TokenIntrospectionResponse?> IntrospectTokenAsync(string token, string identityServiceUrl);
}

在这个接口中,我们只定义了一个方法IntrospectTokenAsync,它接受一个访问令牌和身份服务的URL,并返回一个TokenIntrospectionResponse对象,表示令牌的相关信息。以下是TokenIntrospectionResponse的定义:

/// <summary>
/// 表示令牌内省(Token Introspection)接口的响应结果。
/// </summary>
public class TokenIntrospectionResponse
{
    /// <summary>
    /// 令牌是否处于激活状态。
    /// </summary>
    public bool IsActive { get; set; }

    /// <summary>
    /// 令牌关联的主体(Subject)。
    /// </summary>
    public string? Subject { get; set; }

    /// <summary>
    /// 令牌关联的用户名。
    /// </summary>
    public string? Username { get; set; }

    /// <summary>
    /// 令牌关联的邮箱地址。
    /// </summary>
    public string? Email { get; set; }

    /// <summary>
    /// 令牌的作用域(Scope)。
    /// </summary>
    public string? Scope { get; set; }

    /// <summary>
    /// 客户端标识(ClientId)。
    /// </summary>
    public string? ClientId { get; set; }

    /// <summary>
    /// 令牌过期时间(Unix时间戳,秒)。
    /// </summary>
    public long? ExpiresAt { get; set; }

    /// <summary>
    /// 令牌关联的角色列表。
    /// </summary>
    public List<string> Roles { get; set; } = new();

    /// <summary>
    /// 令牌关联的权限列表。
    /// </summary>
    public List<string> Permissions { get; set; } = new();
}

在这个类中,我们定义了多个属性来表示令牌的相关信息,包括是否激活、主体、用户名、邮箱、作用域、客户端标识、过期时间、角色和权限列表。接下来,我们需要实现这个接口,创建一个TokenIntrospectionService类来处理令牌内省的逻辑。

using System.Text;
using System.Text.Json;
using SP.Gateway.Models;

namespace SP.Gateway.Services.Impl;

/// <summary>
/// 令牌内省服务实现
/// </summary>
public class TokenIntrospectionService : ITokenIntrospectionService
{
    // HttpClient 用于发送 HTTP 请求
    private readonly HttpClient _httpClient;

    // 日志记录器
    private readonly ILogger<TokenIntrospectionService> _logger;

    // 网关配置服务
    private readonly IGatewayConfigService _configService;

    /// <summary>
    /// 构造函数,注入依赖
    /// </summary>
    /// <param name="httpClient">HTTP 客户端</param>
    /// <param name="logger">日志记录器</param>
    /// <param name="configService">网关配置服务</param>
    public TokenIntrospectionService(
        HttpClient httpClient,
        ILogger<TokenIntrospectionService> logger,
        IGatewayConfigService configService)
    {
        _httpClient = httpClient;
        _logger = logger;
        _configService = configService;
    }

    /// <summary>
    /// 异步令牌内省方法,校验令牌有效性并解析相关信息
    /// </summary>
    /// <param name="token">待校验的令牌</param>
    /// <param name="identityServiceUrl">身份服务地址</param>
    /// <returns>令牌内省结果,若无效则返回 null</returns>
    public async Task<TokenIntrospectionResponse?> IntrospectTokenAsync(string token, string identityServiceUrl)
    {
        try
        {
            // 获取身份服务配置
            var config = await _configService.GetIdentityServiceConfigAsync();

            // 构造内省接口地址
            var introspectionUrl = $"{identityServiceUrl.TrimEnd('/')}/api/auth/introspect";
            // 构造请求体
            var requestData = new FormUrlEncodedContent(new[]
            {
                new KeyValuePair<string, string>("token", token)
            });

            // 若配置了客户端凭证,则添加 Basic Auth 头
            if (!string.IsNullOrEmpty(config.ClientId) && !string.IsNullOrEmpty(config.ClientSecret))
            {
                var authHeader = Convert.ToBase64String(
                    Encoding.UTF8.GetBytes($"{config.ClientId}:{config.ClientSecret}"));
                _httpClient.DefaultRequestHeaders.Authorization =
                    new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authHeader);
            }

            // 发送 POST 请求
            var response = await _httpClient.PostAsync(introspectionUrl, requestData);

            // 请求失败则记录警告并返回 null
            if (!response.IsSuccessStatusCode)
            {
                _logger.LogWarning("令牌内省请求失败,状态码: {StatusCode}", response.StatusCode);
                return null;
            }

            // 读取响应内容
            var content = await response.Content.ReadAsStringAsync();
            var introspectionResponse = JsonSerializer.Deserialize<JsonElement>(content);

            // 检查令牌是否有效
            if (!introspectionResponse.TryGetProperty("active", out var activeProperty) ||
                !activeProperty.GetBoolean())
            {
                _logger.LogDebug("令牌内省结果显示令牌无效");
                return null;
            }

            // 构造令牌内省结果对象
            var result = new TokenIntrospectionResponse
            {
                IsActive = true,
                Subject = GetStringProperty(introspectionResponse, "sub"),
                Username = GetStringProperty(introspectionResponse, "username"),
                Email = GetStringProperty(introspectionResponse, "email"),
                Scope = GetStringProperty(introspectionResponse, "scope"),
                ClientId = GetStringProperty(introspectionResponse, "client_id"),
                ExpiresAt = GetLongProperty(introspectionResponse, "exp")
            };

            // 解析角色信息
            if (introspectionResponse.TryGetProperty("roles", out var rolesProperty))
            {
                result.Roles = rolesProperty.EnumerateArray()
                    .Select(r => r.GetString())
                    .Where(r => !string.IsNullOrEmpty(r))
                    .ToList()!;
            }

            // 解析权限信息
            if (introspectionResponse.TryGetProperty("permissions", out var permissionsProperty))
            {
                result.Permissions = permissionsProperty.EnumerateArray()
                    .Select(p => p.GetString())
                    .Where(p => !string.IsNullOrEmpty(p))
                    .ToList()!;
            }

            _logger.LogDebug("令牌内省成功,用户: {Username}, 客户端: {ClientId}",
                result.Username, result.ClientId);

            return result;
        }
        catch (Exception ex)
        {
            // 异常处理,记录错误日志
            _logger.LogError(ex, "令牌内省时发生错误");
            return null;
        }
    }

    /// <summary>
    /// 从 JsonElement 获取字符串属性
    /// </summary>
    /// <param name="element">JsonElement 对象</param>
    /// <param name="propertyName">属性名</param>
    /// <returns>属性值或 null</returns>
    private static string? GetStringProperty(JsonElement element, string propertyName)
    {
        return element.TryGetProperty(propertyName, out var property) ? property.GetString() : null;
    }

    /// <summary>
    /// 从 JsonElement 获取长整型属性
    /// </summary>
    /// <param name="element">JsonElement 对象</param>
    /// <param name="propertyName">属性名</param>
    /// <returns>属性值或 null</returns>
    private static long? GetLongProperty(JsonElement element, string propertyName)
    {
        return element.TryGetProperty(propertyName, out var property) ? property.GetInt64() : null;
    }
}

这个实现代码整体围绕令牌内省(Token Introspection)流程展开,目的是在网关层对接收到的令牌进行有效性校验,并解析出令牌所包含的用户信息和权限数据。首先,在类的构造函数中,通过依赖注入获取了 HttpClient、日志记录器(Logger)以及网关配置服务(GatewayConfigService)。HttpClient 用于后续向身份认证服务发送 HTTP 请求,日志记录器负责在各个关键步骤输出日志,便于问题追踪和运维,而网关配置服务则提供了身份服务的相关配置参数,比如令牌内省接口地址、客户端标识(ClientId)、客户端密钥(ClientSecret)等。

IntrospectTokenAsync 方法中,首先通过配置服务获取身份服务的客户端标识和密钥,这些信息通常用于接口认证。随后,拼接出令牌内省接口的完整地址,并构造请求体,将待校验的令牌作为参数传递。此处请求体一般采用 application/x-www-form-urlencoded 格式,确保兼容 OAuth2 标准。如果身份服务配置了客户端凭证,则会将其按照 Basic Auth 的规范进行编码,并添加到 HTTP 请求头部,确保请求具备必要的认证信息,防止未授权访问令牌内省接口。

接下来,使用 HttpClient 以异步方式发送 POST 请求到令牌内省接口。收到响应后,首先检查 HTTP 状态码,如果请求失败(如 401、403 或 500),则通过日志记录器输出警告日志,并返回 null,表示令牌校验失败。若请求成功,则读取响应内容,并将其反序列化为 JsonElement,以便后续解析。此时会重点检查响应中的 active 字段,判断令牌是否处于激活状态。如果令牌无效或已过期,则记录调试日志并返回 null,避免后续流程继续处理无效令牌。

如果令牌有效,则从响应中依次提取主体(sub)、用户名(username)、邮箱(email)、作用域(scope)、客户端标识(client_id)、过期时间(exp)等关键信息,并赋值给 TokenIntrospectionResponse 对象。对于角色(roles)和权限(permissions)等列表字段,通常会进行解析和转换,确保能够正确存储到响应对象的对应属性中。整个过程中,日志记录器会在令牌内省成功时输出调试日志,方便后续追踪和分析。

该实现充分利用了依赖注入机制,使得各个服务之间解耦,便于测试和维护。同时采用异步编程模式,提高了接口调用的性能和响应速度。日志机制贯穿始终,确保在出现异常或关键事件时能够及时记录和定位问题。通过这种方式,令牌内省流程不仅安全可靠,还为后续的认证和授权逻辑提供了详实的用户和权限数据基础,极大提升了网关的安全性和可扩展性。

通过以上代码,我们实现了一个完整的网关令牌内省服务,可以在网关中使用它来验证令牌的有效性,并获取相关的用户信息和权限数据。

二、认证中间件

接下来,我们需要在网关中添加认证中间件,以便在请求到达网关时进行令牌验证。认证中间件的核心作用是拦截所有进入网关的 HTTP 请求,根据配置判断是否需要认证,并对令牌进行有效性校验。

我们采用自定义的认证中间件,而不是依赖 OpenIddict 的验证机制,这样可以避免在应用启动时因为服务未完全启动而导致的同步调用问题。我们的认证中间件会在每次请求时动态获取身份服务URL,并使用令牌内省服务直接验证令牌。

using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using OpenIddict.Abstractions;
using SP.Common.Redis;
using SP.Gateway.Services;


namespace SP.Gateway.Middleware;

/// <summary>
/// 完整的认证中间件
/// </summary>
public class SPAuthenticationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<SPAuthenticationMiddleware> _logger;
    private readonly IRedisService _redisService;
    private readonly IGatewayConfigService _configService;
    private readonly INacosServiceDiscoveryService _serviceDiscovery;
    private readonly ITokenIntrospectionService _tokenIntrospectionService;

    public SPAuthenticationMiddleware(
        RequestDelegate next,
        ILogger<SPAuthenticationMiddleware> logger,
        IRedisService redisService,
        IGatewayConfigService configService,
        INacosServiceDiscoveryService serviceDiscovery,
        ITokenIntrospectionService tokenIntrospectionService)
    {
        _next = next;
        _logger = logger;
        _redisService = redisService;
        _configService = configService;
        _serviceDiscovery = serviceDiscovery;
        _tokenIntrospectionService = tokenIntrospectionService;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var path = context.Request.Path.Value ?? "";
        
        var requiresAuthentication = await _configService.IsAuthenticationRequiredAsync(path);
        
        if (!requiresAuthentication)
        {
            _logger.LogDebug("路径 {Path} 跳过认证", path);
            await _next(context);
            return;
        }

        try
        {
            // 获取Authorization头
            var authorizationHeader = context.Request.Headers["Authorization"].ToString();
            if (string.IsNullOrEmpty(authorizationHeader) || !authorizationHeader.StartsWith("Bearer "))
            {
                context.Response.StatusCode = 401;
                await context.Response.WriteAsJsonAsync(new { 
                    error = "invalid_token", 
                    error_description = "缺少有效的访问令牌" 
                });
                return;
            }

            var token = authorizationHeader.Substring("Bearer ".Length);
            
            // 获取身份服务URL
            var bestUrl = await _serviceDiscovery.GetBestIdentityServiceUrlAsync();
            if (string.IsNullOrEmpty(bestUrl))
            {
                context.Response.StatusCode = 503;
                await context.Response.WriteAsJsonAsync(new { 
                    error = "service_unavailable", 
                    error_description = "身份服务不可用" 
                });
                return;
            }

            // 使用令牌内省服务验证令牌
            var introspectionResult = await _tokenIntrospectionService.IntrospectTokenAsync(token, bestUrl);
            if (introspectionResult == null || !introspectionResult.IsActive)
            {
                context.Response.StatusCode = 401;
                await context.Response.WriteAsJsonAsync(new { 
                    error = "invalid_token", 
                    error_description = "无效的访问令牌" 
                });
                return;
            }

            // 验证Redis中的令牌
            if (!string.IsNullOrEmpty(introspectionResult.Subject))
            {
                var tokenExists = await ValidateTokenInRedis(introspectionResult.Subject, token);
                if (!tokenExists)
                {
                    context.Response.StatusCode = 401;
                    await context.Response.WriteAsJsonAsync(new { 
                        error = "invalid_token", 
                        error_description = "令牌已被撤销" 
                    });
                    return;
                }
            }

            // 创建ClaimsPrincipal
            var claims = new List<Claim>
            {
                new Claim(OpenIddictConstants.Claims.Subject, introspectionResult.Subject ?? ""),
                new Claim(OpenIddictConstants.Claims.Name, introspectionResult.Username ?? ""),
                new Claim(OpenIddictConstants.Claims.Email, introspectionResult.Email ?? "")
            };

            // 添加角色声明
            if (introspectionResult.Roles != null && introspectionResult.Roles.Any())
            {
                foreach (var role in introspectionResult.Roles)
                {
                    claims.Add(new Claim(OpenIddictConstants.Claims.Role, role));
                }
            }

            var identity = new ClaimsIdentity(claims, "Bearer");
            var principal = new ClaimsPrincipal(identity);
            
            context.User = principal;
            
            // 添加用户信息到请求头
            var userInfo = ExtractUserInfo(principal);
            foreach (var kvp in userInfo)
            {
                context.Request.Headers[kvp.Key] = kvp.Value;
            }
            
            context.Request.Headers["X-Used-Identity-Service"] = bestUrl;
            
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "认证过程中发生错误");
            context.Response.StatusCode = 500;
            await context.Response.WriteAsJsonAsync(new { 
                error = "server_error", 
                error_description = "认证服务内部错误" 
            });
        }
    }

    private async Task<bool> ValidateTokenInRedis(string userId, string token)
    {
        try
        {
            var tokenKey = $"Token:{userId}";
            var storedToken = await _redisService.GetAsync<string>(tokenKey);
            
            if (string.IsNullOrEmpty(storedToken))
            {
                return true; // 如果Redis中没有存储令牌,认为有效
            }
            
            return storedToken == token;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "验证Redis中的令牌时发生错误");
            return false;
        }
    }

    private Dictionary<string, string> ExtractUserInfo(ClaimsPrincipal principal)
    {
        var userInfo = new Dictionary<string, string>();
        
        var userId = principal.FindFirstValue(OpenIddictConstants.Claims.Subject);
        var username = principal.FindFirstValue(OpenIddictConstants.Claims.Name);
        var email = principal.FindFirstValue(OpenIddictConstants.Claims.Email);
        var roles = principal.FindAll(OpenIddictConstants.Claims.Role).Select(c => c.Value);
        
        if (!string.IsNullOrEmpty(userId))
            userInfo["X-User-Id"] = userId;
        if (!string.IsNullOrEmpty(username))
            userInfo["X-User-Name"] = username;
        if (!string.IsNullOrEmpty(email))
            userInfo["X-User-Email"] = email;
        if (roles.Any())
            userInfo["X-User-Roles"] = string.Join(",", roles);
            
        return userInfo;
    }
}

这段中间件的逻辑是一个完整的认证流程描述。当请求进入时,首先会读取请求路径,并通过配置服务判断该路径是否需要认证,如果不需要则直接跳过继续执行后续中间件。若需要认证,则先检查请求头中是否包含有效的 Bearer Token,如果没有则返回 401 错误。

接下来通过服务发现获取一个可用的身份服务地址,如果没有获取到,则返回 503,提示身份服务不可用。成功获取后,使用令牌内省服务验证令牌的有效性。如果验证失败,则返回 401 错误。

如果令牌验证成功,则会取出用户 ID,再结合请求头里的访问令牌去 Redis 做一次二次校验:检查 Redis 中是否存有该用户的令牌记录,如果存有但与当前令牌不一致,说明令牌已被撤销,则返回 401;如果一致或 Redis 没存该用户令牌,则视为合法继续处理。

校验通过后,会根据令牌内省结果创建 ClaimsPrincipal,包含用户的主体、用户名、邮箱和角色等信息,并将其赋给当前上下文用户。同时将用户信息写入请求头,方便后续服务消费,也记录下使用的身份服务地址,最后进入后续中间件。

若认证过程中发生异常,则记录日志并返回 500,提示服务器内部错误。整体上,这个中间件实现了"路径级别认证控制 → 动态身份服务发现 → 令牌内省验证 → Redis 校验令牌有效性 → 将用户信息透传给下游服务"的完整认证闭环。

三、服务注册配置

在所有工作完成后,我们需要在网关的Program.cs 中注册相关服务,以便在请求管道中使用它们,代码如下:

using Ocelot.DependencyInjection;
using Ocelot.Middleware;
using Ocelot.Provider.Nacos;
using Nacos.V2.DependencyInjection;
using Nacos.AspNetCore.V2;
using SP.Gateway.Middleware;
using SP.Common.Redis;
using SP.Gateway.Services;
using SP.Gateway.Services.Impl;

var builder = WebApplication.CreateBuilder(args);

// Nacos 配置中心
builder.Services.AddNacosV2Naming(builder.Configuration);
builder.Configuration.AddNacosV2Configuration(builder.Configuration.GetSection("nacos"));
// 添加Nacos服务注册
builder.Services.AddNacosAspNet(builder.Configuration);

// 基础服务
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

// 添加 HTTP 客户端用于获取微服务的 OpenAPI 文档
builder.Services.AddHttpClient();

// 添加Redis服务
builder.Services.AddRedisService(builder.Configuration);

// 注册服务发现和配置服务
builder.Services.AddSingleton<INacosServiceDiscoveryService, NacosServiceDiscoveryService>();
builder.Services.AddSingleton<IGatewayConfigService, NacosGatewayConfigService>();
builder.Services.AddScoped<ITokenIntrospectionService, TokenIntrospectionService>();

// 添加HTTP客户端
builder.Services.AddHttpClient("IdentityServiceHealthCheck", client => { client.Timeout = TimeSpan.FromSeconds(10); });
builder.Services.AddHttpClient("TokenIntrospection", client => { client.Timeout = TimeSpan.FromSeconds(30); });

// Ocelot + Nacos 服务发现
builder.Services.AddOcelot(builder.Configuration)
    .AddNacosDiscovery();

if (builder.Environment.IsDevelopment() || builder.Environment.EnvironmentName == "Local")
{
    // Swagger 配置
    builder.Services.AddSwaggerGen();
    builder.Services.AddSwaggerForOcelot(builder.Configuration);
}

var app = builder.Build();

// 中间件管道
if (app.Environment.IsDevelopment() || app.Environment.EnvironmentName == "Local")
{
    app.UseSwagger();
    app.UseSwaggerForOcelotUI(opt =>
    {
        opt.PathToSwaggerGenerator = "/swagger/docs";
    });
}

app.UseHttpsRedirection();

// 添加完整认证中间件
app.UseMiddleware<SPAuthenticationMiddleware>();

app.UseAuthorization();

app.MapControllers();

// 启用 Ocelot 作为网关
await app.UseOcelot();

app.Run();

这样,我们就完成了认证中间件的注册。通过这个中间件,我们可以在网关中对请求进行令牌验证,并将用户信息传递给下游服务。

四、总结

在这篇文章中,我们介绍了如何在网关中实现令牌内省服务和认证中间件。通过这些组件,我们可以在网关层对令牌进行有效性验证,并将用户信息传递给下游服务。

主要改进点:

  1. 移除了 OpenIddict 验证扩展:避免了在应用启动时的同步服务发现调用,解决了 GetBestIdentityServiceUrlAsync() 返回 null 的问题。

  2. 采用自定义认证中间件:在每次请求时动态获取身份服务URL,使用令牌内省服务直接验证令牌,提高了系统的灵活性和容错能力。

  3. 动态服务发现:每次请求都能获取最新的服务实例,如果某个实例不可用,会自动选择其他实例。

  4. 启动友好:不会因为服务未启动而导致网关启动失败。

这种设计不仅提高了系统的安全性,还增强了微服务架构的灵活性和可扩展性,是微服务架构下统一认证与授权的理想选择。

希望这篇文章对你理解网关令牌内省服务有所帮助。如果你有任何问题或建议,请随时在评论区留言。感谢你的阅读,我们下次再见!


网站公告

今日签到

点亮在社区的每一天
去签到