Webhook客户端(WebhookClient)

前言

山高路远,止不住行者征程

春秋几变,篆刻鲲鹏轨迹

1.基础设置配置

默认基础设置配置:

  • 1.健康检测
  • 2.OpenTelemetry(可观测性框架:链路追踪、指标采集、日志、数据导出)
  • 3.服务注册和发现
  • 4.HttpClient

image-20251126202408821

2.Blazor配置

WebhookClient是基于Blazor开发的Web项目,所以需要注册Blazor相关配置

注册 Blazor Razor 组件系统,并启用服务器端交互式渲染,使 .razor 页面可以处理用户事件(如点击、表单提交)并动态更新 UI,而无需整页刷新。

关于Blazor这里不是重点,而且属于微软比较新的技术,我了解的也比较少,这里只是以可运行项目为主,遇到什么问题解决什么问题

image-20251126203129718

3.认证和授权

WebhookClient是基于Blazor开发的Web项目,即像客户端,又像服务端,所以认证授权和之前纯后端服务有些不一样,还是一行一行学习吧

3.1授权(AddAuthorization)

这里没有添加授权方案,默认的授权方案是认证登录

之前后端服务都是先认证再授权,为什么这里先授权再认证?

在 ASP.NET Core / Blazor 中,注册顺序看起来是“先授权再认证”,不会影响;真正生效的顺序是中间件执行顺序:先认证(Authentication),再授权(Authorization),确保 [Authorize] 能正确读取用户身份。

image-20251126210047752

3.2认证(AddAuthentication)

DefaultScheme:系统默认用哪个认证方案读取用户身份(告诉系统“你是谁”)。

DefaultChallengeScheme:当用户未认证访问受保护资源时,用哪个方案触发登录/认证流程(告诉系统“你没登录怎么办”)。

CookieAuthenticationDefaults.AuthenticationScheme:系统默认使用 Cookie 作为用户身份来源,用于读取和验证已登录用户的信息。

OpenIdConnectDefaults.AuthenticationScheme:当用户访问受保护资源但未登录时,系统默认触发 OpenID Connect 流程,让用户登录并获取令牌。

image-20251126215734024

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)

image-20251126220501706

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,认为用户已登录
  • 登录信息不匹配 → 出现认证错误或“未授权”

image-20251126221609502

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 参数

image-20251126223153574

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

image-20251126220915665

跳转到了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

image-20251126221052463

登录成功后又跳转回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 数据,自动拆分

image-20251126230935233

3.5Blazor认证状态管理(AuthenticationStateProvider)

在 Blazor 应用中,无论是 Blazor Server 还是 Blazor WebAssembly (WASM),都需要一个机制来获取当前用户的身份信息(Authentication)和角色/权限(Authorization)。

  • Blazor 使用 AuthenticationStateProvider 来提供当前用户的认证状态。
  • 组件可以通过 CascadingAuthenticationState 获取 AuthenticationState 对象,从而判断用户是否登录以及其 Claims 信息。

AuthenticationStateProvider

  • 抽象类

  • GetAuthenticationStateAsync():返回当前用户的认证状态。

  • Blazor 组件可以注入 AuthenticationStateProvider 并调用它获取用户信息。

  • 核心用途:

    • 提供当前用户信息ClaimsPrincipal)。
    • 触发认证状态变化NotifyAuthenticationStateChanged),让 UI 动态更新。

image-20251128003453833

ServerAuthenticationStateProvider

这是 Blazor 服务器端提供的一个实现:

  • 用于 Blazor Server
  • 它会读取 HttpContext.User 来获取当前用户信息。
  • 当用户登录或登出时,可以调用:NotifyAuthenticationStateChanged(Task<AuthenticationState> task)

image-20251128003651750

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

image-20251128003722353

示例

image-20251128004119231

总结

部分 作用
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
  • AddCascadingAuthenticationStateAuthenticationState 挂到组件树顶层。
  • <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)添加了一个日志中间件用于输出请求、参数、响应

image-20251129214139864

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

image-20251129214503967

3.6.2启动客户端

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

image-20251126220915665

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 *

image-20251127202616398

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 **********

image-20251127202220481

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 **********

image-20251127204717662

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_secretcode_challenge)。
    • 可以传输更多参数而不受 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

image-20251127210122895

响应

字段 说明
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 **********

image-20251127205934755

3.6.7客户端请求认证服务授权端点/connect/authorize

