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 做了多重过滤,只有满足以下条件才继续:
- 响应未开始:
Response.HasStarted == false(若响应已经开始发送,不能再改写内容)。 - 请求方法不是 HEAD(HEAD 只需要响应头,不需要 body)。
- 状态码必须是 403 / 404 / 500 之一。
- 是 HTML 请求:
Accept头为空、包含text/html或*/*。 - 不是协议或 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 统一、体验友好的状态页;同时把“没权限 + 页面不存在”的信号用作限流通报的输入,防止暴力扫描。
AI 正在分析代码…
评论加载中...