Webhook客户端(WebhookClient)
前言
山高路远,止不住行者征程
春秋几变,篆刻鲲鹏轨迹
1.基础设置配置
默认基础设置配置:
- 1.健康检测
- 2.
OpenTelemetry(可观测性框架:链路追踪、指标采集、日志、数据导出) - 3.服务注册和发现
- 4.
HttpClient

2.Blazor配置
WebhookClient是基于Blazor开发的Web项目,所以需要注册Blazor相关配置
注册 Blazor Razor 组件系统,并启用服务器端交互式渲染,使 .razor 页面可以处理用户事件(如点击、表单提交)并动态更新 UI,而无需整页刷新。
关于Blazor这里不是重点,而且属于微软比较新的技术,我了解的也比较少,这里只是以可运行项目为主,遇到什么问题解决什么问题

3.认证和授权
WebhookClient是基于Blazor开发的Web项目,即像客户端,又像服务端,所以认证授权和之前纯后端服务有些不一样,还是一行一行学习吧
3.1授权(AddAuthorization)
这里没有添加授权方案,默认的授权方案是认证登录
之前后端服务都是先认证再授权,为什么这里先授权再认证?
在 ASP.NET Core / Blazor 中,注册顺序看起来是“先授权再认证”,不会影响;真正生效的顺序是中间件执行顺序:先认证(Authentication),再授权(Authorization),确保
[Authorize]能正确读取用户身份。

3.2认证(AddAuthentication)
DefaultScheme:系统默认用哪个认证方案读取用户身份(告诉系统“你是谁”)。
DefaultChallengeScheme:当用户未认证访问受保护资源时,用哪个方案触发登录/认证流程(告诉系统“你没登录怎么办”)。
CookieAuthenticationDefaults.AuthenticationScheme:系统默认使用 Cookie 作为用户身份来源,用于读取和验证已登录用户的信息。
OpenIdConnectDefaults.AuthenticationScheme:当用户访问受保护资源但未登录时,系统默认触发 OpenID Connect 流程,让用户登录并获取令牌。

Cookie/OIDC 对比 JWT
-
API 用 JWT Bearer,只验证 Token → 不需要 DefaultScheme / ChallengeScheme
-
Web App 用 OIDC 授权码,需要 Cookie + 登录重定向 → 必须配置 DefaultScheme / ChallengeScheme
| 特性 | Cookie / OIDC | JWT |
|---|---|---|
| 状态 | 有状态,依赖 Cookie | 无状态,每次请求带 Token |
| 用户身份 | 存在服务器 Cookie | 存在 Token 里,解析即可 |
| Challenge(挑战) | 跳转登录页面 | 返回 401 Unauthorized |
| DefaultScheme/ChallengeScheme | 必须配置 | 可省略(JWT 默认就是 Bearer Token) |

3.3Cookie认证方案(AddCookie)
ExpireTimeSpan:过期时间。可以通过appsettings.json中配置SessionCookieLifetimeMinutes,没有配置的话默认60分钟
Cookie.Name:存储用户登录状态的 Cookie 名称,确保当前 Webhooks 客户端的身份信息独立,不与其他应用(即使在同一台机器和不同端口)冲突。如果不设置,系统会用 .AspNetCore.Cookies 来存储用户登录信息。
为什么会冲突?
WebApp
- URL:
https://localhost:5045- Cookie 默认名:
.AspNetCore.Cookies- 存储登录用户信息
WebhooksClient
- URL:
https://localhost:5062- Cookie 默认名:
.AspNetCore.Cookies(没改之前)用户同时在两个应用中访问,浏览器发送请求时,会把 Cookie 名称相同的
.AspNetCore.Cookies都带上:
- WebhooksClient 会读取到 WebApp 的 Cookie,认为用户已登录
- 登录信息不匹配 → 出现认证错误或“未授权”

3.4OpenID Connect挑战方案(AddOpenIdConnect)
配置OpenID Connect,其实大部分和认证服务中的配置相似
这些是 OIDC 客户端的标准/基础配置,注释都写请求了,我就不一个个过了,建议RequireHttpsMetadata=true,但我们测试环境用的是https,知道这是个隐患就行
为什么客户端和服务端配置大部分相似?
OAuth2 / OIDC 是一个协议
- 服务端(IdentityServer / ID4 / IdP)
- 客户端(Blazor / WebApp / SPA / Console)
都必须 遵守同一套参数 才能通信成功。
所以都会出现:
- clientId
- redirect_uri
- scopes
- response_type=code
- PKCE(code_challenge / code_verifier)
但是 用途不同:
配置项 服务端作用 客户端作用 clientId 识别客户端是谁 告诉服务器“我是谁” redirect_uri 允许哪些回调 告诉服务器“回调到我这里” scopes 允许申请哪些权限 我要申请这些权限 PKCE 校验安全性 生成 PKCE 参数

启动认证服务、webhooks服务、webhook前端,点击登录

跳转到了id4登录页面进行登录操作
这就是OpenID Connect挑战方案,未登录跳转到id4登录
https://localhost:5243/Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A16E33408E49FACDFCC5CE02F8F21D5829F10276D5443E27474F4C906EFA30003%26client_id%3Dwebhooksclient
解码后:
https://localhost:5243/Account/Login?ReturnUrl=/connect/authorize/callback?request_uri=urn:ietf:params:oauth:request_uri:16E33408E49FACDFCC5CE02F8F21D5829F10276D5443E27474F4C906EFA30003&client_id=webhooksclient

登录成功后又跳转回WebhookClient,然后打开控制台,查看Cookie认证方案的名称配置,发现名称都是以.AspNetCore.WebHooksClientIdentity开始
.AspNetCore.WebHooksClientIdentity chunks-2 localhost / 会话 42 ✓ Lax Medium
.AspNetCore.WebHooksClientIdentityC1 CfDJ8AiXIMZqjLRDs2az9HQ7eYOpnqu4i_WqnDXBuf4ZbcgIsZfYddA8m_Yido9P-OPk-wrck_0wHzgb7u2bJlY_9ecToPflA9aPHLUDRrRyA... localhost / 会话 4016 ✓ Lax Medium
.AspNetCore.WebHooksClientIdentityC2 1urYJZj3zeCnyLKN7DmAdC9q8Bx35csFZes4LhhvC0IQOJepBg_xnnVi87VhYqpLF4cax3gst649_00iR3BcHBOKsMSZYwACUnCO1uXNFGy62e-eEaYl3UXCiWH5qiULZ9_X6RdNVIMsyKngnVuTaHy2Khf5Dy7C8... localhost / 会话 3614 ✓ Lax Medium
Identity 保存“谁登录了”,IdentityC1/C2 保存“登录用户在 Blazor Server 的状态”,是 Blazor Server 的会话机制生成的。
| Cookie 名称 | 作用 | 类型 | 特点 |
|---|---|---|---|
.AspNetCore.WebHooksClientIdentity |
用户会话主标识 | 会话 Cookie | 保存登录状态引用 |
.AspNetCore.WebHooksClientIdentityC1/C2 |
用户状态分片 | 会话 Cookie | 保存 Blazor Circuit 状态 / session 数据,自动拆分 |

3.5Blazor认证状态管理(AuthenticationStateProvider)
在 Blazor 应用中,无论是 Blazor Server 还是 Blazor WebAssembly (WASM),都需要一个机制来获取当前用户的身份信息(Authentication)和角色/权限(Authorization)。
- Blazor 使用
AuthenticationStateProvider来提供当前用户的认证状态。 - 组件可以通过
CascadingAuthenticationState获取AuthenticationState对象,从而判断用户是否登录以及其 Claims 信息。
AuthenticationStateProvider
-
抽象类
-
GetAuthenticationStateAsync():返回当前用户的认证状态。 -
Blazor 组件可以注入
AuthenticationStateProvider并调用它获取用户信息。 -
核心用途:
- 提供当前用户信息(
ClaimsPrincipal)。 - 触发认证状态变化(
NotifyAuthenticationStateChanged),让 UI 动态更新。
- 提供当前用户信息(

ServerAuthenticationStateProvider
这是 Blazor 服务器端提供的一个实现:
- 用于 Blazor Server。
- 它会读取 HttpContext.User 来获取当前用户信息。
- 当用户登录或登出时,可以调用:
NotifyAuthenticationStateChanged(Task<AuthenticationState> task)

services.AddScoped:表示每个 Blazor Server Circuit(连接) 创建一个实例。注意:Blazor Server 的 Scoped 生命周期和 HTTP 请求不同,它是 “per circuit”,每个 SignalR 连接是一个 scope。

示例

总结
| 部分 | 作用 |
|---|---|
AuthenticationStateProvider |
抽象接口,用于获取用户认证状态 |
ServerAuthenticationStateProvider |
Blazor Server 实现,读取服务器端身份信息(HttpContext.User) |
AddScoped |
注册为作用域服务,每个 Circuit 一个实例 |
NotifyAuthenticationStateChanged |
触发组件重新渲染,响应用户登录/登出事件 |
3.5组件访问认证信息(AddCascadingAuthenticationState)
Blazor 组件要知道用户是否登录、角色、声明等信息,需要一个 身份状态来源(AuthenticationStateProvider):
- ServerAuthenticationStateProvider:Blazor Server 默认的实现,从服务器端获取用户身份。
- AuthenticationState:表示用户身份和认证状态(比如 ClaimsPrincipal)。
组件获取身份状态有两种方式:
- 注入
AuthenticationStateProvider并调用GetAuthenticationStateAsync()。 - 通过
<CascadingAuthenticationState>级联,让子组件直接用[CascadingParameter] Task<AuthenticationState> AuthenticationStateTask或<AuthorizeView>获取状态。
流程
- 用户访问 Blazor 页面。
- 如果未认证,OpenID Connect 会跳转到 IdentityServer 登录。
- 登录成功后,服务器端生成 Cookie。
- ServerAuthenticationStateProvider 读取 Cookie,并生成
AuthenticationState。 - AddCascadingAuthenticationState 把
AuthenticationState挂到组件树顶层。 <AuthorizeView>或[CascadingParameter] Task<AuthenticationState>直接拿到用户信息。
IdentityServer
↓ OpenID Connect (授权码模式)
WebhooksClient
↓ CookieAuthentication
ServerAuthenticationStateProvider
↓ GetAuthenticationStateAsync()
AddCascadingAuthenticationState() -> 自动注入级联值
↓
AuthorizeView / CascadingParameter -> 直接访问 AuthenticationState
AddCascadingAuthenticationState() 在 Blazor Server 中自动将服务器端 AuthenticationStateProvider 提供的用户身份状态挂到组件树顶层,使 <AuthorizeView> 和 [CascadingParameter] Task<AuthenticationState> 能直接访问用户认证信息,无需手动包裹 <CascadingAuthenticationState>。
3.6OpenID Connect认证授权流程
经过之前的学习,项目基本都能跑起来了(除了HybridApp),但是我还是想有始有终,全部学完,如果你此时还运行不了项目,先看看我的验证过程即可
3.6.1认证服务日志中间件(RequestLoggingMiddleware)
为了了解认证服务(Identity.API)处理了那些请求,方便我们学习OpenID Connect认证授权流程,我在认证服务(Identity.API)添加了一个日志中间件用于输出请求、参数、响应

await _next(context)上面输出请求、参数,下面输出响应结果

3.6.2启动客户端
启动认证服务、webhooks服务、webhook前端,点击登录

3.6.3启动认证服务端
认证服务端打印了日志,收到了GET /请求,返回了首页html
info: Identity.API.RequestLoggingMiddleware[0]
********** GET / Start **********
========== Incoming Request ==========
GET /
======================================
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
********** GET / End *

3.6.4客户端请求认证服务OIDC(/.well-known/openid-configuration)
客户端首先请求 /.well-known/openid-configuration,IdentityServer 返回 OIDC 发现文档,详细说明服务器的地址、授权端点、Token 端点、公钥信息、支持的 Scope、Claim、授权模式和响应类型等,使客户端能够根据这些信息自动、安全地完成登录、获取 Token 和验证用户身份的整个认证授权流程。
GET /.well-known/openid-configuration
部分响应字段含义
| 字段 | 说明 |
|---|---|
issuer |
发行者地址(重要) |
authorization_endpoint |
授权端点(浏览器登录跳转用) |
token_endpoint |
获取 Token 的端点 |
userinfo_endpoint |
获取用户信息 |
jwks_uri |
公钥地址,用来验证 JWT Token |
scopes_supported |
支持哪些 Scope |
claims_supported |
支持哪些 Claim |
grant_types_supported |
支持哪些授权模式 |
response_types_supported |
浏览器回调格式 |
code_challenge_methods_supported |
支持 PKCE |
日志
========== Incoming Request ==========
GET /.well-known/openid-configuration
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.DiscoveryEndpoint for /.well-known/openid-configuration
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
--- Body ---
{"issuer":"https://localhost:5243","jwks_uri":"https://localhost:5243/.well-known/openid-configuration/jwks","authorization_endpoint":"https://localhost:5243/connect/authorize","token_endpoint":"https://localhost:5243/connect/token","userinfo_endpoint":"https://localhost:5243/connect/userinfo","end_session_endpoint":"https://localhost:5243/connect/endsession","check_session_iframe":"https://localhost:5243/connect/checksession","revocation_endpoint":"https://localhost:5243/connect/revocation","introspection_endpoint":"https://localhost:5243/connect/introspect","device_authorization_endpoint":"https://localhost:5243/connect/deviceauthorization","backchannel_authentication_endpoint":"https://localhost:5243/connect/ciba","pushed_authorization_request_endpoint":"https://localhost:5243/connect/par","require_pushed_authorization_requests":false,"frontchannel_logout_supported":true,"frontchannel_logout_session_supported":true,"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"scopes_supported":["openid","profile","orders","basket","webhooks","offline_access"],"claims_supported":["sub","name","family_name","given_name","middle_name","nickname","preferred_username","profile","picture","website","gender","birthdate","zoneinfo","locale","updated_at"],"grant_types_supported":["authorization_code","client_credentials","refresh_token","implicit","password","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"],"response_types_supported":["code","token","id_token","id_token token","code id_token","code token","code id_token token"],"response_modes_supported":["form_post","query","fragment"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"],"subject_types_supported":["public"],"code_challenge_methods_supported":["plain","S256"],"request_parameter_supported":true,"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES384","ES512","HS256","HS384","HS512"],"prompt_values_supported":["none","login","consent","select_account"],"authorization_response_iss_parameter_supported":true,"backchannel_token_delivery_modes_supported":["poll"],"backchannel_user_code_parameter_supported":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES384","ES512"]}
======================================
********** GET /.well-known/openid-configuration End **********