/connect/authorize 是 OIDC 授权端点,客户端携带 client_idrequest_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

image-20251126233921959

日志

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 **********

image-20251127211523107

3.6.8用户未登录重定向登录页(/Account/Login)

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

GET /Account/Login

image-20251127221549060

参数

request_uriPushed 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 **********

image-20251127220620641

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)

image-20251127222637056

响应

重定向到了授权页

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 **********

image-20251127223552402

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 **********

image-20251127225008743

3.6.11授权页(/consent)

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

image-20251127225958919

参数

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 **********

image-20251127230027544

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 请求的 openidprofilewebhooks 权限,并选择记住同意,以便下次无需再次确认。

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 **********

image-20251127231205063

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

数据库查询用户信息

image-20251127231733691

用户已同意授权

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}]

image-20251127231859812

响应

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 **********

image-20251127232334769

3.6.14授权回调请求-颁发Token(/connect/token)

客户端使用之前从 /connect/authorize/callback 获取的授权码(Authorization Code),连同 client_idclient_secretredirect_uricode_verifier(PKCE 验证)向 IdentityServer 的 /connect/token 发送 POST 请求;IdentityServer 先验证客户端身份,再验证授权码和 PKCE,确认合法后生成 Access TokenID 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 成功,客户端身份合法,可以继续处理授权码

image-20251127232938992

数据库查询用户信息

image-20251127233218069

验证授权码请求

IdentityServer 验证:

  • 授权码是否有效且未过期
  • redirect_uri 是否匹配
  • code_verifier 是否正确(PKCE 验证)

验证成功后,可以生成 tokens

image-20251127233257077

颁发 Token

  • IdentityServer 生成:

    • ID Token → 包含用户身份信息 (JWT)

    • Access Token → 用于访问受保护资源(API)

  • Scope 对应用户同意的权限:openidprofilewebhooks

  • Token 会被返回给客户端

image-20251127233459687

响应

字段 说明
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),客户端获得的权限,这里包括 openidprofilewebhooks
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 **********

image-20251127233810035

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)查询数据库。此处使用 PostgreSQLAspNetUsers 表获取用户完整信息。

image-20251127235410541

IdentityServer 将数据库中的用户信息转换为Claims

Claims 列表包括:

  • 标准 OpenID Connect Claims:subpreferred_usernamenameemail
  • 自定义 Claims:如银行卡信息(card_numbercard_holder 等)、地址信息(address_cityaddress_state 等)

image-20251127235711785

响应

返回的都是数据库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 **********

image-20251128000004879

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 **********

image-20251128000807829

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 **********

image-20251128000928059

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 并返回给客户端。

如果客户端请求的 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_verifiercode_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,包含用户身份信息(subnameemail 等)。

  • 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 客户端使用的 Token
  • SelfUrl:Webhook 客户端对外暴露的 URL(自身的地址)
  • ValidateToken: 是否启用 Token 校验。

image-20251128172916468

5.内存仓储服务(HooksRepository)

HooksRepository 是一个 线程安全的内存 Webhook 消息仓库 + 订阅通知机制

  • 存储功能:用内存队列 _data 保存收到的 Webhook 消息(WebHookReceived)。
  • 通知功能:允许多个订阅者注册回调函数,当有新 Webhook 加入时通知它们。
  • 主要场景
    • 实时 UI 展示(Blazor 页面订阅回调刷新界面)
    • 调试或测试环境
    • 临时存储 Webhook 数据

注意:不持久化,进程重启会丢失数据;也不支持跨实例通知(多进程、多 pod 情况下,每个实例独立存储和通知)。

ConcurrentQueue<WebHookReceived> _data

  • 存储所有接收到的 Webhook 消息。
  • ConcurrentQueue
    • 线程安全:多个线程可以同时 EnqueueTryDequeue
    • FIFO:消息顺序按接收时间排列,符合 Webhook 事件顺序(先进先出)。
    • 无锁高性能:比 List 或 Queue 在高并发场景下更高效。

ConcurrentDictionary<OnChangeSubscription, object?> _onChangeSubscriptions

  • 保存所有订阅者(订阅回调函数),字典只用作集合。
  • KeyOnChangeSubscription 对象,每个对象代表一个订阅者及其回调。
  • Value:固定为 null,因为字典主要用作线程安全的集合。

