namespace Dpz.Core.Auth.Middleware;

public class AuthStatusPageMiddleware(
    RequestDelegate next,
    IConfiguration configuration,
    ILogger<AuthStatusPageMiddleware> logger,
    IIpRateLimitService rateLimitService
)
{
    public async Task InvokeAsync(HttpContext context, AuthShellRenderService shellRenderService)
    {
        await next(context);

        if (!ShouldRenderStatusPage(context))
        {
            return;
        }

        var requestPath = context.Request.Path.Value ?? "";
        if (
            context.Response.StatusCode == StatusCodes.Status404NotFound
            && context.User.Identity?.IsAuthenticated != true
        )
        {
            await RecordNotFoundAsync(context, requestPath);
        }

        AuthShellViewModel? model = context.Response.StatusCode switch
        {
            StatusCodes.Status403Forbidden => CreateForbiddenModel(),
            StatusCodes.Status404NotFound => CreateNotFoundModel(requestPath),
            StatusCodes.Status500InternalServerError => CreateErrorModel(),
            _ => null,
        };

        if (model is null)
        {
            return;
        }

        context.Response.ContentLength = null;
        await shellRenderService.WriteAsync(context, model);
    }

    private static bool ShouldRenderStatusPage(HttpContext context)
    {
        if (
            context.Response.HasStarted
            || context.Request.Method.Equals("HEAD", StringComparison.OrdinalIgnoreCase)
            || context.Response.StatusCode
                is not StatusCodes.Status403Forbidden
                    and not StatusCodes.Status404NotFound
                    and not StatusCodes.Status500InternalServerError
        )
        {
            return false;
        }

        return IsHtmlRequest(context) && !IsProtocolOrApiRequest(context.Request.Path);
    }

    private static bool IsHtmlRequest(HttpContext context)
    {
        var accept = context.Request.Headers.Accept.ToString();
        if (string.IsNullOrWhiteSpace(accept))
        {
            return true;
        }

        return accept.Contains("text/html", StringComparison.OrdinalIgnoreCase)
            || accept.Contains("*/*", StringComparison.OrdinalIgnoreCase);
    }

    private static bool IsProtocolOrApiRequest(PathString path)
    {
        return path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase)
            || path.StartsWithSegments("/connect/token", StringComparison.OrdinalIgnoreCase)
            || path.StartsWithSegments("/connect/userinfo", StringComparison.OrdinalIgnoreCase)
            || path.StartsWithSegments("/connect/introspect", StringComparison.OrdinalIgnoreCase)
            || path.StartsWithSegments("/connect/revocation", StringComparison.OrdinalIgnoreCase);
    }

    private async Task RecordNotFoundAsync(HttpContext context, string requestPath)
    {
        if (string.IsNullOrWhiteSpace(requestPath) || requestPath == "/")
        {
            return;
        }

        var ignoreRequestPrefixes =
            configuration.GetSection("IgnoreRequestPrefix").Get<List<string>>() ?? [];
        if (
            ignoreRequestPrefixes.Any(prefix =>
                requestPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
            )
        )
        {
            return;
        }

        try
        {
            await rateLimitService.RecordEventAsync(
                context.Request.GetIpAddress(),
                "404请求",
                context.RequestAborted
            );
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "记录404请求失败,RequestPath: {RequestPath}", requestPath);
        }
    }

    private static AuthShellViewModel<string[]> CreateForbiddenModel() =>
        new()
        {
            EntryName = "public",
            PageName = "forbidden",
            Message = "rejected-request",
            Payload =
            [
                "你的 IP 访问频率过高",
                "User-Agent 并不被信任",
                "浏览器版本过低",
                "访问了禁止访问的资源",
            ],
        };

    private static AuthShellViewModel<NotFoundShellPayload> CreateNotFoundModel(
        string requestPath
    ) =>
        new()
        {
            EntryName = "public",
            PageName = "not-found",
            Message = "not-found",
            Payload = new NotFoundShellPayload { Path = requestPath },
        };

    private static AuthShellViewModel<EmptyAuthShellPayload> CreateErrorModel() =>
        new()
        {
            EntryName = "public",
            PageName = "auth-status",
            Message = "server-error",
            Payload = new EmptyAuthShellPayload(),
        };
}

public class NotFoundShellPayload
{
    public required string Path { get; init; }
}

public static class AuthStatusPageMiddlewareExtensions
{
    public static IApplicationBuilder UseAuthStatusPages(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<AuthStatusPageMiddleware>();
    }
}
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这段代码是 ASP.NET Core 中的中间件,用于针对特定 HTTP 状态码(403、404、500)渲染自定义的“状态页面”(public 页面),同时带有 404 请求的记录/限流统计功能。下面从整体到细节逐层解释。

1. 类定义与依赖注入(主构造函数)

public class AuthStatusPageMiddleware(
    RequestDelegate next,
    IConfiguration configuration,
    ILogger<AuthStatusPageMiddleware> logger,
    IIpRateLimitService rateLimitService
)
  • 使用 主构造函数 注入四个依赖:
    • next:请求管道的下一个中间件委托,用于继续向后传递请求。
    • configuration:读取配置(如 IgnoreRequestPrefix)。
    • logger:日志记录器。
    • rateLimitService:IP 限流服务,用于记录事件(比如“404请求”),防止恶意扫描或攻击。

2. 核心处理方法 InvokeAsync