3.6.5客户端请求认证服务公钥(/.well-known/openid-configuration/jwks)
客户端请求 /.well-known/openid-configuration/jwks,IdentityServer 返回当前有效的公钥集合(JWKS),包含 Key ID、算法、模数和指数等信息,供客户端或资源服务器根据 JWT Token 中的 kid 自动验证签名,从而确保 Token 的真实性、完整性和安全,同时支持密钥轮换。
/.well-known/openid-configuration/jwks
响应字段含义
| 字段 | 说明 |
|---|---|
keys |
公钥集合,可能有多个 Key,每个 Key 用于验证不同的 Token 或 Key Rotation |
kty |
Key 类型,这里是 RSA(非对称加密算法) |
use |
Key 用途,这里是 sig(签名),表示用来验证 JWT 签名 |
kid |
Key ID,标识这把 Key 的唯一 ID,JWT 里会有对应 kid,方便找到对应公钥 |
n |
RSA 模数(modulus),是公钥的一部分,用于验证签名 |
e |
RSA 指数(exponent),公钥另一部分,通常是 AQAB(65537) |
alg |
JWT 签名算法,这里是 RS256(RSA + SHA-256) |
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /.well-known/openid-configuration/jwks Start **********
========== Incoming Request ==========
GET /.well-known/openid-configuration/jwks
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.DiscoveryKeyEndpoint for /.well-known/openid-configuration/jwks
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
--- Body ---
{"keys":[{"kty":"RSA","use":"sig","kid":"F7383A0ED7CD960EF473FD2851803938","e":"AQAB","n":"p3jTfCB0YsKpqJ6CNJi0tVmFBmoyI_D7QLLsbB-TCZ3-HIXDEr_k6zKb2GJ_QP7mncdSnYpJWSv7fWPfM0bL3A6NaMLF9MDjbfD5ti9irEW1dzBvIK0YjWmfks3eq6Mb2mM6PZtNEnoCqEzjgcRkDR1vtClEzUjs1E_i7TB-Y0J_aTYpLf-eN7yA1Obu8zMVRSSVBIwG5W5jljzA2nxk2u9qeDq8Sn0qgGwbX8cyYGQoVWBOPx7zap4cNcL6dHILjnlVrHqAUW9NVXtBWlVDP1Gvnm2zhCVJT_gW1twNhswyFULGVQH1ZWI0NukEqHG6LpN8Ti7Hx-K8MEEv_vQBYw","alg":"RS256"}]}
======================================
********** GET /.well-known/openid-configuration/jwks End **********