AddNew(WebHookReceived hook)

  • 将新的 Webhook 消息放入队列。

  • 队列保证消息顺序,多个线程可安全入队(先进先出)。

  • 通知订阅者:遍历所有订阅者,调用其回调函数 NotifyAsync()

  • 异步调用:使用 _ = fire-and-forget(Fire-and-forget 不保证回调完成或成功),不阻塞 AddNew 执行。

  • 异常处理:异常被捕获并忽略,保证仓库不会因为某个回调失败而中断。

image-20251128211305252

GetAll():返回队列中所有 Webhook 消息的可枚举集合。

Subscribe(Func<Task> callback)

  • 注册订阅者

  • callback:当有新消息时执行的回调函数。

  • 返回 IDisposable 对象,允许调用者取消订阅(Dispose 移除订阅者)。

OnChangeSubscription

  • 内部类,代表一个订阅者,包含回调逻辑和对仓库的引用。
  • NotifyAsync()通知订阅者:仓库调用 AddNew 时,会调用这个方法触发回调。
  • Dispose:当 Blazor 页面 Dispose() 时,会调用这个方法取消回调,避免内存泄漏。

image-20251128211206058

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刷新页面

image-20251128224313137

6.配置WebhooksClientHttpClient

注册 WebhooksClient,用于调用 Webhooks.API

image-20251128230805722

6.1WebhooksClient

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

image-20251128230547179

6.2配置BaseAddress

之前默认配置的是域名

image-20251128231030258

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

image-20251128231054096

宿主程序(eShop.AppHost)中配置的是域名,然后读取的也是实际服务的运行地址

暂时还没学习到宿主程序(eShop.AppHost),先这么配置,后面需要域名的话再改appsettings.json即可

image-20251128231658635

6.3配置API 版本

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

image-20251128232307818

WebhookClient也只用添加 /v1 版本

image-20251128232604547

6.4配置Token

AddAuthToken

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

image-20251128233202970

HttpClientAuthorizationDelegatingHandler是一个自定义的HttpClient请求拦截器,继承DelegatingHandler,重写SendAsync

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

image-20251128233611364

7.映射默认端点

MapDefaultEndpoints

  • /health:检查应用及依赖是否就绪
  • /alive:检查应用进程是否存活

image-20251128234632758

8.生产环境异常处理 & HSTS

UseExceptionHandler:全局捕获未处理异常。当发生未捕获异常时,会重定向到 /Error 页面。

  • createScopeForErrors: true
    • 为错误处理创建独立的 DI scope。
    • 方便在处理异常时使用依赖注入(如日志服务、数据库)。

UseHsts:全称HTTP Strict Transport Security,告诉浏览器未来访问该网站只能使用 HTTPS。默认值 30 天,生产环境推荐开启。保护应用免受中间人攻击和协议降级攻击。

image-20251129001607052

9.防跨站请求伪造(UseAntiforgery)

为 Blazor Server 或 MVC 页面生成 Anti-Forgery Token。防止外部站点伪造请求。

Token 会自动附加在表单或 AJAX 请求里,服务端验证有效性。

image-20251129002009112

10.静态文件中间件(UseStaticFiles)

允许访问 wwwroot 目录下的 CSS、JS、图片等静态资源。

注意:必须在 路由/Endpoint 前 调用,否则静态资源可能被路由拦截。

用途:加载 Blazor WebAssembly 静态文件、CSS、图标等。

image-20251129002130121

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 的交互式模式,让页面能实时响应用户操作。

image-20251129002710553

12.注销认证(MapAuthenticationEndpoints)

MapAuthenticationEndpoints

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

image-20251129003338124

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

image-20251129003628330

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

image-20251129003929096

13.Webhook其他端点(MapWebhookEndpoints)

/check:预检路由,用于接收外部系统的 OPTIONS 请求,检查请求头 X-eshop-whtoken 是否与配置的 token 匹配,如果关闭验证或 token 正确,则返回 200 OK 并可回写 token,否则返回 400 Bad Request,确保只有合法 Webhook 能访问服务。

这个逻辑关系有点绕,总结一下:

  • !validateTokenfalse关闭验证,直接返回 200 OK
  • !validateToken true并且value == tokenToValidatetrue开启验证且 token 正确, 返回 200 OK
  • !validateTokentrue并且value == tokenToValidatefalse开启验证但 token 错误,返回 400 Bad Request