public async Task InvokeAsync(HttpContext context, AuthShellRenderService shellRenderService)
  • 注意 AuthShellRenderService 是通过方法注入传入的,而不是构造函数。通常是因为它是作用域(Scoped)服务,而中间件本身是单例(Singleton),不能直接注入作用域服务。
  • 逻辑流程:
await next(context);   // 先执行后面的中间件,得到最终状态码
  • 先让后续管道执行完毕,拿到 context.Response.StatusCode,再决定是否需要渲染自定义页面。

判断是否渲染状态页

if (!ShouldRenderStatusPage(context)) return;

ShouldRenderStatusPage 做了多重过滤,只有满足以下条件才继续:

  1. 响应未开始Response.HasStarted == false(若响应已经开始发送,不能再改写内容)。
  2. 请求方法不是 HEAD(HEAD 只需要响应头,不需要 body)。
  3. 状态码必须是 403 / 404 / 500 之一。
  4. 是 HTML 请求Accept 头为空、包含 text/html*/*
  5. 不是协议或 API 请求:排除 /api/connect/token/connect/userinfo 等 OAuth/OpenID Connect 端点。这些端点应返回 JSON 或错误详情,而不是一个完整的 HTML 页面。

这里 string.IsNullOrWhiteSpace(accept) 返回 true 表示“客户端不关心返回类型”,此时也按 HTML 处理,以便浏览器直接访问时能看到友好错误页。

记录未授权访问的 404

if (context.Response.StatusCode == 404 && context.User.Identity?.IsAuthenticated != true)
{
    await RecordNotFoundAsync(context, requestPath);
}
  • 对于 404 且用户未登录的请求,说明可能是来访者在扫描不存在的路径,或访问了无权查看的内容,因此调用 RecordNotFoundAsync 记录该事件,交给 IIpRateLimitService 做频控统计。

RecordNotFoundAsync 内部细节:

  • 忽略空路径、/ 根路径。
  • 读取配置中 IgnoreRequestPrefix 列表,如果请求路径以其中任意前缀开头(例如 /favicon.ico/static 等),则不记录。
  • 调用 rateLimitService.RecordEventAsync 并传入客户端 IP 与 context.RequestAborted 作为 CancellationToken。
  • 记录失败时只记录 Warning 日志,不影响主流程。

根据状态码构建页面模型

AuthShellViewModel? model = context.Response.StatusCode switch
{
    403 => CreateForbiddenModel(),
    404 => CreateNotFoundModel(requestPath),
    500 => CreateErrorModel(),
    _ => null,
};

C# switch 表达式,三种状态码映射到三个模型工厂方法。如果模型为 null 说明状态码不匹配,直接返回。

清空 ContentLength 并渲染

context.Response.ContentLength = null;
await shellRenderService.WriteAsync(context, model);
  • ContentLength 置空,这样渲染服务可以自由写入响应体,避免长度不匹配。
  • shellRenderService.WriteAsync 负责将 AuthShellViewModel<T> 渲染成最终的 HTML 字符串并写入 HttpContext.Response

3. 模型构建(视图数据)

三个静态工厂方法分别构造不同泛型负载的 AuthShellViewModel<T>

403 Forbidden 模型

new AuthShellViewModel<string[]> {
    EntryName = "public",
    PageName = "forbidden",
    Message = "rejected-request",
    Payload = [ "你的 IP 访问频率过高", "User-Agent 并不被信任", ... ]
}
  • EntryName = "public":表示使用 public 入口(例如 SPA 的 public bundle)。
  • PageName = "forbidden":对应前端路由/页面名称。
  • Message = "rejected-request":给前端一个错误标识。
  • Payload:包含了四条展示给用户的可能原因说明,均为中文。

404 模型

new AuthShellViewModel<NotFoundShellPayload> {
    PageName = "not-found",
    Payload = new NotFoundShellPayload { Path = requestPath }
}
  • 把请求路径传给页面,用于显示“找不到 xxx 路径”。

500 模型

new AuthShellViewModel<EmptyAuthShellPayload> {
    PageName = "auth-status",
    Message = "server-error",
    Payload = new EmptyAuthShellPayload()
}
  • 专门用于显示服务器错误。

4. 辅助类与方法

NotFoundShellPayload

public class NotFoundShellPayload
{
    public required string Path { get; init; }
}
  • required(C# 11)表示必须初始化,init 表示只能在构造/对象初始化器中设置。

扩展方法 UseAuthStatusPages

public static IApplicationBuilder UseAuthStatusPages(this IApplicationBuilder builder)
{
    return builder.UseMiddleware<AuthStatusPageMiddleware>();
}
  • 方便开发者在 Program.cs 里直接使用:
app.UseAuthStatusPages();

5. 整体设计思路总结

关注点实现方式
何时生效仅对 403/404/500,且请求方期望 HTML 页面时
排除范围API(/api)、OAuth2 相关端点(/connect/*
前后端分离返回一个“HTML 外壳”,内部通过 AuthShellViewModel 携带数据,由 shellRenderService 渲染(很可能是一个 SSO/认证站点的通用页面模板)
安全防护记录未登录的 404 请求并按 IP 限流,能有效抑制路径扫描、恶意探测等行为
错误容忍记录 404 失败仅打印 Warning,不阻断正常请求

整体来看,这个中间件服务的核心场景是认证/访问控制网关:当用户被拒绝(403)、访问不存在页面(404)或遭遇服务器错误(500)时,返回一个 UI 统一、体验友好的状态页;同时把“没权限 + 页面不存在”的信号用作限流通报的输入,防止暴力扫描。

评论加载中...