using System.Net;
using System.Security.Claims;
using Dpz.Core.EnumLibrary;
using Microsoft.AspNetCore.Authentication;
using OpenIddict.Client.AspNetCore;

namespace Dpz.Core.Web.Jobs.Security;

/// <summary>
/// Hangfire Dashboard 访问控制中间件:对 Dashboard 路径进行认证和权限校验。
/// </summary>
/// <param name="next">下一个中间件。</param>
/// <param name="webHostEnvironment">宿主环境,用于读取静态文件。</param>
public class HangfireDashboardAccessMiddleware(
    RequestDelegate next,
    IWebHostEnvironment webHostEnvironment
)
{
    private const string ForbiddenPagePath = "template/HangfireForbidden.html";

    /// <summary>
    /// 中间件入口:仅对 Dashboard 路径生效,依次执行认证检查、权限检查。
    /// </summary>
    /// <param name="context">当前 HTTP 上下文。</param>
    public async Task InvokeAsync(HttpContext context)
    {
        // 非 Dashboard 路径直接放行
        if (!context.Request.Path.StartsWithSegments(Program.ConsoleUrl))
        {
            await next(context);
            return;
        }

        // 未登录 → 跳转 OpenIddict 认证
        if (context.User.Identity?.IsAuthenticated != true)
        {
            await ChallengeAsync(context);
            return;
        }

        // 无 System 权限 → 返回 403 禁止页面
        if (!HangfireDashboardPermission.HasPermission(context.User, Permissions.System))
        {
            await WriteForbiddenPageAsync(context, webHostEnvironment);
            return;
        }

        await next(context);
    }

    private static Task ChallengeAsync(HttpContext context)
    {
        var returnUrl =
            context.Request.PathBase + context.Request.Path + context.Request.QueryString;
        var properties = new AuthenticationProperties { RedirectUri = returnUrl };

        return context.ChallengeAsync(
            OpenIddictClientAspNetCoreDefaults.AuthenticationScheme,
            properties
        );
    }

    private static async Task WriteForbiddenPageAsync(
        HttpContext context,
        IWebHostEnvironment webHostEnvironment
    )
    {
        context.Response.StatusCode = StatusCodes.Status403Forbidden;
        context.Response.ContentType = "text/html; charset=utf-8";
        context.Response.Headers.CacheControl = "no-store";

        var displayName = context.User.FindFirst("displayName")?.Value;
        var accountLabel = string.IsNullOrWhiteSpace(displayName)
            ? "当前账号"
            : WebUtility.HtmlEncode(displayName);

        var fileInfo = webHostEnvironment.WebRootFileProvider.GetFileInfo(ForbiddenPagePath);
        if (!fileInfo.Exists)
        {
            await context.Response.WriteAsync("没有对应的权限");
            return;
        }

        await using var stream = fileInfo.CreateReadStream();
        using var reader = new StreamReader(stream);
        var html = await reader.ReadToEndAsync(context.RequestAborted);

        // 将模板中的 {{accountLabel}} 占位符替换为当前用户名
        await context.Response.WriteAsync(
            html.Replace("{{accountLabel}}", accountLabel, StringComparison.Ordinal),
            context.RequestAborted
        );
    }
}

/// <summary>
/// <see cref="HangfireDashboardAccessMiddleware"/> 的 IApplicationBuilder 扩展方法。
/// </summary>
public static class HangfireDashboardAccessMiddlewareExtensions
{
    /// <summary>
    /// 注册 Hangfire Dashboard 访问控制中间件。
    /// </summary>
    /// <param name="app">应用构建器。</param>
    public static IApplicationBuilder UseHangfireDashboardAccess(this IApplicationBuilder app)
    {
        return app.UseMiddleware<HangfireDashboardAccessMiddleware>();
    }
}