3.6.6客户端向认证服务推送授权请求(/connect/par)
客户端通过 POST /connect/par 将完整授权请求推送给 IdentityServer,服务器验证客户端后返回一个 request_uri,客户端随后使用该 URI 进行浏览器跳转完成标准授权码流程,从而实现 URL 安全、参数完整和支持 PKCE 的现代 OIDC 授权方式。
**PAR **
- 全称:Pushed Authorization Request
- RFC:RFC 9126
- 作用:客户端先把授权请求参数推送到 IdentityServer,而不是直接通过浏览器 URL 传递。
- 好处:
- URL 更短、更安全,不暴露敏感信息(如
client_secret、code_challenge)。 - 可以传输更多参数而不受 URL 长度限制。
- 提升安全性,避免 URL 被记录在日志或浏览器历史中。
- URL 更短、更安全,不暴露敏感信息(如
参数
| 参数 | 说明 |
|---|---|
client_id |
客户端标识(这里是 webhooksclient) |
client_secret |
客户端密钥,用于客户端认证 |
redirect_uri |
授权码回调地址 |
response_type |
响应类型,这里是 code(标准授权码模式) |
scope |
授权范围(openid、profile、webhooks) |
code_challenge / code_challenge_method |
PKCE 验证参数 |
response_mode |
响应模式,这里用 form_post |
nonce |
防止重放攻击的随机值 |
state |
防止 CSRF 攻击 |
POST /connect/par
client_id=webhooksclient&
redirect_uri=http%3A%2F%2Flocalhost%3A5062%2Fsignin-oidc&
response_type=code&
scope=openid+profile+webhooks&
code_challenge=Qq0d_4mV0Fdtx5CJJ4pNlg2d-wKRWS0dLgIuP83DlyU&
code_challenge_method=S256&
response_mode=form_post&
nonce=638998427975851129.NjdjNzg1NzgtYWMwYy00MzY0LWFjOWUtZGI0ZGQwMDM2NWZlODVkY2U2YmMtOTM4MS00ODg1LTkyYWItMzcyMGNkNzFlMzVh&state=CfDJ8AiXIMZqjLRDs2az9HQ7eYPhfTaLGaI1B3xGAEul7c1b7J1duur3lgpABqPRl-rGyuGfpZ-_WG4qlWkzuw2VFuOIPqaRu_mlMoZE14ww2dVpB5WD8jQ2cBDWWBSSo1jJsjUwBMLTycqsDywMJCmoI0qjkO7OnK84ixAGBan20AEImdcbIlH2XZX29xgdsHFsHYZJEb80sxJT2PiBM3NWiWM8yfvazolRUefu8iTtf7jz5HYEJJ1dQLfldPJq3nuKJp3aze_49kzG2UmkrcYaZgKA67Dh-QwzEauJIVdKbsR0XFLFZParXTe9gnGwOb6WecD6wcTRF62YSYIm14BKQyPotwz3KAso0saSOXbAxoswEdc8BxxOydIlEToY7BDl3AYPa3pOaZbbC9bndDZTbyE&
client_secret=secret
客户端认证成功事件
| 字段 | 说明 |
|---|---|
ClientId |
请求的客户端 ID(webhooksclient) |
AuthenticationMethod |
使用的客户端认证方式(SharedSecret) |
EventType |
事件类型,这里是 Success,表示客户端认证成功 |
TimeStamp |
事件发生时间 |
LocalIpAddress / RemoteIpAddress |
服务器和客户端 IP |

响应
| 字段 | 值 | 说明 |
|---|---|---|
request_uri |
urn:ietf:params:oauth:request_uri: 934E308D4D9E332AD35549B39C1D7EB61D14D 6E39B5FF61736603D29E054788A |
唯一标识这次授权请求的 URI。客户端后续浏览器跳转只需带上这个 request_uri,不必把完整参数写在 URL 上。 |
expires_in |
600 |
这个请求 URI 的有效期(秒),这里是 600 秒 = 10 分钟。在有效期内,客户端可以使用该 URI 完成授权码流程。 |
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** POST /connect/par Start **********
========== Incoming Request ==========
POST /connect/par
--- Body ---
client_id=webhooksclient&redirect_uri=http%3A%2F%2Flocalhost%3A5062%2Fsignin-oidc&response_type=code&scope=openid+profile+webhooks&code_challenge=Qq0d_4mV0Fdtx5CJJ4pNlg2d-wKRWS0dLgIuP83DlyU&code_challenge_method=S256&response_mode=form_post&nonce=638998427975851129.NjdjNzg1NzgtYWMwYy00MzY0LWFjOWUtZGI0ZGQwMDM2NWZlODVkY2U2YmMtOTM4MS00ODg1LTkyYWItMzcyMGNkNzFlMzVh&state=CfDJ8AiXIMZqjLRDs2az9HQ7eYPhfTaLGaI1B3xGAEul7c1b7J1duur3lgpABqPRl-rGyuGfpZ-_WG4qlWkzuw2VFuOIPqaRu_mlMoZE14ww2dVpB5WD8jQ2cBDWWBSSo1jJsjUwBMLTycqsDywMJCmoI0qjkO7OnK84ixAGBan20AEImdcbIlH2XZX29xgdsHFsHYZJEb80sxJT2PiBM3NWiWM8yfvazolRUefu8iTtf7jz5HYEJJ1dQLfldPJq3nuKJp3aze_49kzG2UmkrcYaZgKA67Dh-QwzEauJIVdKbsR0XFLFZParXTe9gnGwOb6WecD6wcTRF62YSYIm14BKQyPotwz3KAso0saSOXbAxoswEdc8BxxOydIlEToY7BDl3AYPa3pOaZbbC9bndDZTbyE&client_secret=secret
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.PushedAuthorizationEndpoint for /connect/par
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"ClientId": "webhooksclient",
"AuthenticationMethod": "SharedSecret",
"Category": "Authentication",
"Name": "Client Authentication Success",
"EventType": "Success",
"Id": 1010,
"ActivityId": "0HNHDLT0V7DOI:00000003",
"TimeStamp": "2025-11-27T12:19:57.6426806",
"ProcessId": 6832,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
warn: Duende.IdentityServer.License[0]
A request was made to the pushed authorization endpoint, but you do not have a license. This feature requires the Business Edition or higher tier of license.
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 201
--- Body ---
{"request_uri":"urn:ietf:params:oauth:request_uri:934E308D4D9E332AD35549B39C1D7EB61D14D6E39B5FF61736603D29E054788A","expires_in":600}
======================================
********** POST /connect/par End **********

3.6.7客户端请求认证服务授权端点/connect/authorize
/connect/authorize 是 OIDC 授权端点,客户端携带 client_id 和 request_uri(或完整参数)访问它时,IdentityServer 会验证客户端与用户状态,如果用户未登录就重定向到登录页,登录后再根据授权请求生成授权码并重定向回客户端的 redirect_uri。
客户端通过 /connect/authorize 请求授权码时,IdentityServer 检查到用户未登录,因此返回 302 重定向到 /Account/Login 登录页,登录成功后会使用 request_uri 恢复之前的授权请求,继续完成授权码流程。
参数
| 参数 | 说明 |
|---|---|
client_id=webhooksclient |
客户端 ID,标识哪个应用发起授权请求 |
request_uri=urn:ietf:params:oauth:request_uri:... |
PAR 返回的唯一标识符,用于还原完整授权请求参数(scope、redirect_uri、PKCE 等) |
x-client-SKU=ID_NET9_0 |
客户端 SDK 类型(.NET 9) |
x-client-ver=8.0.1.0 |
客户端 SDK 版本号 |
https://localhost:5243/connect/authorize?
client_id=webhooksclient&
request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A4DDC5E84FC16EA94D7661366602F058BD74E02C1FC6D0C8B637FDEEF0D16209B&
x-client-SKU=ID_NET9_0&
x-client-ver=8.0.1.0
// 解码后:
https://localhost:5243/connect/authorize?
client_id=webhooksclient&
request_uri=urn:ietf:params:oauth:request_uri:4DDC5E84FC16EA94D7661366602F058BD74E02C1FC6D0C8B637FDEEF0D16209B&
x-client-SKU=ID_NET9_0&
x-client-ver=8.0.1.0

日志
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /connect/authorize Start **********
========== Incoming Request ==========
GET /connect/authorize?client_id=webhooksclient&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3AED0DC98296957FDACD7D80198DF14973B3D55F5AA3320098202CD38AD6381427&x-client-SKU=ID_NET9_0&x-client-ver=8.0.1.0
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.AuthorizeEndpoint for /connect/authorize
info: Duende.IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator[0]
Showing login: User is not authenticated
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 302
********** GET /connect/authorize End **********

3.6.8用户未登录重定向登录页(/Account/Login)
用户还未登录被重定向到登录页面 /Account/Login,登录成功后继续授权 (via ReturnUrl)
GET /Account/Login

参数
request_uri:Pushed Authorization Request 的引用标识符,客户端之前通过 /par 端点把完整授权请求参数(client_id、scope、redirect_uri、code_challenge、nonce 等)推送到 IdP,然后 IdP 返回 request_uri,浏览器端只带这个引用就能完成授权请求。
client_id:标识哪个客户端应用向 IdP 发起授权请求。授权服务器根据 client_id 校验该客户端的合法性、权限范围、redirect_uri 是否匹配等。
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /Account/Login Start **********
========== Incoming Request ==========
GET /Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253AAB9E9AB6C69C6FC7FD5796FD9EB64E5431E9686088161C3CF5B1BD9D31128512%26client_id%3Dwebhooksclient
======================================
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
********** GET /Account/Login End **********

3.6.9登录(/Account/Login)
用户在登录页提交用户名、密码及 CSRF 防护令牌(__RequestVerificationToken),IdentityServer 验证凭证成功后记录登录事件,并通过 302 重定向将浏览器引导回授权端点继续完成授权码流程。
Query 参数
| 参数 | 作用 |
|---|---|
ReturnUrl |
告诉登录页面用户登录成功后要跳转到哪里(通常是授权回调 URL),保证登录完成后能继续原授权流程。 |
request_uri |
指向之前通过 PAR 推送到授权服务器的完整授权请求参数的引用标识符,浏览器只携带它,授权服务器根据它继续 OIDC 授权流程。request_uri 不包含明文参数,它只是一个短期唯一标识符,授权服务器通过它去查找存储在内部的完整参数集合,从而继续 OIDC 授权流程。 |
POST /Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A%26client_id%3Dwebhooksclient
// 解码后
/Account/Login?
ReturnUrl=/connect/authorize/callback?request_uri=urn:ietf:params:oauth:request_uri:9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A&
client_id=webhooksclient
Body 参数
| 字段 | 说明 |
|---|---|
ReturnUrl |
登录成功后重定向的目标 URL(这里是 /connect/authorize/callback,带 request_uri) |
Username |
用户输入的用户名(alice) |
Password |
用户输入的密码 |
button |
登录按钮(通常表单控件) |
__RequestVerificationToken |
防 CSRF(跨站请求伪造) 的 Anti-forgery Token(ASP.NET Core 自动生成) |
RememberLogin |
是否记住登录状态(cookie) |
ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A%26client_id%3Dwebhooksclient&
Username=alice&
Password=Pass123%24&
button=login&
__RequestVerificationToken=CfDJ8AiXIMZqjLRDs2az9HQ7eYMHO2ztVM9N2SXUMmejdxF0uodlZrzbIZTnGgDadIJ04Z8dKfZJqpKA5W0HSuHNcAQ3W3FdYKjc2l8m8OFEs8vqmF5OpPFQE33aSEnk2gV4FCvdz-D8rciHWVCjlom3sCg&
RememberLogin=false
登录成功事件
| 字段 | 含义 |
|---|---|
Username / DisplayName |
登录用户信息 |
SubjectId |
IdentityServer 为该用户分配的唯一标识(sub claim 对应) |
ClientId |
发起授权请求的客户端(webhooksclient) |
Endpoint |
登录事件来源(UI 表单) |
Category |
事件类别(Authentication 登录) |
Name |
事件名称(User Login Success) |
EventType |
Success / Failure 等 |
TimeStamp |
登录时间 |
LocalIpAddress / RemoteIpAddress |
客户端和服务器 IP(本地开发测试为 ::1) |

响应
重定向到了授权页
Status Code: 302
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** POST /Account/Login Start **********
========== Incoming Request ==========
POST /Account/Login?ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A%26client_id%3Dwebhooksclient
--- Body ---
ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A%26client_id%3Dwebhooksclient&Username=alice&Password=Pass123%24&button=login&__RequestVerificationToken=CfDJ8AiXIMZqjLRDs2az9HQ7eYMHO2ztVM9N2SXUMmejdxF0uodlZrzbIZTnGgDadIJ04Z8dKfZJqpKA5W0HSuHNcAQ3W3FdYKjc2l8m8OFEs8vqmF5OpPFQE33aSEnk2gV4FCvdz-D8rciHWVCjlom3sCg&RememberLogin=false
======================================
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__normalizedUserName_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."NormalizedUserName" = @__normalizedUserName_0
LIMIT 1
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__user_Id_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."ClaimType", a."ClaimValue", a."UserId"
FROM "AspNetUserClaims" AS a
WHERE a."UserId" = @__user_Id_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__userId_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a0."Name"
FROM "AspNetUserRoles" AS a
INNER JOIN "AspNetRoles" AS a0 ON a."RoleId" = a0."Id"
WHERE a."UserId" = @__userId_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__normalizedUserName_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."NormalizedUserName" = @__normalizedUserName_0
LIMIT 1
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"Username": "alice",
"SubjectId": "13051f3a-c540-4f64-8048-b141d7a3e026",
"DisplayName": "alice",
"Endpoint": "UI",
"ClientId": "webhooksclient",
"Category": "Authentication",
"Name": "User Login Success",
"EventType": "Success",
"Id": 1000,
"ActivityId": "0HNHDNUPTKN1L:0000001B",
"TimeStamp": "2025-11-27T14:17:52.6066697",
"ProcessId": 20408,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 302
********** POST /Account/Login End **********

3.6.10授权回调请求-重定向授权页(/connect/authorize/callback)
/connect/authorize/callback 请求是浏览器在用户登录或同意授权后访问的回调端点,IdentityServer 根据 request_uri 查找之前的完整授权请求,判断用户是否已同意 scope,然后决定显示 Consent 页面或生成授权码/ID Token 并重定向回客户端。
登录成功后,浏览器访问 /connect/authorize/callback,IdentityServer 根据 request_uri 获取完整 PAR 授权请求、查询用户信息,发现用户尚未同意客户端请求的 scope,于是返回 302 重定向到 Consent 页面,让用户确认授权后再继续生成授权码。
Query 参数
request_uri:PAR 授权请求标识符client_id:请求授权的客户端 ID
GET /connect/authorize/callback?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A&client_id=webhooksclient
调用 IdentityServer 端点
IdentityServer 拦截 /connect/authorize/callback,调用 AuthorizeCallbackEndpoint 来处理授权回调
根据 request_uri 获取之前通过 PAR 推送的完整授权请求,准备生成授权码或跳转到 Consent
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.AuthorizeCallbackEndpoint for /connect/authorize/callback
数据库查询用户信息
IdentityServer 根据用户 ID 查询数据库,获取用户信息,包括用户名、Email、锁定状态、两步验证、角色等
用于判断用户是否已登录并已同意授权,数据库操作是正常的用户信息加载
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."Id" = @__p_0
LIMIT 1
检查用户是否同意授权
IdentityServer 检查用户是否已对客户端请求的 scope 授权过,用户未需要显示 Consent 页面
info: Duende.IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator[0]
Showing consent: User has not yet consented
响应
IdentityServer 不是直接生成授权码,先让用户在 Consent 页面确认 scope,浏览器收到 302 自动跳转到 Consent 页面
Status Code: 302
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /connect/authorize/callback Start **********
========== Incoming Request ==========
GET /connect/authorize/callback?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A&client_id=webhooksclient
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.AuthorizeCallbackEndpoint for /connect/authorize/callback
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."Id" = @__p_0
LIMIT 1
info: Duende.IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator[0]
Showing consent: User has not yet consented
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 302
********** GET /connect/authorize/callback End **********

3.6.11授权页(/consent)
/consent GET 请求用于显示用户授权确认页面,浏览器通过 returnUrl 带上原始授权请求标识(request_uri),用户在页面上同意或拒绝客户端请求的权限,后续继续 OIDC 授权流程。

参数
returnUrl:原始授权回调 URL /connect/authorize/callback(包含 request_uri)
client_id:请求授权的客户端 ID
GET /consent?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A%26
client_id%3Dwebhooksclient
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /consent Start **********
========== Incoming Request ==========
GET /consent?returnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A9A9F6E2E714443C29985C223CB65B6F8A30B3A7F6E8851CD1912400AE680E56A%26client_id%3Dwebhooksclient
======================================
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
********** GET /consent End **********

3.6.12授权(/consent)
/Consent POST 请求用于用户提交授权确认,IdentityServer 验证请求、记录用户同意的 scope(并可记住选择),然后重定向回原始授权回调 URL,继续 OIDC/PAR 授权码生成流程。
Body 参数
| 参数 | 作用 |
|---|---|
ReturnUrl |
Consent 页面完成后要重定向回的授权回调 URL(携带 request_uri) |
ScopesConsented |
用户同意的权限列表(openid, profile, webhooks) |
RememberConsent |
用户是否选择“记住同意”,下次无需再次显示 Consent |
button |
用户点击的按钮(yes / no) |
__RequestVerificationToken |
防 CSRF token,防止跨站请求伪造 |
POST /Consent ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A0F8304C65120C621A2089FF9A63A5E38A3537E0E8CB5686495432DBA1BF592CB%26client_id%3Dwebhooksclient&ScopesConsented=openid&ScopesConsented=profile&ScopesConsented=webhooks&Description=&RememberConsent=true&button=yes&__RequestVerificationToken=CfDJ8AiXIMZqjLRDs2az9HQ7eYNAFpqWllO7FkzY0sJh1tZ0Zwz4NDbj7X-xNQjiQAN7CGdSwXGp241s8k_gcYE8NLCzDramxnEm3EfCR7dRemKPSblJomckGO_xNglgCehDjNYB8ID9brsP2g31WDLZ9HNPA2h2rrnDuuNOFmIDdZZrA2-JNvzKSYCsXZbhGIb5kw&RememberConsent=false
IdentityServer 处理 Consent
用户已同意客户端 webhooksclient 请求的 openid、profile 和 webhooks 权限,并选择记住同意,以便下次无需再次确认。
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"SubjectId": "13051f3a-c540-4f64-8048-b141d7a3e026",
"ClientId": "webhooksclient",
"RequestedScopes": [
"openid",
"profile",
"webhooks"
],
"GrantedScopes": [
"openid",
"profile",
"webhooks"
],
"ConsentRemembered": true,
"Category": "Grants",
"Name": "Consent granted",
"EventType": "Information",
"Id": 4000,
"ActivityId": "0HNHDOMTV007E:0000001B",
"TimeStamp": "2025-11-27T15:00:49.9958745",
"ProcessId": 16064,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
响应
302 重定向到 ReturnUrl。Consent 页面 POST 完成后,授权流程继续
Status Code: 302
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** POST /Consent Start **********
========== Incoming Request ==========
POST /Consent
--- Body ---
ReturnUrl=%2Fconnect%2Fauthorize%2Fcallback%3Frequest_uri%3Durn%253Aietf%253Aparams%253Aoauth%253Arequest_uri%253A0F8304C65120C621A2089FF9A63A5E38A3537E0E8CB5686495432DBA1BF592CB%26client_id%3Dwebhooksclient&ScopesConsented=openid&ScopesConsented=profile&ScopesConsented=webhooks&Description=&RememberConsent=true&button=yes&__RequestVerificationToken=CfDJ8AiXIMZqjLRDs2az9HQ7eYNAFpqWllO7FkzY0sJh1tZ0Zwz4NDbj7X-xNQjiQAN7CGdSwXGp241s8k_gcYE8NLCzDramxnEm3EfCR7dRemKPSblJomckGO_xNglgCehDjNYB8ID9brsP2g31WDLZ9HNPA2h2rrnDuuNOFmIDdZZrA2-JNvzKSYCsXZbhGIb5kw&RememberConsent=false
======================================
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"SubjectId": "13051f3a-c540-4f64-8048-b141d7a3e026",
"ClientId": "webhooksclient",
"RequestedScopes": [
"openid",
"profile",
"webhooks"
],
"GrantedScopes": [
"openid",
"profile",
"webhooks"
],
"ConsentRemembered": true,
"Category": "Grants",
"Name": "Consent granted",
"EventType": "Information",
"Id": 4000,
"ActivityId": "0HNHDOMTV007E:0000001B",
"TimeStamp": "2025-11-27T15:00:49.9958745",
"ProcessId": 16064,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 302
********** POST /Consent End **********

3.6.13授权回调请求-生成授权码(/connect/authorize/callback)
用户在 Consent 页面同意权限后,浏览器访问 /connect/authorize/callback,IdentityServer 根据 request_uri 获取完整授权请求、生成授权码,并重定向回客户端回调 URL,完成 OIDC 授权码颁发流程。
Query 参数
request_uri→ PAR 请求标识符,引用之前推送的完整授权请求client_id→ 授权请求的客户端 ID
GET /connect/authorize/callback?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A0F8304C65120C621A2089FF9A63A5E38A3537E0E8CB5686495432DBA1BF592CB&client_id=webhooksclient
IdentityServer 处理授权回调
IdentityServer 调用 AuthorizeCallbackEndpoint
AuthorizeCallbackEndpoint 是 IdentityServer 中处理 授权码流程回调的关键端点。在用户完成登录和同意授权(Consent)后,浏览器会被重定向到该端点,IdentityServer 会根据前面保存的授权请求信息(如 PAR 的 request_uri 或完整请求参数)执行以下操作:首先验证客户端、用户身份及授权请求合法性,然后生成授权码(Authorization Code),并将其附加到客户端的 redirect_uri 中,最后通过 HTTP 重定向将浏览器返回给客户端应用。该端点还会触发相关事件记录,如登录成功、授权同意等,确保整个授权码流程安全、完整,并支持 PKCE、Scopes 和 Consent 的管理。
Invoking IdentityServer endpoint: AuthorizeCallbackEndpoint
数据库查询用户信息

用户已同意授权
dentityServer 识别到用户已经同意了 requested scopes
fo: Duende.IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator[0]
User consented to scopes: openid, profile, webhooks
事件记录
-
生成授权码 (
code) -
记录事件:Token 已成功颁发
-
指明客户端回调 URI、Scope、GrantType
Token Issued Success
ClientId: webhooksclient
RedirectUri: http://localhost:5062/signin-oidc
Scopes: openid profile webhooks
GrantType: authorization_code
Tokens: [{TokenType: code}]

响应
Status Code: 200
日志
fo: Identity.API.RequestLoggingMiddleware[0]
********** GET /connect/authorize/callback Start **********
========== Incoming Request ==========
GET /connect/authorize/callback?request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A0F8304C65120C621A2089FF9A63A5E38A3537E0E8CB5686495432DBA1BF592CB&client_id=webhooksclient
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.AuthorizeCallbackEndpoint for /connect/authorize/callback
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."Id" = @__p_0
LIMIT 1
info: Duende.IdentityServer.ResponseHandling.AuthorizeInteractionResponseGenerator[0]
User consented to scopes: openid, profile, webhooks
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"ClientId": "webhooksclient",
"ClientName": "Webhooks Client",
"RedirectUri": "http://localhost:5062/signin-oidc",
"Endpoint": "Authorize",
"SubjectId": "13051f3a-c540-4f64-8048-b141d7a3e026",
"Scopes": "openid profile webhooks",
"GrantType": "authorization_code",
"Tokens": [
{
"TokenType": "code",
"TokenValue": "****9B-1"
}
],
"Category": "Token",
"Name": "Token Issued Success",
"EventType": "Success",
"Id": 2000,
"ActivityId": "0HNHDOMTV007E:0000001D",
"TimeStamp": "2025-11-27T15:00:50.0619166",
"ProcessId": 16064,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
********** GET /connect/authorize/callback End **********

3.6.14授权回调请求-颁发Token(/connect/token)
客户端使用之前从 /connect/authorize/callback 获取的授权码(Authorization Code),连同 client_id、client_secret、redirect_uri 和 code_verifier(PKCE 验证)向 IdentityServer 的 /connect/token 发送 POST 请求;IdentityServer 先验证客户端身份,再验证授权码和 PKCE,确认合法后生成 Access Token 和 ID Token,并将它们返回给客户端,用于访问受保护的 API 和完成用户登录身份认证。
Body 参数
| 参数 | 作用 |
|---|---|
client_id |
客户端标识,表示哪个应用在请求 token |
client_secret |
客户端密钥,用于认证客户端身份(Confidential Client) |
code |
授权码(Authorization Code),之前 /connect/authorize/callback 生成 |
grant_type |
授权类型,这里是 authorization_code |
redirect_uri |
必须与授权请求一致,用于验证回调合法性 |
code_verifier |
PKCE 验证值,确保授权码只能被原始客户端使用 |
POST /connect/token
client_id=webhooksclient&
client_secret=secret&
code=F95F4B21958B6456294D215758170B0D96C770EEA073FD5438B4D7724F4ED69B-1&
grant_type=authorization_code&
redirect_uri=http%3A%2F%2Flocalhost%3A5062%2Fsignin-oidc&
code_verifier=v5TrkqKPser4OaGeHcVlvApu143lRljRbKsFC6yxw8c
客户端认证
IdentityServer 验证 client_id + client_secret 成功,客户端身份合法,可以继续处理授权码

数据库查询用户信息

验证授权码请求
IdentityServer 验证:
- 授权码是否有效且未过期
redirect_uri是否匹配code_verifier是否正确(PKCE 验证)
验证成功后,可以生成 tokens

颁发 Token
-
IdentityServer 生成:
-
ID Token → 包含用户身份信息 (JWT)
-
Access Token → 用于访问受保护资源(API)
-
-
Scope 对应用户同意的权限:
openid、profile、webhooks -
Token 会被返回给客户端

响应
| 字段 | 说明 |
|---|---|
id_token |
OpenID Connect ID Token,包含用户身份信息(如 sub、name、email 等),用于客户端验证用户身份 |
expires_in |
Token 有效期,单位秒,这里是 7200 秒(2 小时) |
token_type |
Token 类型,这里是 Bearer,客户端在请求资源时需在 HTTP Header 中使用 Authorization: Bearer <token> |
scope |
授权范围(Scope),客户端获得的权限,这里包括 openid、profile 和 webhooks |
Status Code: 200
{"id_token":"eyJhbGciOiJSUzI1NiIsw此处省略...","expires_in":7200,"token_type":"Bearer","scope":"openid profile webhooks"}
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** POST /connect/token Start **********
========== Incoming Request ==========
POST /connect/token
--- Body ---
client_id=webhooksclient&client_secret=secret&code=F95F4B21958B6456294D215758170B0D96C770EEA073FD5438B4D7724F4ED69B-1&grant_type=authorization_code&redirect_uri=http%3A%2F%2Flocalhost%3A5062%2Fsignin-oidc&code_verifier=v5TrkqKPser4OaGeHcVlvApu143lRljRbKsFC6yxw8c
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.TokenEndpoint for /connect/token
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"ClientId": "webhooksclient",
"AuthenticationMethod": "SharedSecret",
"Category": "Authentication",
"Name": "Client Authentication Success",
"EventType": "Success",
"Id": 1010,
"ActivityId": "0HNHDOMTV007F:00000004",
"TimeStamp": "2025-11-27T15:00:50.1456808",
"ProcessId": 16064,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."Id" = @__p_0
LIMIT 1
info: Duende.IdentityServer.Validation.TokenRequestValidator[0]
Token request validation success, {
"ClientId": "webhooksclient",
"ClientName": "Webhooks Client",
"GrantType": "authorization_code",
"AuthorizationCode": "****9B-1",
"RefreshToken": "********",
"Raw": {
"client_id": "webhooksclient",
"client_secret": "***REDACTED***",
"code": "***REDACTED***",
"grant_type": "authorization_code",
"redirect_uri": "http://localhost:5062/signin-oidc",
"code_verifier": "v5TrkqKPser4OaGeHcVlvApu143lRljRbKsFC6yxw8c"
}
}
info: Duende.IdentityServer.Events.DefaultEventService[0]
{
"ClientId": "webhooksclient",
"ClientName": "Webhooks Client",
"Endpoint": "Token",
"SubjectId": "13051f3a-c540-4f64-8048-b141d7a3e026",
"Scopes": "openid profile webhooks",
"GrantType": "authorization_code",
"Tokens": [
{
"TokenType": "id_token",
"TokenValue": "****A8QA"
},
{
"TokenType": "access_token",
"TokenValue": "****kF9w"
}
],
"Category": "Token",
"Name": "Token Issued Success",
"EventType": "Success",
"Id": 2000,
"ActivityId": "0HNHDOMTV007F:00000004",
"TimeStamp": "2025-11-27T15:00:50.2195689",
"ProcessId": 16064,
"LocalIpAddress": "::1:5243",
"RemoteIpAddress": "::1"
}
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
--- Body ---
{"id_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IkY3MzgzQTBFRDdDRDk2MEVGNDczRkQyODUxODAzOTM4IiwidHlwIjoiSldUIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MjQzIiwibmJmIjoxNzY0MjU1NjUwLCJpYXQiOjE3NjQyNTU2NTAsImV4cCI6MTc2NDI2Mjg1MCwiYXVkIjoid2ViaG9va3NjbGllbnQiLCJhbXIiOlsicHdkIl0sIm5vbmNlIjoiNjM4OTk4NTI0NDc1ODM5NjQxLk5HTmxNV0prTldNdE1EY3lOaTAwTnpFeExUazROak10TXpGbU0yUTRaRGhtTjJNeE1tSXpZelZsWVRndE5qYzBZUzAwWkdFMkxXSXdaVGN0WW1RNU9EYzVNekV6WkdWaSIsImF0X2hhc2giOiJrOHRjOXBGRDZvVmxDRzF3dE9MT1FnIiwic2lkIjoiQjlGOTMxRkJCODVFMEQ2NjM3M0Y2MTVFNTY2NkQ0RTUiLCJzdWIiOiIxMzA1MWYzYS1jNTQwLTRmNjQtODA0OC1iMTQxZDdhM2UwMjYiLCJhdXRoX3RpbWUiOjE3NjQyNTMwNzIsImlkcCI6ImxvY2FsIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWxpY2UiLCJ1bmlxdWVfbmFtZSI6ImFsaWNlIiwibmFtZSI6IkFsaWNlIiwibGFzdF9uYW1lIjoiU21pdGgiLCJjYXJkX251bWJlciI6IlhYWFhYWFhYWFhYWDE4ODEiLCJjYXJkX2hvbGRlciI6IkFsaWNlIFNtaXRoIiwiY2FyZF9zZWN1cml0eV9udW1iZXIiOiIxMjMiLCJjYXJkX2V4cGlyYXRpb24iOiIxMi8yNCIsImFkZHJlc3NfY2l0eSI6IlJlZG1vbmQiLCJhZGRyZXNzX2NvdW50cnkiOiJVLlMuIiwiYWRkcmVzc19zdGF0ZSI6IldBIiwiYWRkcmVzc19zdHJlZXQiOiIxNTcwMyBORSA2MXN0IEN0IiwiYWRkcmVzc196aXBfY29kZSI6Ijk4MDUyIiwiZW1haWwiOiJBbGljZVNtaXRoQGVtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJwaG9uZV9udW1iZXIiOiIxMjM0NTY3ODkwIiwicGhvbmVfbnVtYmVyX3ZlcmlmaWVkIjpmYWxzZX0.oxcegeZZfrHuQ35VBe4KFMMFK8MAHOGATG-Rc_ERdk9fdFf6o28knw-ciJJz-48kr1e1EUODwzH5MAE-Zv9KqzSnymN6jAalQ-zTOgRsoVtsDBW2P9mEb69LdkEa3F9kmTFu4-aEBUVk_LZ0H68Nml5JG9fb5YRHoW6oK5k6IY665i8fFOh2mPQuwtRQCAde4s789efWL01974g98PACn2hfCia53xOPV4RQHIGlX-c7yR2H3h9aCdei8Phh2V_FuAFvafQYIcVByrDjXIfpFcr8MDcCa-SBPo-UUyADTRLZsySwNgsqQplkpITXun03MlzcMUzW1l5hA_reBYA8QA","access_token":"eyJhbGciOiJSUzI1NiIsImtpZCI6IkY3MzgzQTBFRDdDRDk2MEVGNDczRkQyODUxODAzOTM4IiwidHlwIjoiYXQrand0In0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MjQzIiwibmJmIjoxNzY0MjU1NjUwLCJpYXQiOjE3NjQyNTU2NTAsImV4cCI6MTc2NDI2Mjg1MCwiYXVkIjoid2ViaG9va3MiLCJzY29wZSI6WyJvcGVuaWQiLCJwcm9maWxlIiwid2ViaG9va3MiXSwiYW1yIjpbInB3ZCJdLCJjbGllbnRfaWQiOiJ3ZWJob29rc2NsaWVudCIsInN1YiI6IjEzMDUxZjNhLWM1NDAtNGY2NC04MDQ4LWIxNDFkN2EzZTAyNiIsImF1dGhfdGltZSI6MTc2NDI1MzA3MiwiaWRwIjoibG9jYWwiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJhbGljZSIsInVuaXF1ZV9uYW1lIjoiYWxpY2UiLCJuYW1lIjoiQWxpY2UiLCJsYXN0X25hbWUiOiJTbWl0aCIsImNhcmRfbnVtYmVyIjoiWFhYWFhYWFhYWFhYMTg4MSIsImNhcmRfaG9sZGVyIjoiQWxpY2UgU21pdGgiLCJjYXJkX3NlY3VyaXR5X251bWJlciI6IjEyMyIsImNhcmRfZXhwaXJhdGlvbiI6IjEyLzI0IiwiYWRkcmVzc19jaXR5IjoiUmVkbW9uZCIsImFkZHJlc3NfY291bnRyeSI6IlUuUy4iLCJhZGRyZXNzX3N0YXRlIjoiV0EiLCJhZGRyZXNzX3N0cmVldCI6IjE1NzAzIE5FIDYxc3QgQ3QiLCJhZGRyZXNzX3ppcF9jb2RlIjoiOTgwNTIiLCJlbWFpbCI6IkFsaWNlU21pdGhAZW1haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsInBob25lX251bWJlciI6IjEyMzQ1Njc4OTAiLCJwaG9uZV9udW1iZXJfdmVyaWZpZWQiOmZhbHNlLCJzaWQiOiJCOUY5MzFGQkI4NUUwRDY2MzczRjYxNUU1NjY2RDRFNSIsImp0aSI6IjNBMTVENTI4NTU1NUJDMzJDNDZDNEMwNjBENDcyNUE3In0.NaLKrVPbP_ojZoDTZUzWenehjTDUf8Jc7ztQn-GQmmRXngx7bTexQnF086_eZtJwm2A5k_Y_dcjcYmIHZhb4L3eHVLokI2CX5r6SuV4fRJBaZ9_E8XjN-0EYBvy6T8jUC8sP_Wk78auQETvgxf5VNsw5xo6ay2W-m9MfPZHRvKZDNDqVUF_uJdKZOf5hj0Qu6r9wRbbOGMQnNtKL7Kyo3KrYtb1TCuXnGSItKiXE1jQAhVVWsAT31mIwuV1RJclbXBlEbye6GO27wWB-sivVHwuSt0s3KDb6JzKHTFNw40GDrqtj4G6thyFYtWIe2kmbfFnF8K4I9CIf4OsMgNkF9w","expires_in":7200,"token_type":"Bearer","scope":"openid profile webhooks"}
======================================
********** POST /connect/token End **********

3.5.15根据Access Token返回用户完整信息(/connect/userinfo)
客户端发起 GET 请求访问 UserInfoEndpoint,IdentityServer 接收请求并通过 Profile Service 查询数据库中对应用户的详细信息(如用户名、姓名、邮箱、手机号、地址及部分信用卡信息),生成包含所有 claims 的 JSON 响应返回给客户端
IdentityServer 处理
IdentityServer 接收请求后,将其路由到 UserInfoEndpoint。UserInfoEndpoint 是 OIDC 提供的接口,客户端通过 Access Token 调用它可以获取已认证用户的详细信息(Claims),用于显示用户资料或进行授权决策。
内部会验证 Access Token 是否有效,以及该 Token 是否有权访问用户信息。
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.UserInfoEndpoint for /connect/userinfo
数据库查询用户信息
IdentityServer 根据 Access Token 中的 sub(用户 ID)查询数据库。此处使用 PostgreSQL 的 AspNetUsers 表获取用户完整信息。

IdentityServer 将数据库中的用户信息转换为Claims
Claims 列表包括:
- 标准 OpenID Connect Claims:
sub、preferred_username、name、email等 - 自定义 Claims:如银行卡信息(
card_number、card_holder等)、地址信息(address_city、address_state等)

响应
返回的都是数据库AspNetUsers 表存储的用户信息
Status Code: 200
{"sub":"13051f3a-c540-4f64-8048-b141d7a3e026","preferred_username":"alice","unique_name":"alice","name":"Alice","last_name":"Smith","card_number":"XXXXXXXXXXXX1881","card_holder":"Alice Smith","card_security_number":"123","card_expiration":"12/24","address_city":"Redmond","address_country":"U.S.","address_state":"WA","address_street":"15703 NE 61st Ct","address_zip_code":"98052","email":"AliceSmith@email.com","email_verified":true,"phone_number":"1234567890","phone_number_verified":false}
日志
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /connect/userinfo Start **********
========== Incoming Request ==========
GET /connect/userinfo
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.UserInfoEndpoint for /connect/userinfo
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[@__p_0='?'], CommandType='Text', CommandTimeout='30']
SELECT a."Id", a."AccessFailedCount", a."CardHolderName", a."CardNumber", a."CardType", a."City", a."ConcurrencyStamp", a."Country", a."Email", a."EmailConfirmed", a."Expiration", a."LastName", a."LockoutEnabled", a."LockoutEnd", a."Name", a."NormalizedEmail", a."NormalizedUserName", a."PasswordHash", a."PhoneNumber", a."PhoneNumberConfirmed", a."SecurityNumber", a."SecurityStamp", a."State", a."Street", a."TwoFactorEnabled", a."UserName", a."ZipCode"
FROM "AspNetUsers" AS a
WHERE a."Id" = @__p_0
LIMIT 1
info: Duende.IdentityServer.ResponseHandling.UserInfoResponseGenerator[0]
Profile service returned the following claim types: sub preferred_username unique_name name last_name card_number card_holder card_security_number card_expiration address_city address_country address_state address_street address_zip_code email email_verified phone_number phone_number_verified
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
--- Body ---
{"sub":"13051f3a-c540-4f64-8048-b141d7a3e026","preferred_username":"alice","unique_name":"alice","name":"Alice","last_name":"Smith","card_number":"XXXXXXXXXXXX1881","card_holder":"Alice Smith","card_security_number":"123","card_expiration":"12/24","address_city":"Redmond","address_country":"U.S.","address_state":"WA","address_street":"15703 NE 61st Ct","address_zip_code":"98052","email":"AliceSmith@email.com","email_verified":true,"phone_number":"1234567890","phone_number_verified":false}
======================================
********** GET /connect/userinfo End **********