简单理解:就是判断请求的请求头(X-eshop-whtoken)是否和配置(appsettings.json)里的一致(除非关闭验证开关 ValidateToken=false

image-20251129005844346

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

image-20251129010935090

14.Blazor入门

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

image-20251129012113052

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>InputTextInputNumberValidationMessage 等。
    • 提供表单验证、双向绑定、提交事件等。
  • @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 库

image-20251129014614660

14.2入口(App.razor)

App.razorBlazor Server 应用的入口 HTML 文件,也就是浏览器加载 Blazor 组件和 SignalR 交互的基础页面。

基本结构

  • 标准 HTML 页面结构。
  • 加载样式文件

Blazor 特有标记

  • <HeadOutlet>:允许 Blazor 动态往 <head> 注入内容,比如页面标题、meta 标签、CSS。
  • <Routes>:渲染当前 URL 对应的组件(类似 <Router> 功能)。
  • @rendermode="InteractiveServer"
    • 启用 Blazor Server 模式
    • 页面通过 SignalR 连接到服务器,实现组件交互和 UI 实时刷新。

image-20251129012258512

14.3路由(Routes.razor)

Routes.razorBlazor 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> 元素。

image-20251129012834362

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.razorBlazor 页面布局组件

  • @inherits LayoutComponentBase:提供布局能力
  • <header>:固定页头 + 用户菜单
  • <main>@Body</main>:子页面内容插入点
  • #blazor-error-ui:Blazor 全局错误处理界面

布局作用:统一整个应用的外观和头尾,子页面只需渲染内容。

image-20251129014554390

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):获取当前页面相对路径 returnUrl
  • Nav.NavigateTo():跳转到 login 页面,携带 returnUrl 参数。

image-20251129020147602

14.5页面(Pages)

14.5.1Home.razor

@page "/"

  • 指定这是一个页面组件,并把它映射到根路由 /。用户访问站点根路径时会渲染此组件。
  • 注意:只有带 @page 的 Razor 组件才能被路由直接访问。

@using Microsoft.AspNetCore.Components.Authorization

  • 在这个文件中引入认证相关的命名空间,允许使用 AuthorizeViewAuthenticationStateProvider 等类型。
  • 如果不引入也可以用完全限定名,但通常放在页面顶部以方便使用授权组件和类型。

<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 详情)。

image-20251129185425168

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

  • 组件销毁时取消订阅,避免内存泄漏

image-20251129185407934

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。
  • IEnumerableList 也可以使用,但所有操作都在内存里执行,数据量大时性能较差。

使用 IQueryable 可以让 QuickGrid 的排序、分页、过滤延迟执行并与数据库查询结合,更高效;数据量小则 ListIEnumerable 也可以。

image-20251129190620910

14.5.4AddWebhook.razor

@page "/add-webhook"

  • 声明这是一个页面组件,路由为 /add-webhook
  • 访问 /add-webhook URL 时会渲染这个组件

@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">:提交按钮

RegisterAsyncRegisterAsync 异步提交表单,将 WebhookClient 配置的 token 发送给 Webhooks API 注册 OrderPaid webhook,并根据结果导航或显示错误信息。

image-20251129212106609

14.5.5Error.razor

@page "/Error":路由为 /Error,当请求发生异常时可以导航到此页面

@using System.Diagnostics:引入 Activity 类,用于获取请求 ID

<PageTitle>:设置浏览器标签页标题

这个错误页面显示请求处理异常,提供 Request ID 以便调试,并提示开发者在 Development 环境下查看详细信息,同时提醒生产环境不要泄露异常详情。

image-20251129212959073

14.5.6LogIn.razor

@page "/login":页面路由为 /login

@attribute [Authorize]:访问此页面需要认证。如果用户未登录,会自动被重定向到 IdentityServer / 登录页

@using Microsoft.AspNetCore.Authorization:引入授权相关命名空间

@inject NavigationManager Nav:注入 NavigationManager 用于在客户端进行页面导航

[SupplyParameterFromQuery]: Blazor 特性

  • 允许组件从 URL 查询字符串中接收参数
  • 例如:访问 /login?ReturnUrl=/ordersReturnUrl 会被赋值 /orders

Nav.NavigateTo(returnUrl.ToString(), replace: true)

  • 客户端导航到目标 URL
  • replace: true:替换当前浏览器历史记录,避免用户点击“返回”返回登录页

image-20251129213225729

14.5.7总结

后面的WebAppHybridApp也使用了Blazor技术,对Blazor有个基本概念会用就行了,不影响接下来的学习