/// <summary>
/// Hangfire Dashboard 权限校验工具类,基于 Claims 中的 Permission 声明做位运算判断。
/// </summary>
public static class HangfireDashboardPermission
{
    /// <summary>
    /// 检查用户是否拥有指定权限(位掩码匹配)。
    /// </summary>
    /// <param name="user">当前用户主体。</param>
    /// <param name="permission">需要校验的权限位。</param>
    /// <returns>用户已认证且拥有该权限时返回 <c>true</c>。</returns>
    public static bool HasPermission(ClaimsPrincipal user, Permissions permission)
    {
        var permissionClaim = user.FindFirst("Permission")?.Value;

        return !string.IsNullOrWhiteSpace(permissionClaim)
            && Enum.TryParse<Permissions>(permissionClaim, ignoreCase: true, out var permissions)
            && (permissions & permission) == permission;
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个用于保护 Hangfire Dashboard 访问的 ASP.NET Core 中间件实现,提供了基于 OpenIddict 的身份认证和基于 Claims 的权限授权功能。


核心组件

1. HangfireDashboardAccessMiddleware - 访问控制中间件

这是主要的中间件类,负责拦截对 Hangfire Dashboard 的请求并执行安全检查。

构造函数参数:

  • next: 管道中的下一个中间件
  • webHostEnvironment: 用于访问静态文件(如 403 禁止页面模板)

核心逻辑(InvokeAsync 方法):

请求流程:
┌─────────────────────────────────────┐
│ 1. 检查路径是否为 Dashboard 路径    │
│    (Program.ConsoleUrl)            │
└──────────┬──────────────────────────┘
           │ 否 → 直接放行
           │ 是 ↓
┌──────────▼──────────────────────────┐
│ 2. 检查用户是否已登录               │
└──────────┬──────────────────────────┘
           │ 否 → 跳转 OpenIddict 登录
           │ 是 ↓
┌──────────▼──────────────────────────┐
│ 3. 检查是否拥有 System 权限         │
└──────────┬──────────────────────────┘
           │ 否 → 返回 403 禁止页面
           │ 是 ↓
┌──────────▼──────────────────────────┐
│ 4. 允许访问 Dashboard               │
└─────────────────────────────────────┘

2. ChallengeAsync - 认证挑战方法

当用户未登录时,触发 OpenIddict 认证流程:

  • 保存当前请求的完整 URL(PathBase + Path + QueryString
  • 设置为认证成功后的返回地址
  • 调用 OpenIddict 客户端认证方案

3. WriteForbiddenPageAsync - 403 页面渲染

当用户已登录但无权限时:

  • 设置 HTTP 状态码为 403
  • wwwroot/template/HangfireForbidden.html 读取模板
  • 提取用户的 displayName Claim
  • 将模板中的 {{accountLabel}} 占位符替换为用户名
  • 返回个性化的禁止访问页面

安全细节

  • 使用 WebUtility.HtmlEncode 防止 XSS 攻击
  • 设置 Cache-Control: no-store 防止页面被缓存

4. HangfireDashboardPermission - 权限校验工具类

提供静态方法 HasPermission 进行位运算权限检查:

// 权限检查逻辑:
// 1. 从用户 Claims 中提取 "Permission" 值
// 2. 尝试解析为 Permissions 枚举(忽略大小写)
// 3. 使用位与运算检查是否包含指定权限
(permissions & permission) == permission

示例

// 假设用户权限为:Permissions.Read | Permissions.System = 0b11
// 检查 Permissions.System (0b10)
// 0b11 & 0b10 = 0b10 → 匹配成功

5. HangfireDashboardAccessMiddlewareExtensions - 扩展方法

提供便捷的注册方法:

app.UseHangfireDashboardAccess();

使用场景

Program.csStartup.cs 中:

app.UseAuthentication();
app.UseAuthorization();
app.UseHangfireDashboardAccess();  // 必须在认证授权之后
app.UseHangfireDashboard(Program.ConsoleUrl);

依赖技术栈

  • OpenIddict: 提供 OAuth 2.0/OpenID Connect 认证
  • Claims-Based Authorization: 基于声明的权限模型
  • Enum Flags Pattern: 使用枚举位标志进行权限控制(Permissions 枚举需标记 [Flags]
  • ASP.NET Core Middleware Pipeline: 标准中间件模式

安全特性

  1. 认证隔离:未登录用户无法访问
  2. 精细权限控制:支持位运算的组合权限
  3. XSS 防护:用户名经过 HTML 编码
  4. 缓存控制:禁止缓存敏感页面
  5. 友好体验:提供定制化的 403 页面
评论加载中...