3.6.16客户端再次请求认证服务OIDC发现身份服务端点(/.well-known/openid-configuration)
通过再次请求,客户端可以获取 IdentityServer 暴露的所有关键端点(如授权端点 /connect/authorize、Token 端点 /connect/token、用户信息端点 /connect/userinfo、登出端点 /connect/endsession 等)、支持的 OAuth2/OIDC 功能(如 grant 类型、response type、scope、claim、签名算法、PKCE 方法、Prompt 值等),以及公钥 URL(jwks_uri)用于验证签名。这保证了客户端无需硬编码端点和能力信息,可以动态适配服务器能力,并为后续授权码交换、访问 Token 获取以及用户信息请求提供必要的基础信息,是整个 OIDC 授权流程初始化和配置发现的重要环节。
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /.well-known/openid-configuration Start **********
========== Incoming Request ==========
GET /.well-known/openid-configuration
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.DiscoveryEndpoint for /.well-known/openid-configuration
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
--- Body ---
{"issuer":"https://localhost:5243","jwks_uri":"https://localhost:5243/.well-known/openid-configuration/jwks","authorization_endpoint":"https://localhost:5243/connect/authorize","token_endpoint":"https://localhost:5243/connect/token","userinfo_endpoint":"https://localhost:5243/connect/userinfo","end_session_endpoint":"https://localhost:5243/connect/endsession","check_session_iframe":"https://localhost:5243/connect/checksession","revocation_endpoint":"https://localhost:5243/connect/revocation","introspection_endpoint":"https://localhost:5243/connect/introspect","device_authorization_endpoint":"https://localhost:5243/connect/deviceauthorization","backchannel_authentication_endpoint":"https://localhost:5243/connect/ciba","pushed_authorization_request_endpoint":"https://localhost:5243/connect/par","require_pushed_authorization_requests":false,"frontchannel_logout_supported":true,"frontchannel_logout_session_supported":true,"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"scopes_supported":["openid","profile","orders","basket","webhooks","offline_access"],"claims_supported":["sub","name","family_name","given_name","middle_name","nickname","preferred_username","profile","picture","website","gender","birthdate","zoneinfo","locale","updated_at"],"grant_types_supported":["authorization_code","client_credentials","refresh_token","implicit","password","urn:ietf:params:oauth:grant-type:device_code","urn:openid:params:grant-type:ciba"],"response_types_supported":["code","token","id_token","id_token token","code id_token","code token","code id_token token"],"response_modes_supported":["form_post","query","fragment"],"token_endpoint_auth_methods_supported":["client_secret_basic","client_secret_post"],"id_token_signing_alg_values_supported":["RS256"],"subject_types_supported":["public"],"code_challenge_methods_supported":["plain","S256"],"request_parameter_supported":true,"request_object_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES384","ES512","HS256","HS384","HS512"],"prompt_values_supported":["none","login","consent","select_account"],"authorization_response_iss_parameter_supported":true,"backchannel_token_delivery_modes_supported":["poll"],"backchannel_user_code_parameter_supported":true,"dpop_signing_alg_values_supported":["RS256","RS384","RS512","PS256","PS384","PS512","ES256","ES384","ES512"]}
======================================
********** GET /.well-known/openid-configuration End **********

3.6.17客户端再次请求认证服务公钥(/.well-known/openid-configuration/jwks)
客户端或中间件再次请求 /.well-known/openid-configuration/jwks 公钥,是为了确保 使用最新公钥验证 ID token 或 access token 的签名安全,尤其是在可能存在密钥轮换、缓存过期或应用初始化等场景下,这样可以保证 token 的完整性和可信性。
info: Identity.API.RequestLoggingMiddleware[0]
********** GET /.well-known/openid-configuration/jwks Start **********
========== Incoming Request ==========
GET /.well-known/openid-configuration/jwks
======================================
info: Duende.IdentityServer.Hosting.IdentityServerMiddleware[0]
Invoking IdentityServer endpoint: Duende.IdentityServer.Endpoints.DiscoveryKeyEndpoint for /.well-known/openid-configuration/jwks
info: Identity.API.RequestLoggingMiddleware[0]
========== Outgoing Response ==========
Status Code: 200
--- Body ---
{"keys":[{"kty":"RSA","use":"sig","kid":"F7383A0ED7CD960EF473FD2851803938","e":"AQAB","n":"p3jTfCB0YsKpqJ6CNJi0tVmFBmoyI_D7QLLsbB-TCZ3-HIXDEr_k6zKb2GJ_QP7mncdSnYpJWSv7fWPfM0bL3A6NaMLF9MDjbfD5ti9irEW1dzBvIK0YjWmfks3eq6Mb2mM6PZtNEnoCqEzjgcRkDR1vtClEzUjs1E_i7TB-Y0J_aTYpLf-eN7yA1Obu8zMVRSSVBIwG5W5jljzA2nxk2u9qeDq8Sn0qgGwbX8cyYGQoVWBOPx7zap4cNcL6dHILjnlVrHqAUW9NVXtBWlVDP1Gvnm2zhCVJT_gW1twNhswyFULGVQH1ZWI0NukEqHG6LpN8Ti7Hx-K8MEEv_vQBYw","alg":"RS256"}]}
======================================
********** GET /.well-known/openid-configuration/jwks End **********

3.6.18总结
OpenID Connect (OIDC):基于 OAuth 2.0 的认证层,用于验证用户身份,同时返回用户信息(ID Token)。
角色:
- 客户端 (Client / Relying Party):请求认证的应用,比如
webhooksclient。 - 资源拥有者 (Resource Owner / User):最终用户,比如日志中的
alice。 - 身份提供者 (Identity Provider / IdP):处理认证与授权的服务,比如 Duende IdentityServer。
- 受保护资源 (Resource Server):提供实际数据的 API,可以用 Access Token 访问。
3.6.18.1客户端发起授权请求 (Authorization Request)
客户端向 IdentityServer 的 /connect/authorize 或 /connect/par 发送请求:
POST /connect/par
client_id=webhooksclient
redirect_uri=http://localhost:5062/signin-oidc
response_type=code
scope=openid profile webhooks
code_challenge=CU6Wz...
code_challenge_method=S256
3.6.18.2用户重定向到登录页面 (User Login)
IdentityServer 检查用户是否已认证:
- 未认证 → 重定向到
/Account/Login。 - 已认证 → 直接跳到同意页面或颁发授权码。
GET /connect/authorize
Showing login: User is not authenticated
POST /Account/Login
Username=alice
Password=Pass123$
IdentityServer 收到请求后,会生成一个 Request URI 并返回给客户端。
3.6.18.3用户同意授权 (Consent)
如果客户端请求的 Scope 包含敏感权限,IdentityServer 会要求用户同意。
用户可以选择记住同意(RememberConsent)。
用户登录
IdentityServer 校验用户凭证。
登录成功,生成用户会话。
GET /consent
POST /Consent
ScopesConsented=openid profile webhooks
ConsentRemembered=true
3.6.18.4颁发授权码 (Authorization Code)
用户登录并同意授权后,IdentityServer 生成授权码 (Authorization Code)。
授权码通过 redirect_uri 返回给客户端。
IdentityServer 记录用户同意信息,方便下次免确认。
GET /connect/authorize/callback
User consented to scopes: openid, profile, webhooks
Token Issued Success (code)
3.6.18.5颁发授权码 (Authorization Code)
用户登录并同意授权后,IdentityServer 生成授权码 (Authorization Code)。
授权码通过 redirect_uri 返回给客户端。
客户端收到授权码,准备兑换令牌。
GET /connect/authorize/callback
User consented to scopes: openid, profile, webhooks
Token Issued Success (code)
3.6.18.6客户端使用授权码请求令牌 (Token Request)
客户端向 /connect/token 发送 POST 请求
code_verifier与 code_challenge 配对,用于 PKCE 验证。
IdentityServer 校验授权码有效性、客户端身份、PKCE。
POST /connect/token
client_id=webhooksclient
client_secret=secret
code=授权码
grant_type=authorization_code
redirect_uri=http://localhost:5062/signin-oidc
code_verifier=N5yjin3JPv...
3.6.18.7颁发令牌 (Token Response)
验证成功后,IdentityServer 返回:
-
ID Token:JWT,包含用户身份信息(
sub、name、email等)。 -
Access Token:用于访问受保护的 API(如
webhooks)。 -
Refresh Token(可选):刷新 Access Token。
{
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkY3Mzg...",
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkY3Mzg...",
"expires_in": 3600,
"token_type": "Bearer"
}
Token Issued Success
id_token=****
access_token=****
3.6.18.8客户端使用令牌访问资源 (Resource Access)
客户端携带 Access Token 调用 API。
API 验证 Access Token,返回资源。
3.6.18.9流程
| 步骤 | 日志关键点 |
|---|---|
| 发起授权 | POST /connect/par → Request URI |
| 用户登录 | GET /Account/Login, POST /Account/Login |
| 用户同意 | GET /consent, POST /Consent |
| 授权码回调 | GET /connect/authorize/callback |
| 令牌请求 | POST /connect/token |
| 返回令牌 | id_token + access_token |
写了好几个小时,短时间想全部消化有点难,慢慢再看吧,一遍学不会就多学几遍
4.WebhookClient选项配置(WebhookClientOptions)
WebhookClientOptions:
Token:Webhook 客户端使用的 TokenSelfUrl:Webhook 客户端对外暴露的 URL(自身的地址)ValidateToken: 是否启用 Token 校验。

5.内存仓储服务(HooksRepository)
HooksRepository 是一个 线程安全的内存 Webhook 消息仓库 + 订阅通知机制:
- 存储功能:用内存队列
_data保存收到的 Webhook 消息(WebHookReceived)。 - 通知功能:允许多个订阅者注册回调函数,当有新 Webhook 加入时通知它们。
- 主要场景:
- 实时 UI 展示(Blazor 页面订阅回调刷新界面)
- 调试或测试环境
- 临时存储 Webhook 数据
注意:不持久化,进程重启会丢失数据;也不支持跨实例通知(多进程、多 pod 情况下,每个实例独立存储和通知)。
ConcurrentQueue<WebHookReceived> _data:
- 存储所有接收到的 Webhook 消息。
ConcurrentQueue:- 线程安全:多个线程可以同时
Enqueue或TryDequeue。 - FIFO:消息顺序按接收时间排列,符合 Webhook 事件顺序(先进先出)。
- 无锁高性能:比 List 或 Queue 在高并发场景下更高效。
- 线程安全:多个线程可以同时
ConcurrentDictionary<OnChangeSubscription, object?> _onChangeSubscriptions:
- 保存所有订阅者(订阅回调函数),字典只用作集合。
- Key:
OnChangeSubscription对象,每个对象代表一个订阅者及其回调。 - Value:固定为
null,因为字典主要用作线程安全的集合。
AddNew(WebHookReceived hook):
-
将新的 Webhook 消息放入队列。
-
队列保证消息顺序,多个线程可安全入队(先进先出)。
-
通知订阅者:遍历所有订阅者,调用其回调函数
NotifyAsync()。 -
异步调用:使用
_ =fire-and-forget(Fire-and-forget 不保证回调完成或成功),不阻塞AddNew执行。 -
异常处理:异常被捕获并忽略,保证仓库不会因为某个回调失败而中断。

GetAll():返回队列中所有 Webhook 消息的可枚举集合。
Subscribe(Func<Task> callback):
-
注册订阅者
-
callback:当有新消息时执行的回调函数。 -
返回
IDisposable对象,允许调用者取消订阅(Dispose移除订阅者)。
OnChangeSubscription:
- 内部类,代表一个订阅者,包含回调逻辑和对仓库的引用。
NotifyAsync():通知订阅者:仓库调用AddNew时,会调用这个方法触发回调。Dispose:当 Blazor 页面Dispose()时,会调用这个方法取消回调,避免内存泄漏。

eshop中的示例
页面 ReceivedMessages.razor 在初始化时向 HooksRepository 注册一次订阅(一个 OnChangeSubscription 实例),HooksRepository 使用线程安全的集合 ConcurrentDictionary<OnChangeSubscription, object?> _onChangeSubscriptions 保存所有订阅。
OnChangeSubscription 有两个重要方法:
NotifyAsync():当HooksRepository新增 WebHook 时由仓库调用,执行页面刷新相关的回调(例如通过InvokeAsync(OnMessageReceivedAsync)在 Blazor 上下文刷新 UI)。Dispose():当页面销毁(或不再需要接收通知)时调用,负责将该订阅从_onChangeSubscriptions中移除。必须实现正确移除(TryRemove)以断开引用链,否则会导致订阅对象和页面实例无法被垃圾回收,从而造成内存泄漏。
简单理解:每个页面对应一个OnChangeSubscription,页面初始化的时候添加到ConcurrentDictionary<OnChangeSubscription, object?> ,页面关闭时从ConcurrentDictionary<OnChangeSubscription, object?> 移除,新增WebHook 触发NotifyAsync刷新页面

6.配置WebhooksClient的HttpClient
注册 WebhooksClient,用于调用 Webhooks.API

6.1WebhooksClient
WebhooksClient 封装注入的 HttpClient,通过 AddWebHookAsync 向 Webhooks.API 发送 POST 请求 注册订阅,并通过 LoadWebhooks 加载已注册的订阅列表。

6.2配置BaseAddress
之前默认配置的是域名

为了方便调试,改成了配置文件,就是Webhooks.API的地址,WebhookClient访问的是Webhooks.API的接口

宿主程序(eShop.AppHost)中配置的是域名,然后读取的也是实际服务的运行地址
暂时还没学习到宿主程序(eShop.AppHost),先这么配置,后面需要域名的话再改appsettings.json即可

6.3配置API 版本
"Webhooks.API 中使用 Minimal API 的路由组仅注册了 /v1 前缀的接口"

WebhookClient也只用添加 /v1 版本

6.4配置Token
AddAuthToken:
- 注入
HTTP请求上下文(HttpContextAccessor) - 注入
HttpClientAuthorizationDelegatingHandler为瞬时服务 AddHttpMessageHandler:AddHttpMessageHandler<T>()会把一个DelegatingHandler加入HttpClient的处理链。DelegatingHandler是一个可扩展的中间件,用于拦截、修改、处理请求和响应。每次你通过这个HttpClient发送请求时,处理链会依次执行这些DelegatingHandler的SendAsync方法。换句话说,AddHttpMessageHandler就是把一个“请求拦截器”加到 HttpClient 里。

HttpClientAuthorizationDelegatingHandler是一个自定义的HttpClient请求拦截器,继承DelegatingHandler,重写SendAsync
- 1.继承
DelegatingHandler,DelegatingHandler是一个可扩展的中间件,用于拦截、修改、处理请求和响应。 - 2.
HttpClientAuthorizationDelegatingHandler构造函数注入HTTP请求上下文(HttpContextAccessor),用于获取访问令牌(access_token) - 3.重写
SendAsync:- 3.1从
HTTP请求上下文(HttpContextAccessor)获取访问令牌(access_token) - 3.2把访问令牌(
access_token)放到请求头(Headers)中 - 3.3发送请求
- 3.1从

7.映射默认端点
MapDefaultEndpoints:
/health:检查应用及依赖是否就绪/alive:检查应用进程是否存活

8.生产环境异常处理 & HSTS
UseExceptionHandler:全局捕获未处理异常。当发生未捕获异常时,会重定向到 /Error 页面。
createScopeForErrors: true:- 为错误处理创建独立的 DI scope。
- 方便在处理异常时使用依赖注入(如日志服务、数据库)。
UseHsts:全称HTTP Strict Transport Security,告诉浏览器未来访问该网站只能使用 HTTPS。默认值 30 天,生产环境推荐开启。保护应用免受中间人攻击和协议降级攻击。

9.防跨站请求伪造(UseAntiforgery)
为 Blazor Server 或 MVC 页面生成 Anti-Forgery Token。防止外部站点伪造请求。
Token 会自动附加在表单或 AJAX 请求里,服务端验证有效性。

10.静态文件中间件(UseStaticFiles)
允许访问 wwwroot 目录下的 CSS、JS、图片等静态资源。
注意:必须在 路由/Endpoint 前 调用,否则静态资源可能被路由拦截。
用途:加载 Blazor WebAssembly 静态文件、CSS、图标等。

11.Blazor Server组件映射和交互模式
MapRazorComponents<App>():注册 App 组件为应用根组件,并在 HTTP 请求管道中创建对应的路由映射。
- 创建一个 endpoint,浏览器请求
/(或你指定的路径)时,会返回 Blazor Server 的初始 HTML。 - 初始化 SignalR 连接,使客户端可以和服务器端组件进行实时交互。
App:根组件,通常是App.razor。
AddInteractiveServerRenderMode():
- 启用 Blazor Server 的交互模式。
- 每个组件通过 SignalR 与服务器连接,实现 双向绑定、实时 UI 更新。
| 方法 | 作用 |
|---|---|
MapRazorComponents<T>() |
注册 Razor 组件路由,初始化 SignalR 端点 |
AddInteractiveServerRenderMode() |
开启 Blazor Server 交互模式,支持实时事件处理和双向绑定 |
把 App.razor 映射为应用根组件,并开启 Blazor Server 的交互式模式,让页面能实时响应用户操作。

12.注销认证(MapAuthenticationEndpoints)
MapAuthenticationEndpoints:
- 1.注册
Miniapi接口/logout路由,用于注销当前用户 - 2.验证
Anti-Forgery Token,防止 CSRF 攻击 - 3.注销
Cookie认证 - 4.注销
OpenID Connect认证

认证的时候就是注册的Cookie认证和OpenID Connect认证,所以需要注销Cookie认证和OpenID Connect认证

注销OpenID Connect认证需要在认证服务(Identity.API)中配置注销后回调地址

13.Webhook其他端点(MapWebhookEndpoints)
/check:预检路由,用于接收外部系统的 OPTIONS 请求,检查请求头 X-eshop-whtoken 是否与配置的 token 匹配,如果关闭验证或 token 正确,则返回 200 OK 并可回写 token,否则返回 400 Bad Request,确保只有合法 Webhook 能访问服务。
这个逻辑关系有点绕,总结一下:
!validateToken为false:关闭验证,直接返回 200 OK!validateToken为true并且value == tokenToValidate为true:开启验证且 token 正确, 返回 200 OK!validateToken为true并且value == tokenToValidate为false:开启验证但 token 错误,返回 400 Bad Request
简单理解:就是判断请求的请求头(X-eshop-whtoken)是否和配置(appsettings.json)里的一致(除非关闭验证开关 ValidateToken=false)

/webhook-received:定义/webhook-receivedPOST路由,用于接收外部系统发送的 Webhook 消息:它先从请求头 X-eshop-whtoken 获取 token 并记录日志,然后判断是否关闭验证或 token 是否匹配,如果通过,则将消息封装为 WebHookReceived 对象保存到内存仓库 HooksRepository 并返回 200 OK,否则返回 400 Bad Request,同时记录未处理的日志。

14.Blazor入门
WebhookClient运行起来没什么问题,然后就简单学习一下Blazor的知识

14.1统一 using 导入(_Imports.razor)
为整个项目或者该目录下的 Razor 页面提供 全局 using 指令,避免每个页面都重复写 @using。
-
@using System.Net.Http:引入HttpClient相关类,用于发送 HTTP 请求。 -
@using System.Net.Http.Json:- 扩展
HttpClient,支持 JSON 的序列化/反序列化。 GetFromJsonAsync<T>()→ 直接把响应 JSON 反序列化成对象PostAsJsonAsync<T>()→ 发送对象作为 JSON
- 扩展
-
@using Microsoft.AspNetCore.Components.Forms:- 用于表单相关功能
<EditForm>、InputText、InputNumber、ValidationMessage等。 - 提供表单验证、双向绑定、提交事件等。
- 用于表单相关功能
-
@using Microsoft.AspNetCore.Components.Routing:- 用于 Blazor 页面跳转和导航。
- 提供路由功能相关类:
NavLink:导航链接组件NavigationManager:URL 导航、获取当前 URL
-
@using Microsoft.AspNetCore.Components.QuickGrid:- QuickGrid 是 Blazor 的数据表格组件库。
<QuickGrid>:显示表格<PropertyColumn>:列定义- 排序、分页、过滤功能
-
@using Microsoft.AspNetCore.Components.Web:-
引入 Web 相关事件和功能:
- 鼠标事件、键盘事件
- 表单事件
- DOM 交互事件
-
支持 Blazor 组件事件绑定,比如:
<button @onclick="OnClick">Click</button>
-
-
@using static Microsoft.AspNetCore.Components.Web.RenderMode:- 使用
static导入枚举或静态成员,方便直接引用: - 比如
<HeadOutlet @rendermode="InteractiveServer" />不用写全名RenderMode.InteractiveServer。
- 使用
-
@using Microsoft.AspNetCore.Components.Web.Virtualization:- 提供虚拟化组件:
<Virtualize>渲染大量列表时,只渲染可见部分,提高性能 - 在展示大量 Webhook 消息或表格时很有用。
- 提供虚拟化组件:
-
@using Microsoft.JSInterop:- JavaScript 互操作:在 Blazor 中调用 JS 方法,或从 JS 调用 .NET 方法
- 常用于:操作 DOM、调用浏览器 API(如 alert、localStorage)、集成第三方 JS 库

14.2入口(App.razor)
App.razor是 Blazor Server 应用的入口 HTML 文件,也就是浏览器加载 Blazor 组件和 SignalR 交互的基础页面。
基本结构:
- 标准 HTML 页面结构。
- 加载样式文件
Blazor 特有标记
<HeadOutlet>:允许 Blazor 动态往<head>注入内容,比如页面标题、meta 标签、CSS。<Routes>:渲染当前 URL 对应的组件(类似<Router>功能)。@rendermode="InteractiveServer":- 启用 Blazor Server 模式。
- 页面通过 SignalR 连接到服务器,实现组件交互和 UI 实时刷新。

14.3路由(Routes.razor)
Routes.razor是 Blazor Server 或 Blazor WebAssembly 中典型的路由配置组件
<Router AppAssembly="@typeof(Program).Assembly">...</Router>:
- 作用:负责应用的路由分发。
AppAssembly:告诉 Blazor 去扫描哪个程序集(Assembly)来查找带有[Route("/xxx")]特性的组件。@typeof(Program).Assembly表示当前应用程序的主程序集。
<Found Context="routeData">...</Found>:
- 作用:当路由匹配到组件时执行的渲染逻辑。
Context="routeData":将匹配到的路由信息(RouteData对象)传入内部模板。routeData包含:组件类型和参数信息。
<RouteView RouteData="@routeData" DefaultLayout="@typeof(Layout.MainLayout)" />:
- 作用:真正渲染匹配的 Razor 组件。
RouteData="@routeData":提供匹配的组件类型和参数给RouteView。DefaultLayout="@typeof(Layout.MainLayout)":- 如果该组件没有指定
@layout,则使用MainLayout作为默认布局。 - 布局会把页面内容插入
@Body位置。
- 如果该组件没有指定
<FocusOnNavigate RouteData="@routeData" Selector="h1" />:
- 作用:页面导航时自动将焦点移动到指定元素,提高无障碍体验。
RouteData="@routeData":当前路由信息。Selector="h1":焦点将跳到页面中的第一个<h1>元素。

14.4布局(Layout)
14.4.1MainLayout.razor
@inherits LayoutComponentBase:
- 说明这个组件继承自
LayoutComponentBase - 所有布局组件必须继承它。
LayoutComponentBase提供了一个特殊属性@Body,表示子页面内容在布局中的位置。- 简单理解:布局是整个页面的“框架”,
@Body是“内容插槽”。
<UserMenu />:是一个 Blazor 组件
@Body :表示 该布局下子页面的实际内容
<div id="blazor-error-ui">:
- Blazor 内置 全局错误提示 UI:
- 当应用抛出未捕获异常时,这个
<div>会显示。 <a class="reload">Reload</a>:刷新页面<a class="dismiss">🗙</a>:关闭错误提示
总结
MainLayout.razor 是 Blazor 页面布局组件:
@inherits LayoutComponentBase:提供布局能力<header>:固定页头 + 用户菜单<main>@Body</main>:子页面内容插入点#blazor-error-ui:Blazor 全局错误处理界面
布局作用:统一整个应用的外观和头尾,子页面只需渲染内容。

14.4.2UserMenu.razor
@inject NavigationManager Nav:注入 Blazor 内置的 NavigationManager
- 管理当前 URL
- 用于跳转页面(
NavigateTo)
<AuthorizeView><Authorized>...</Authorized><NotAuthorized>... </NotAuthorized> </AuthorizeView>:
- 作用:根据用户认证状态(是否登录)渲染不同的内容。
Authorized:用户已登录NotAuthorized:用户未登录
<form method="post" action="logout"><AntiforgeryToken /><button class="action" type="submit">Log out</button></form>:
- 已认证视图
- 提交表单到
/logout端点,登出用户。 <AntiforgeryToken />:Blazor 自动生成的防 CSRF Token,保证表单安全。
LogIn:
Nav.ToBaseRelativePath(Nav.Uri):获取当前页面相对路径returnUrlNav.NavigateTo():跳转到login页面,携带returnUrl参数。

14.5页面(Pages)
14.5.1Home.razor
@page "/":
- 指定这是一个页面组件,并把它映射到根路由
/。用户访问站点根路径时会渲染此组件。 - 注意:只有带
@page的 Razor 组件才能被路由直接访问。
@using Microsoft.AspNetCore.Components.Authorization:
- 在这个文件中引入认证相关的命名空间,允许使用
AuthorizeView、AuthenticationStateProvider等类型。 - 如果不引入也可以用完全限定名,但通常放在页面顶部以方便使用授权组件和类型。
<PageTitle>Order management</PageTitle>:
- Blazor 的内置组件,用来设置浏览器标签页(
<title>)的内容为 “Order management”。 - 只在页面被渲染时生效,多个页面可设置不同标题。
<AuthorizeView>:
- Blazor 提供的授权显示组件:根据当前用户的认证状态(已认证/未认证)渲染不同内容。
- 它会从最近的
CascadingAuthenticationState获取AuthenticationState,所以必须在App.razor或上层组件中提供级联认证状态。
<Authorized>:
AuthorizeView的子元素,包裹在用户已认证(已登录)时要显示的内容。用户通过认证时,这部分会被渲染。- 内部可以放权限受限的 UI 或需要用户信息的组件。
<RegisteredHooks />:
- 自定义的 Blazor 子组件(或组件标签)。意味着在当前位置渲染名为
RegisteredHooks的组件。 - 该组件通常负责列出并管理已注册的 webhook(例如:显示 URL、删除、编辑等)。若找不到组件会编译错误。
<a class="action" href="add-webhook">Add webhook registration</a>:
- 一个 HTML 链接,指向相对路径
add-webhook(即/add-webhook)。点击后导航到注册 webhook 的页面。 class="action"是 CSS 类名,样式取决于项目的样式表。注意:这是普通<a>,在 Blazor Server/WasM 中若想使用内部路由而不刷新页面,可换成<NavLink>或@onclick+NavigationManager.NavigateTo。
NotAuthorized:
AuthorizeView的另一个子元素,包裹未认证用户看到的内容。未登录用户会看到这里的内容。- 常用于提示登录或显示不允许访问的替代文本。
<ReceivedMessages />:
- 渲染另一个自定义组件
ReceivedMessages。该组件负责显示后端接收到的 webhook 消息(通常是一个列表、时间戳和 payload 详情)。

14.5.2ReceivedMessages.razor
@inject HooksRepository HooksRepository:
- 注入一个 HooksRepository 实例
- 组件加载时,Blazor 自动将 DI 容器中的
HooksRepository注入。用于读取所有消息 & 订阅新消息事件。
@implements IDisposable:
- 声明组件实现 IDisposable
- Blazor 在组件销毁(离开页面)时会调用
Dispose()。 - 用来取消订阅,避免组件销毁后 Repository 还继续回调 → 内存泄漏。
@if (messages is null):
- 页面首次加载:数据还没拉回来
- 初始
messages为 null,所以显示一个 "Loading..."。
else if (messages.Any()):
- 有消息时渲染
QuickGrid
<QuickGrid Items="@messages">:
- 官方高性能表格组件(排序、虚拟化、分页)
- 虚拟化渲染就是“只渲染眼前,隐藏内容不渲染”,从而让表格能高效显示几千甚至几万行数据。
Items="@messages":绑定数据源PropertyColumn:列配置Sortable=true:列支持排序
@code:是 Blazor(Razor 组件)中用于写 C# 代码的代码块。
OnInitializedAsync:
- 生命周期初始化
- 组件首次渲染之前执行(加载数据、注册事件等最好放这里)
Dispose:
- 组件销毁时取消订阅,避免内存泄漏

14.5.3RegisteredHooks.razor
@inject WebhooksClient WebhooksClient:
- 注入
WebhooksClient服务,用于获取 webhook 数据。 - Blazor 会从 DI 容器中提供实例。
@if (webhooks is null):
- 数据还没加载
webhooks初始为 null → 显示 “Loading...” 占位信息。- 用户体验优化:避免空白页面。
else if (webhooks.Any()):
- 有数据
- 使用 Blazor QuickGrid 显示表格。
<QuickGrid Items="@webhooks">:
Items="@webhooks":绑定数据源。PropertyColumn:定义列Sortable=true:可以点击列排序IsDefaultSortColumn+InitialSortDirection:默认按 Date 倒序
else:
- 没有数据
- 占位提示,告诉用户当前没有 webhook 注册。
OnInitializedAsync:
- 生命周期方法:
OnInitializedAsync() - 页面初始化时调用一次:异步加载 webhook 数据
- 加载完成后
webhooks不再为 null:UI 自动刷新渲染表格
为什么表格数据源都是
IQueryable?
- QuickGrid 支持排序、分页、过滤等操作。
IQueryable可以延迟执行,排序/分页可直接生成数据库查询,节省内存和 CPU。IEnumerable或List也可以使用,但所有操作都在内存里执行,数据量大时性能较差。使用
IQueryable可以让 QuickGrid 的排序、分页、过滤延迟执行并与数据库查询结合,更高效;数据量小则List或IEnumerable也可以。

14.5.4AddWebhook.razor
@page "/add-webhook":
- 声明这是一个页面组件,路由为
/add-webhook - 访问
/add-webhookURL 时会渲染这个组件
@inject: 注入服务
IOptions<WebhookClientOptions> options:注入配置项NavigationManager Nav:用于页面导航WebhooksClient WebhooksClient:用于调用后台 API 注册 webhook
<form @onsubmit="RegisterAsync">:
- 使用 Blazor 的事件绑定,把表单提交与
RegisterAsync方法绑定
<input type="text" @bind="@token" />:
- 双向绑定
token变量 - 用户输入会实时更新
token,变量改变也会更新输入框
<button type="submit">:提交按钮
RegisterAsync:RegisterAsync 异步提交表单,将 WebhookClient 配置的 token 发送给 Webhooks API 注册 OrderPaid webhook,并根据结果导航或显示错误信息。

14.5.5Error.razor
@page "/Error":路由为 /Error,当请求发生异常时可以导航到此页面
@using System.Diagnostics:引入 Activity 类,用于获取请求 ID
<PageTitle>:设置浏览器标签页标题
这个错误页面显示请求处理异常,提供 Request ID 以便调试,并提示开发者在 Development 环境下查看详细信息,同时提醒生产环境不要泄露异常详情。

14.5.6LogIn.razor
@page "/login":页面路由为 /login
@attribute [Authorize]:访问此页面需要认证。如果用户未登录,会自动被重定向到 IdentityServer / 登录页
@using Microsoft.AspNetCore.Authorization:引入授权相关命名空间
@inject NavigationManager Nav:注入 NavigationManager 用于在客户端进行页面导航
[SupplyParameterFromQuery]: Blazor 特性
- 允许组件从 URL 查询字符串中接收参数
- 例如:访问
/login?ReturnUrl=/orders,ReturnUrl会被赋值/orders
Nav.NavigateTo(returnUrl.ToString(), replace: true):
- 客户端导航到目标 URL
replace: true:替换当前浏览器历史记录,避免用户点击“返回”返回登录页

14.5.7总结
后面的WebApp和HybridApp也使用了Blazor技术,对Blazor有个基本概念会用就行了,不影响接下来的学习
如果你以前有使用过Asp.Net MVC或者Vue等前端框架,Blazor和他们有很多相同点,学起来会简单些
15WebhookClient的X-eshop-whtoken验证流程
eshop自定义的请求头X-eshop-whtoken,防止伪造请求,WebhookClient模拟第三方服务订阅eshop的集成事件,通过Webhooks.API转发
WebhookClient配置X-eshop-whtoken

启动WebhookClient,然后登录,点击Add webhook registration

会跳转到AddWebhook.razor界面

WebhookClient模拟的是订单支付的第三方客户端,让后把appsettings.json中WebhookClientOptions:Token的配置和当前WebhookClient运行地址/webhook-received发送到Webhooks.API进行保存

Webhooks.API会把X-eshop-whtoken和地址保存到数据库

订单管理的信息成功入库
Token:待验证的请求头DestUrl:Webhooks.API收到订阅的集成事件后需要转发的第三方服务的地址

Webhooks.API的订阅的集成事件处理器收到消息都会转发,目前只订阅了:
OrderStatusChangedToPaidIntegrationEvent:订单支付成功OrderStatusChangedToShippedIntegrationEvent:订单已发货ProductPriceChangedIntegrationEvent:商品价格变更
查询所有的WebhookType.CatalogItemPriceChange/WebhookType.OrderShipped/WebhookType.OrderPaid的订阅者,然后调用SendAll发送

发送的时候会使用从数据库查询到订阅者的地址(DestUrl)和Token

WebhookClient收到回调请求会验证token和自己配置appsettings.json中WebhookClientOptions:Token的是否匹配,匹配才通知页面更新UI

WebhookClient会显示出订阅的消息,订阅的消息只保留在内存中,未持久化,所以页面关闭就没了

16.总结
WebhookClient比较重要的3个知识点:
OAuth2.0 OpenID Connect授权Blazor框架WebhookClient模拟第三方服务订阅eshop集成事件,X-eshop-whtoken验证流程
Webhooks.API和WebhookClient不是前后端的关系,WebhookClient是个独立的Web项目
Webhooks API 是 eShop 提供的事件订阅和推送服务,由第三方服务使用,而 WebhooksClient 是模拟第三方服务,用来订阅和接收这些事件。
| 名称 | 用途 | 说明 |
|---|---|---|
| Webhooks API | 提供事件订阅接口 | 这是 eShop 系统提供的服务,第三方服务可以通过它注册自己想要订阅的事件(例如 OrderPaid),然后 eShop 会在事件发生时发送 POST 请求到第三方服务提供的 URL。 |
| WebhooksClient | 模拟第三方服务 | 这是一个测试/模拟客户端,用来模拟外部服务订阅 eShop 的事件,实现接收 webhook 消息的逻辑,也可以注册自己的 token、回调 URL 等。 |
17.启动项目
17.1配置X-eshop-whtoken
WebhookClient的appsettings.json配置WebhookClientOptions/Token

这个Token会通过界面的Add webhook registration按钮保存到数据库
WebhookClient是模拟第三方服务订阅eshop的集成事件,WebhooksClient通过DestUrl和Token转发给WebhookClient

17.2配置认证授权
appsettings.json中配置:
IdentityUrl:认证服务器地址CallBackUrl:注销成功毁掉地址

WebhookClient使用的是Cookie和OpenId Connect俩种认证方案
ClientId、ClientSecret、ResponseType要和认证服务的配置一致
RequireHttpsMetadata:保证认证服务是https协议运行

在认证服务(Identity.API)中配置WebhookClient的认证授权方案,以下是比较重要的配置
ClientId:客户端唯一标识AllowedGrantTypes:授权方式,这里使用的是授权码模式RequireConsent:显示授权界面,方便理解认证授权完整流程RedirectUris:登录成功回调地址,WebApp的地址PostLogoutRedirectUris:注销后的回调地址,WebApp的地址AllowedScopes:WebApp允许访问的范围

WebhookClient配置了Cookie的命名

最后点击Log in测试认证授权

17.3配置Webhooks.API地址
配置Webhooks.API的实际地址

WebhooksClient使用的是HttpClient

WebhooksClient实现了俩个方法:
AddWebHookAsync:添加第三方Webhook客户端LoadWebhooks:加载用户订阅的所有Webhook客户端

📌 创作不易,感谢支持!
每一篇内容都凝聚了心血与热情,如果我的内容对您有帮助,欢迎请我喝杯咖啡☕,您的支持是我持续分享的最大动力!
💬 加入交流群(QQ群):576434538

浙公网安备 33010602011771号