如果你以前有使用过Asp.Net MVC或者Vue等前端框架,Blazor和他们有很多相同点,学起来会简单些

15WebhookClientX-eshop-whtoken验证流程

eshop自定义的请求头X-eshop-whtoken,防止伪造请求,WebhookClient模拟第三方服务订阅eshop的集成事件,通过Webhooks.API转发

WebhookClient配置X-eshop-whtoken

image-20251203001809206

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

image-20251203002247659

会跳转到AddWebhook.razor界面

image-20251203002506224

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

image-20251203003210898

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

image-20251203003259211

订单管理的信息成功入库

  • Token:待验证的请求头
  • DestUrlWebhooks.API收到订阅的集成事件后需要转发的第三方服务的地址

image-20251203003351487

Webhooks.API的订阅的集成事件处理器收到消息都会转发,目前只订阅了:

  • OrderStatusChangedToPaidIntegrationEvent:订单支付成功
  • OrderStatusChangedToShippedIntegrationEvent:订单已发货
  • ProductPriceChangedIntegrationEvent:商品价格变更

查询所有的WebhookType.CatalogItemPriceChange/WebhookType.OrderShipped/WebhookType.OrderPaid的订阅者,然后调用SendAll发送

image-20251203003711855

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

image-20251203004324201

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

image-20251203004540952

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

image-20251203004838135

16.总结

WebhookClient比较重要的3个知识点:

  • OAuth2.0 OpenID Connect授权
  • Blazor框架
  • WebhookClient模拟第三方服务订阅eshop集成事件,X-eshop-whtoken验证流程

Webhooks.APIWebhookClient不是前后端的关系,WebhookClient是个独立的Web项目

Webhooks API 是 eShop 提供的事件订阅和推送服务,由第三方服务使用,而 WebhooksClient 是模拟第三方服务,用来订阅和接收这些事件。

名称 用途 说明
Webhooks API 提供事件订阅接口 这是 eShop 系统提供的服务,第三方服务可以通过它注册自己想要订阅的事件(例如 OrderPaid),然后 eShop 会在事件发生时发送 POST 请求到第三方服务提供的 URL。
WebhooksClient 模拟第三方服务 这是一个测试/模拟客户端,用来模拟外部服务订阅 eShop 的事件,实现接收 webhook 消息的逻辑,也可以注册自己的 token、回调 URL 等。

17.启动项目

17.1配置X-eshop-whtoken

WebhookClientappsettings.json配置WebhookClientOptions/Token

image-20251203005353904

这个Token会通过界面的Add webhook registration按钮保存到数据库

WebhookClient是模拟第三方服务订阅eshop的集成事件,WebhooksClient通过DestUrlToken转发给WebhookClient

image-20251203005620401

17.2配置认证授权

appsettings.json中配置:

  • IdentityUrl:认证服务器地址
  • CallBackUrl:注销成功毁掉地址

image-20251203005817646

WebhookClient使用的是CookieOpenId Connect俩种认证方案

ClientIdClientSecretResponseType要和认证服务的配置一致

RequireHttpsMetadata:保证认证服务是https协议运行

image-20251203010344404

在认证服务(Identity.API)中配置WebhookClient的认证授权方案,以下是比较重要的配置

  • ClientId:客户端唯一标识
  • AllowedGrantTypes:授权方式,这里使用的是授权码模式
  • RequireConsent:显示授权界面,方便理解认证授权完整流程
  • RedirectUris:登录成功回调地址,WebApp的地址
  • PostLogoutRedirectUris:注销后的回调地址,WebApp的地址
  • AllowedScopesWebApp允许访问的范围

image-20251203010316974

WebhookClient配置了Cookie的命名

image-20251203010629810

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

image-20251203010815295

17.3配置Webhooks.API地址

配置Webhooks.API的实际地址

image-20251203011227407

WebhooksClient使用的是HttpClient

image-20251203011321770

WebhooksClient实现了俩个方法:

  • AddWebHookAsync:添加第三方Webhook客户端
  • LoadWebhooks:加载用户订阅的所有Webhook客户端

image-20251203011307267

📌 创作不易,感谢支持!

每一篇内容都凝聚了心血与热情,如果我的内容对您有帮助,欢迎请我喝杯咖啡☕,您的支持是我持续分享的最大动力!

💬 加入交流群(QQ群):576434538

微信打赏

posted @ 2025-12-06 00:33  peng_boke  阅读(4)  评论(0)    收藏  举报