一文理清 Blazor Identity 鉴权验证

摘要

在现代Web应用程序中,身份认证与授权是确保应用安全性和用户数据保护的关键环节。Blazor作为基于C#和.NET的前端框架,提供了丰富的身份认证与授权机制。本文将深入解析Blazor的身份认证框架的构成,比较不同渲染模式下鉴权逻辑的异同,并通过具体案例演示如何在Blazor Server和Blazor WebAssembly中实现身份认证。通过本文的学习,读者将能够更好地理解并应用Blazor中的Identity,以构建安全可靠的Web应用程序。

鉴权框架的构成

Blazor的身份认证框架主要由以下三个核心部分组成:

基架: AuthenticationMiddleware (Microsoft.AspNetCore.Authentication)

AuthenticationMiddlewareASP.NET Core中用于处理身份认证的中间件组件。它位于请求处理管道中,负责验证用户的身份并构建ClaimsPrincipal对象,将其附加到HttpContext.User属性中。所有后续的中间件和请求处理程序都可以访问该用户对象,从而了解当前请求的身份信息。

在Blazor应用程序中,AuthenticationMiddleware的作用是拦截HTTP请求,检查请求中是否包含有效的认证凭据(例如CookieJWT等)。如果凭据有效,它将解析并构建用户的身份信息;如果无效,则将用户视为未认证状态。

#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ccc;}#graph-div .cluster-label text{fill:#F9FFFE;}#graph-div .cluster-label span{color:#F9FFFE;}#graph-div .cluster-label span p{background-color:transparent;}#graph-div .label text,#graph-div span{fill:#ccc;color:#ccc;}#graph-div .node rect,#graph-div .node circle,#graph-div .node ellipse,#graph-div .node polygon,#graph-div .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#graph-div .rough-node .label text,#graph-div .node .label text,#graph-div .image-shape .label,#graph-div .icon-shape .label{text-anchor:middle;}#graph-div .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#graph-div .rough-node .label,#graph-div .node .label,#graph-div .image-shape .label,#graph-div .icon-shape .label{text-align:center;}#graph-div .node.clickable{cursor:pointer;}#graph-div .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#graph-div .arrowheadPath{fill:lightgrey;}#graph-div .edgePath .path{stroke:lightgrey;stroke-width:2.0px;}#graph-div .flowchart-link{stroke:lightgrey;fill:none;}#graph-div .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#graph-div .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#graph-div .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#graph-div .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#graph-div .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#graph-div .cluster text{fill:#F9FFFE;}#graph-div .cluster span{color:#F9FFFE;}#graph-div div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#graph-div .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#graph-div rect.text{fill:none;stroke-width:0;}#graph-div .icon-shape,#graph-div .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#graph-div .icon-shape p,#graph-div .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#graph-div .icon-shape rect,#graph-div .image-shape rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}

Cookie认证

JWT认证

其他方案

成功

失败

通过

不通过

HTTP请求

是否需要认证

直接访问资源

Authentication中间件

检查认证方案

解析Cookie

解析Authorization Header

...

验证Cookie有效性

验证Token签名

验证相应凭证

验证结果

创建ClaimsPrincipal

返回401/403

设置HttpContext.User

是否需要授权

Authorization中间件

访问资源

检查授权策略

访问资源

返回403

引用:

后端鉴权逻辑服务: IdentityCore (Microsoft.AspNetCore.Identity)

IdentityCoreASP.NET Core提供的完整的身份管理框架。它为开发者提供了处理用户注册、登录、角色管理、密码重置等功能的 APIs 和服务。IdentityCore高度可定制,可以使用不同的数据存储方式(如Entity Framework CoreMongoDB等)和密码哈希算法。

Blazor Server模式下,IdentityCore通常与AuthenticationMiddleware结合使用。后端服务器负责处理所有与身份相关的逻辑,包括验证用户凭据、管理用户数据和生成身份认证令牌等。

核心架构图

#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#graph-div g.classGroup text .title{font-weight:bolder;}#graph-div .nodeLabel,#graph-div .edgeLabel{color:#e0dfdf;}#graph-div .edgeLabel .label rect{fill:#1f2020;}#graph-div .label text{fill:#e0dfdf;}#graph-div .labelBkg{background:#1f2020;}#graph-div .edgeLabel .label span{background:#1f2020;}#graph-div .classTitle{font-weight:bolder;}#graph-div .node rect,#graph-div .node circle,#graph-div .node ellipse,#graph-div .node polygon,#graph-div .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#graph-div .divider{stroke:#ccc;stroke-width:1;}#graph-div g.clickable{cursor:pointer;}#graph-div g.classGroup rect{fill:#1f2020;stroke:#ccc;}#graph-div g.classGroup line{stroke:#ccc;stroke-width:1;}#graph-div .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#graph-div .classLabel .label{fill:#ccc;font-size:10px;}#graph-div .relation{stroke:lightgrey;stroke-width:1;fill:none;}#graph-div .dashed-line{stroke-dasharray:3;}#graph-div .dotted-line{stroke-dasharray:1 2;}#graph-div #compositionStart,#graph-div .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #compositionEnd,#graph-div .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #dependencyStart,#graph-div .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #dependencyStart,#graph-div .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #extensionStart,#graph-div .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #extensionEnd,#graph-div .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #aggregationStart,#graph-div .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #aggregationEnd,#graph-div .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #lollipopStart,#graph-div .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #lollipopEnd,#graph-div .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#graph-div .edgeTerminals{font-size:11px;line-height:initial;}#graph-div .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}

IdentityUser

+string Id

+string UserName

+string Email

+string PasswordHash

+string SecurityStamp

+bool EmailConfirmed

+bool TwoFactorEnabled

IdentityRole

+string Id

+string Name

+string NormalizedName

UserManager

+CreateAsync()

+FindByIdAsync()

+AddToRoleAsync()

+CheckPasswordAsync()

+GenerateEmailConfirmationTokenAsync()

SignInManager

+PasswordSignInAsync()

+SignInAsync()

+SignOutAsync()

+TwoFactorAuthenticatorSignInAsync()

RoleManager

+CreateAsync()

+FindByIdAsync()

+AddClaimAsync()

IdentityDbContext

+DbSet<IdentityUser> Users

+DbSet<IdentityRole> Roles

+DbSet<IdentityUserClaim> UserClaims

+DbSet<IdentityRoleClaim> RoleClaims

UserStore

+CreateAsync()

+UpdateAsync()

+FindByIdAsync()

+AddToRoleAsync()

RoleStore

+CreateAsync()

+UpdateAsync()

+FindByIdAsync()

IdentityOptions

+PasswordOptions

+LockoutOptions

+UserOptions

+SignInOptions

IdentityBuilder

+AddEntityFrameworkStores()

+AddDefaultTokenProviders()

+AddDefaultUI()

配置与启动流程 (图中的配置函数非常原始,案例中会使用较新的简化函数,不过最终都是调用它们)

OptionsEFIdentityServicesStartupOptionsEFIdentityServicesStartup#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .actor{stroke:#ccc;fill:#1f2020;}#graph-div text.actor>tspan{fill:lightgrey;stroke:none;}#graph-div .actor-line{stroke:#ccc;}#graph-div .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:lightgrey;}#graph-div .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:lightgrey;}#graph-div #arrowhead path{fill:lightgrey;stroke:lightgrey;}#graph-div .sequenceNumber{fill:black;}#graph-div #sequencenumber{fill:lightgrey;}#graph-div #crosshead path{fill:lightgrey;stroke:lightgrey;}#graph-div .messageText{fill:lightgrey;stroke:none;}#graph-div .labelBox{stroke:#ccc;fill:#1f2020;}#graph-div .labelText,#graph-div .labelText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopText,#graph-div .loopText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#ccc;fill:#ccc;}#graph-div .note{stroke:hsl(180, 0%, 18.3529411765%);fill:hsl(180, 1.5873015873%, 28.3529411765%);}#graph-div .noteText,#graph-div .noteText>tspan{fill:rgb(183.8476190475, 181.5523809523, 181.5523809523);stroke:none;}#graph-div .activation0{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation1{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation2{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .actorPopupMenu{position:absolute;}#graph-div .actorPopupMenuPanel{position:absolute;fill:#1f2020;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#graph-div .actor-man line{stroke:#ccc;fill:#1f2020;}#graph-div .actor-man circle,#graph-div line{stroke:#ccc;fill:#1f2020;stroke-width:2px;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}服务配置阶段配置密码规则锁定策略用户选项等中间件配置ConfigureServices()AddIdentity<TUser, TRole>()注册核心服务AddEntityFrameworkStores()Configure<IdentityOptions>()UseAuthentication()UseAuthorization()配置认证中间件配置授权中间件

用户注册与认证流程

EmailDBStoreSignInManagerUserManagerControllerClientEmailDBStoreSignInManagerUserManagerControllerClient#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .actor{stroke:#ccc;fill:#1f2020;}#graph-div text.actor>tspan{fill:lightgrey;stroke:none;}#graph-div .actor-line{stroke:#ccc;}#graph-div .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:lightgrey;}#graph-div .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:lightgrey;}#graph-div #arrowhead path{fill:lightgrey;stroke:lightgrey;}#graph-div .sequenceNumber{fill:black;}#graph-div #sequencenumber{fill:lightgrey;}#graph-div #crosshead path{fill:lightgrey;stroke:lightgrey;}#graph-div .messageText{fill:lightgrey;stroke:none;}#graph-div .labelBox{stroke:#ccc;fill:#1f2020;}#graph-div .labelText,#graph-div .labelText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopText,#graph-div .loopText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#ccc;fill:#ccc;}#graph-div .note{stroke:hsl(180, 0%, 18.3529411765%);fill:hsl(180, 1.5873015873%, 28.3529411765%);}#graph-div .noteText,#graph-div .noteText>tspan{fill:rgb(183.8476190475, 181.5523809523, 181.5523809523);stroke:none;}#graph-div .activation0{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation1{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation2{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .actorPopupMenu{position:absolute;}#graph-div .actorPopupMenuPanel{position:absolute;fill:#1f2020;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#graph-div .actor-man line{stroke:#ccc;fill:#1f2020;}#graph-div .actor-man circle,#graph-div line{stroke:#ccc;fill:#1f2020;stroke-width:2px;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}注册流程alt[需要邮箱验证]登录流程alt[启用2FA]注册请求CreateAsync(user, password)验证密码规则生成SecurityStampHash密码CreateAsync(user)保存用户GenerateEmailConfirmationTokenAsync发送确认邮件登录请求PasswordSignInAsyncFindByNameAsyncFindByNameAsync查询用户CheckPasswordAsync要求2FA验证提供2FA码TwoFactorSignInAsync创建身份票据设置认证Cookie

授权与角色管理流程(更侧重于使用AuthorizationMiddleware)

DBStoreRoleManagerUserManagerControllerClientDBStoreRoleManagerUserManagerControllerClient#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .actor{stroke:#ccc;fill:#1f2020;}#graph-div text.actor>tspan{fill:lightgrey;stroke:none;}#graph-div .actor-line{stroke:#ccc;}#graph-div .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:lightgrey;}#graph-div .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:lightgrey;}#graph-div #arrowhead path{fill:lightgrey;stroke:lightgrey;}#graph-div .sequenceNumber{fill:black;}#graph-div #sequencenumber{fill:lightgrey;}#graph-div #crosshead path{fill:lightgrey;stroke:lightgrey;}#graph-div .messageText{fill:lightgrey;stroke:none;}#graph-div .labelBox{stroke:#ccc;fill:#1f2020;}#graph-div .labelText,#graph-div .labelText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopText,#graph-div .loopText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#ccc;fill:#ccc;}#graph-div .note{stroke:hsl(180, 0%, 18.3529411765%);fill:hsl(180, 1.5873015873%, 28.3529411765%);}#graph-div .noteText,#graph-div .noteText>tspan{fill:rgb(183.8476190475, 181.5523809523, 181.5523809523);stroke:none;}#graph-div .activation0{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation1{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation2{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .actorPopupMenu{position:absolute;}#graph-div .actorPopupMenuPanel{position:absolute;fill:#1f2020;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#graph-div .actor-man line{stroke:#ccc;fill:#1f2020;}#graph-div .actor-man circle,#graph-div line{stroke:#ccc;fill:#1f2020;stroke-width:2px;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}角色管理授权验证alt[基于角色][基于Claims]创建角色请求CreateAsync(role)CreateAsync(role)保存角色分配角色请求AddToRoleAsync(user, role)AddToRoleAsync更新用户角色关系请求受保护资源[Authorize(Roles = "Admin")]IsInRoleAsyncIsInRoleAsync查询角色关系GetClaimsAsyncGetClaimsAsync查询Claims

扩展点和自定义实现

#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div g.classGroup text{fill:#ccc;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#graph-div g.classGroup text .title{font-weight:bolder;}#graph-div .nodeLabel,#graph-div .edgeLabel{color:#e0dfdf;}#graph-div .edgeLabel .label rect{fill:#1f2020;}#graph-div .label text{fill:#e0dfdf;}#graph-div .labelBkg{background:#1f2020;}#graph-div .edgeLabel .label span{background:#1f2020;}#graph-div .classTitle{font-weight:bolder;}#graph-div .node rect,#graph-div .node circle,#graph-div .node ellipse,#graph-div .node polygon,#graph-div .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#graph-div .divider{stroke:#ccc;stroke-width:1;}#graph-div g.clickable{cursor:pointer;}#graph-div g.classGroup rect{fill:#1f2020;stroke:#ccc;}#graph-div g.classGroup line{stroke:#ccc;stroke-width:1;}#graph-div .classLabel .box{stroke:none;stroke-width:0;fill:#1f2020;opacity:0.5;}#graph-div .classLabel .label{fill:#ccc;font-size:10px;}#graph-div .relation{stroke:lightgrey;stroke-width:1;fill:none;}#graph-div .dashed-line{stroke-dasharray:3;}#graph-div .dotted-line{stroke-dasharray:1 2;}#graph-div #compositionStart,#graph-div .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #compositionEnd,#graph-div .composition{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #dependencyStart,#graph-div .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #dependencyStart,#graph-div .dependency{fill:lightgrey!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #extensionStart,#graph-div .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #extensionEnd,#graph-div .extension{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #aggregationStart,#graph-div .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #aggregationEnd,#graph-div .aggregation{fill:transparent!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #lollipopStart,#graph-div .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#graph-div #lollipopEnd,#graph-div .lollipop{fill:#1f2020!important;stroke:lightgrey!important;stroke-width:1;}#graph-div .edgeTerminals{font-size:11px;line-height:initial;}#graph-div .classTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}

«interface»

IUserStore

+CreateAsync()

+UpdateAsync()

+DeleteAsync()

+FindByIdAsync()

«interface»

IUserPasswordStore

+SetPasswordHashAsync()

+GetPasswordHashAsync()

«interface»

IUserRoleStore

+AddToRoleAsync()

+RemoveFromRoleAsync()

+GetRolesAsync()

«interface»

IUserClaimStore

+AddClaimsAsync()

+RemoveClaimsAsync()

+GetClaimsAsync()

CustomUserStore

-IRepository repository

+CreateAsync()

+UpdateAsync()

+FindByIdAsync()

«interface»

IPasswordHasher

+HashPassword()

+VerifyHashedPassword()

«interface»

IPasswordValidator

+ValidateAsync()

«interface»

IUserValidator

+ValidateAsync()

主要特点:

模块化设计:核心身份模型、用户管理服务、存储抽象层、验证器接口

灵活的扩展性:自定义用户模型、自定义存储实现、可配置的选项、验证器扩展

完整的认证流程:用户注册、密码验证、双因素认证、外部登录

丰富的授权机制:基于角色、基于Claims、基于策略、动态授权

安全特性:密码哈希、账户锁定、安全戳验证、令牌管理

引用:

前端鉴权逻辑服务: AuthenticationStateProvider (Microsoft.AspNetCore.Components.Authorization)

AuthenticationStateProviderBlazor中用于提供当前用户身份状态的抽象类。它的主要作用是向Blazor组件提供身份认证状态(AuthenticationState),以便组件能够根据用户的身份进行相应的显示和操作。

Blazor应用程序中,AuthenticationStateProvider的具体实现方式取决于应用的渲染模式和身份认证方案。对于Blazor Server,这个提供程序可以直接从服务器的HttpContext.User获取身份信息;对于Blazor WebAssembly,由于代码在客户端运行,需要通过其他方式(如调用后端API或解析令牌)获取用户身份。

状态存储Identity服务自定义AuthProviderAuthenticationStateProviderCascadingAuthenticationStateBlazor组件状态存储Identity服务自定义AuthProviderAuthenticationStateProviderCascadingAuthenticationStateBlazor组件#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .actor{stroke:#ccc;fill:#1f2020;}#graph-div text.actor>tspan{fill:lightgrey;stroke:none;}#graph-div .actor-line{stroke:#ccc;}#graph-div .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:lightgrey;}#graph-div .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:lightgrey;}#graph-div #arrowhead path{fill:lightgrey;stroke:lightgrey;}#graph-div .sequenceNumber{fill:black;}#graph-div #sequencenumber{fill:lightgrey;}#graph-div #crosshead path{fill:lightgrey;stroke:lightgrey;}#graph-div .messageText{fill:lightgrey;stroke:none;}#graph-div .labelBox{stroke:#ccc;fill:#1f2020;}#graph-div .labelText,#graph-div .labelText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopText,#graph-div .loopText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#ccc;fill:#ccc;}#graph-div .note{stroke:hsl(180, 0%, 18.3529411765%);fill:hsl(180, 1.5873015873%, 28.3529411765%);}#graph-div .noteText,#graph-div .noteText>tspan{fill:rgb(183.8476190475, 181.5523809523, 181.5523809523);stroke:none;}#graph-div .activation0{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation1{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation2{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .actorPopupMenu{position:absolute;}#graph-div .actorPopupMenuPanel{position:absolute;fill:#1f2020;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#graph-div .actor-man line{stroke:#ccc;fill:#1f2020;}#graph-div .actor-man circle,#graph-div line{stroke:#ccc;fill:#1f2020;stroke-width:2px;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}初始化阶段alt[自定义认证逻辑][默认实现]状态访问alt[通过注入访问][通过级联参数访问]状态变更Server模式授权验证alt[未授权][已授权]组件初始化GetAuthenticationStateAsync()委托处理获取用户信息返回用户数据构建AuthenticationState缓存状态获取当前用户返回ClaimsPrincipalnew AuthenticationState()返回AuthenticationState通过级联参数提供状态[CascadingParameter]Task<AuthenticationState>@inject AuthenticationStateProvider返回Provider实例GetAuthenticationStateAsync()await AuthenticationState获取User信息处理授权逻辑用户状态变更更新AuthenticationState更新缓存状态NotifyAuthenticationStateChanged通知状态变更触发重新渲染@attribute [Authorize]GetAuthenticationStateAsync()获取缓存状态返回状态重定向到登录页允许访问渲染受保护内容

主要特点:

状态管理:缓存认证状态、状态变更通知、状态同步更新

组件集成:CascadingAuthenticationState提供状态共享、AuthorizeView用于条件渲染、Authorize特性支持

自定义能力:自定义认证逻辑、自定义授权策略、状态持久化

安全特性:状态验证、角色授权、Claims基础授权

性能优化:状态缓存、按需刷新、组件重渲染控制

引用:

不同渲染模式中鉴权逻辑的异同

相同点

组件在获取用户信息与鉴权状态时,都统一使用CascadingAuthenticationState

无论是Blazor Server还是Blazor WebAssembly,组件在需要访问用户身份信息时,都通过CascadingAuthenticationState提供的AuthenticationState。这使得组件能够以一致的方式获取用户的身份认证状态,无需关注底层的实现细节。

使用级联参数获取用户信息

@code {
[CascadingParameter] private Task<AuthenticationState> stateTask { get; set; } private ClaimsPrincipal user; protected override async Task OnInitializedAsync()
{
var authState = await stateTask;
user = authState.User;
}
}

使用服务获取用户信息

@inject AuthenticationStateProvider AuthenticationStateProvider

@code {
private ClaimsPrincipal user; protected override async Task OnInitializedAsync()
{
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
user = authState.User;
}
}

引用:

不同点

Server模式 实现RevalidatingServerAuthenticationStateProvider(基于AuthenticationStateProvider) 重点在验证ClaimPrincipal中的安全戳

Blazor Server模式下,应用程序在服务器上运行,客户端通过SignalR持久连接与服务器通信。默认情况下,Blazor Server使用ServerAuthenticationStateProvider,它直接从HttpContext.User获取用户的身份信息。

为了增强安全性,Blazor Server提供了RevalidatingServerAuthenticationStateProvider。它继承自ServerAuthenticationStateProvider,能够在指定的时间间隔内重新验证用户的身份状态。这主要通过检查ClaimsPrincipal中的安全戳(Security Stamp)来实现。当用户的安全戳发生变化(如密码更改、账户被禁用等),该提供程序会检测到并更新用户的身份状态,要求用户重新登录。

HttpContext认证状态存储Blazor组件RevalidatingAuthProviderAuthenticationStateProviderBlazor CircuitSignalR HubCookie中间件数据库User StoreUserManagerSignInManagerIdentity服务认证中间件服务器浏览器用户HttpContext认证状态存储Blazor组件RevalidatingAuthProviderAuthenticationStateProviderBlazor CircuitSignalR HubCookie中间件数据库User StoreUserManagerSignInManagerIdentity服务认证中间件服务器浏览器用户#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .actor{stroke:#ccc;fill:#1f2020;}#graph-div text.actor>tspan{fill:lightgrey;stroke:none;}#graph-div .actor-line{stroke:#ccc;}#graph-div .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:lightgrey;}#graph-div .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:lightgrey;}#graph-div #arrowhead path{fill:lightgrey;stroke:lightgrey;}#graph-div .sequenceNumber{fill:black;}#graph-div #sequencenumber{fill:lightgrey;}#graph-div #crosshead path{fill:lightgrey;stroke:lightgrey;}#graph-div .messageText{fill:lightgrey;stroke:none;}#graph-div .labelBox{stroke:#ccc;fill:#1f2020;}#graph-div .labelText,#graph-div .labelText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopText,#graph-div .loopText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#ccc;fill:#ccc;}#graph-div .note{stroke:hsl(180, 0%, 18.3529411765%);fill:hsl(180, 1.5873015873%, 28.3529411765%);}#graph-div .noteText,#graph-div .noteText>tspan{fill:rgb(183.8476190475, 181.5523809523, 181.5523809523);stroke:none;}#graph-div .activation0{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation1{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation2{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .actorPopupMenu{position:absolute;}#graph-div .actorPopupMenuPanel{position:absolute;fill:#1f2020;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#graph-div .actor-man line{stroke:#ccc;fill:#1f2020;}#graph-div .actor-man circle,#graph-div line{stroke:#ccc;fill:#1f2020;stroke-width:2px;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}SecurityStamp生成阶段Guid.NewGuid().ToString()如密码修改/角色变更等alt[用户注册][安全相关操作]登录阶段包含用户Claims和SecurityStampHttpContext可用new AuthenticationState(Principal)认证状态持久化验证当前用户状态opt[需要重新验证]alt[首次请求或状态过期]后台定期验证par[获取Claims中的SecurityStamp][获取数据库中的SecurityStamp]alt[SecurityStamp不匹配][SecurityStamp匹配]loop[每30分钟]注册请求CreateAsync生成新SecurityStamp保存用户信息存储SecurityStampUpdateSecurityStampAsync生成新SecurityStamp更新用户信息更新SecurityStamp登录请求SignInAsync验证凭据获取用户信息返回用户数据验证成功验证成功创建ClaimsPrincipal创建认证Cookie设置Cookie首次HTTP请求(带Cookie)返回初始HTML(App.razor)WebSocket连接请求(带Cookie)创建Circuit验证请求Cookie解析认证Cookie还原ClaimsPrincipal返回Principal创建AuthenticationState初始化认证状态存储认证状态GetAuthenticationStateAsync()检查状态是否需要重新验证返回验证时间ValidateAuthenticationStateAsync返回AuthenticationState用户交互处理请求无法访问HttpContext获取认证状态返回认证状态ValidateAuthenticationStateAsync从Claims获取SecurityStampGetSecurityStampAsync查询用户获取SecurityStamp返回当前SecurityStamp返回结果返回SecurityStamp更新认证状态NotifyAuthenticationStateChanged更新UI状态继续使用当前认证状态

引用:

Webassembly模式 实现AuthenticationStateProvider与HttpMessageHandler(可选) 重点在访问个人信息终结点(或从自包含令牌)解析ClaimPrincipal

Blazor WebAssembly模式下,应用程序在客户端浏览器中运行,没有直接访问服务器HttpContext的能力。因此,获取用户身份信息需要通过其他方式。例如,实现自定义的AuthenticationStateProvider,通过调用后端API(如用户信息终结点)获取用户信息,或者解析存储在客户端的JWT令牌来构建ClaimsPrincipal

此外,为了在客户端向受保护的API发送请求时自动附加身份认证令牌,或是在令牌过期后自动刷新令牌,可以配置HttpClient使用自定义的HttpMessageHandler。这样,可以在请求头中添加必要的身份认证信息(如Bearer Token)或拦截401响应并刷新令牌重试请求。

Token服务后端APIHttpClientHandlerAuthenticationProviderBlazor组件Blazor WebAssembly应用浏览器用户Token服务后端APIHttpClientHandlerAuthenticationProviderBlazor组件Blazor WebAssembly应用浏览器用户#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .actor{stroke:#ccc;fill:#1f2020;}#graph-div text.actor>tspan{fill:lightgrey;stroke:none;}#graph-div .actor-line{stroke:#ccc;}#graph-div .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:lightgrey;}#graph-div .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:lightgrey;}#graph-div #arrowhead path{fill:lightgrey;stroke:lightgrey;}#graph-div .sequenceNumber{fill:black;}#graph-div #sequencenumber{fill:lightgrey;}#graph-div #crosshead path{fill:lightgrey;stroke:lightgrey;}#graph-div .messageText{fill:lightgrey;stroke:none;}#graph-div .labelBox{stroke:#ccc;fill:#1f2020;}#graph-div .labelText,#graph-div .labelText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopText,#graph-div .loopText>tspan{fill:lightgrey;stroke:none;}#graph-div .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#ccc;fill:#ccc;}#graph-div .note{stroke:hsl(180, 0%, 18.3529411765%);fill:hsl(180, 1.5873015873%, 28.3529411765%);}#graph-div .noteText,#graph-div .noteText>tspan{fill:rgb(183.8476190475, 181.5523809523, 181.5523809523);stroke:none;}#graph-div .activation0{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation1{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .activation2{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:#ccc;}#graph-div .actorPopupMenu{position:absolute;}#graph-div .actorPopupMenuPanel{position:absolute;fill:#1f2020;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#graph-div .actor-man line{stroke:#ccc;fill:#1f2020;}#graph-div .actor-man circle,#graph-div line{stroke:#ccc;fill:#1f2020;stroke-width:2px;}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}alt[Token存在且未过-期][Token不存在或已-过期]alt[验证成功][验证失败]alt[刷新成功][刷新失败]alt[Token有效][Token无效或过期(返回401)]alt[刷新成功][刷新失败]alt[Token即将过期][Token仍然有效]loop[每固定时间间隔]打开应用URL加载Blazor WebAssembly应用初始化认证状态检查本地存储的Token使用现有Token设置为未认证状态返回认证状态渲染组件点击登录按钮调用Login方法发送登录请求(带用户凭据)验证用户凭据返回访问Token和刷新Token存储Token(如localStorage)更新认证状态为已认证通知认证状态已改变重新渲染受限页面返回错误信息通知登录失败GetAuthenticationStateAsync()使用Token请求用户信息(/userinfo)添加Authorization头返回用户信息返回AuthenticationState(包含用户信息)发起HTTP请求(携带API地址)拦截请求,添加Authorization头发送请求(带Token)返回请求数据返回数据返回401 Unauthorized通知Token无效尝试刷新Token发送刷新Token请求验证刷新Token返回新的访问Token和刷新Token返回新的Token更新存储的Token通知重试原请求重新发送原请求(带新Token)返回请求数据返回数据返回刷新失败信息通知刷新失败清除Token,更新为未认证状态通知认证状态已改变(未认证)重定向到登录页或显示登录按钮检查Token有效期刷新Token验证刷新Token返回新的Token更新Token更新存储的Token返回刷新失败信息通知刷新失败清除Token,更新为未认证状态通知认证状态已改变(未认证)重定向到登录页或显示登录按钮无需操作

引用:

眼见为实:通过案例实现两种渲染模式的鉴权

下面,我们将通过具体的案例,演示如何在Blazor Server和Blazor WebAssembly应用程序中实现身份认证。

Server + Cookie

注册服务

builder.Services.AddCascadingAuthenticationState();                                                         // 添加级联参数获取认证信息
builder.Services.AddScoped<AuthenticationStateProvider, IdentityRevalidatingAuthenticationStateProvider>(); // 注册自实现的StateProvider builder.Services.AddAuthentication(options =>
{
//在这里设定默认方案
options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
options.DefaultSignOutScheme = IdentityConstants.ExternalScheme;
}).AddIdentityCookies(options =>
{
}); builder.Services.AddIdentityCore<ApplicationUser>(options => //ApplicationUser继承自IdentityUser
{
//在这里设定鉴权配置,比如验证邮箱(这里不验证)、密码规则(这里设置最简规则)
options.SignIn.RequireConfirmedAccount = false;
options.Password.RequiredLength = 6;
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
})
.AddRoles<IdentityRole>() //需要自定义角色时,继承IdentityRole
.AddEntityFrameworkStores<ApplicationDbContext>() //ApplicationDbContext需要继承IdentityDbContext,其他ORM请自行搜索Store实现
.AddSignInManager() //使用Cookie时,推荐注册
.AddDefaultTokenProviders(); builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>(); var app = builder.Build(); app.UseAuthentication();
app.UseAuthorization();

其中,AddIdentityCookies方法的源代码如下:

    /// <summary>
/// Adds the cookie authentication needed for sign in manager.
/// </summary>
/// <param name="builder">The current <see cref="AuthenticationBuilder"/> instance.</param>
/// <param name="configureCookies">Action used to configure the cookies.</param>
/// <returns>The <see cref="IdentityCookiesBuilder"/> which can be used to configure the identity cookies.</returns>
public static IdentityCookiesBuilder AddIdentityCookies(this AuthenticationBuilder builder, Action<IdentityCookiesBuilder> configureCookies)
{
var cookieBuilder = new IdentityCookiesBuilder();
cookieBuilder.ApplicationCookie = builder.AddApplicationCookie();
cookieBuilder.ExternalCookie = builder.AddExternalCookie();
cookieBuilder.TwoFactorRememberMeCookie = builder.AddTwoFactorRememberMeCookie();
cookieBuilder.TwoFactorUserIdCookie = builder.AddTwoFactorUserIdCookie();
configureCookies?.Invoke(cookieBuilder);
return cookieBuilder;
}

IdentityNoOpEmailSender

    public sealed class IdentityNoOpEmailSender : IEmailSender<ApplicationUser>
{
private readonly IEmailSender emailSender = new NoOpEmailSender(); //案例不实现真正的邮件发送 public Task SendConfirmationLinkAsync(ApplicationUser user, string email, string confirmationLink) =>
emailSender.SendEmailAsync(email, "Confirm your email", $"Please confirm your account by <a href='{confirmationLink}'>clicking here</a>."); public Task SendPasswordResetLinkAsync(ApplicationUser user, string email, string resetLink) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password by <a href='{resetLink}'>clicking here</a>."); public Task SendPasswordResetCodeAsync(ApplicationUser user, string email, string resetCode) =>
emailSender.SendEmailAsync(email, "Reset your password", $"Please reset your password using the following code: {resetCode}");
}

IdentityRevalidatingAuthenticationStateProvider

public sealed class IdentityRevalidatingAuthenticationStateProvider(
ILoggerFactory loggerFactory,
IServiceScopeFactory scopeFactory,
IOptions<IdentityOptions> options)
: RevalidatingServerAuthenticationStateProvider(loggerFactory)
{
protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(30); protected override async Task<bool> ValidateAuthenticationStateAsync(
AuthenticationState authenticationState, CancellationToken cancellationToken)
{
// Get the user manager from a new scope to ensure it fetches fresh data
await using var scope = scopeFactory.CreateAsyncScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
return await ValidateSecurityStampAsync(userManager, authenticationState.User);
} private async Task<bool> ValidateSecurityStampAsync(UserManager<ApplicationUser> userManager, ClaimsPrincipal principal)
{
var user = await userManager.GetUserAsync(principal);
if (user is null)
{
return false;
}
else if (!userManager.SupportsUserSecurityStamp)
{
return true;
}
else
{
var principalStamp = principal.FindFirstValue(options.Value.ClaimsIdentity.SecurityStampClaimType);
var userStamp = await userManager.GetSecurityStampAsync(user);
return principalStamp == userStamp;
}
}
}

登录时,可以使用表单提交,也可以使用Ajax POST(制作动态网页时的首选),后端处理的逻辑代码相同,即都通过SignInManager实现发送Set-Cookie请求

表单处理:

    public async Task LoginUser()
{
// This doesn't count login failures towards account lockout
// To enable password failures to trigger account lockout, set lockoutOnFailure: true
var result = await SignInManager.PasswordSignInAsync(Input.Email, Input.Password, Input.RememberMe, lockoutOnFailure: false);
if (result.Succeeded)
{
RedirectManager.RedirectTo("/");
}
else if (result.RequiresTwoFactor)
{
RedirectManager.RedirectTo(
"Account/LoginWith2fa",
new() { ["returnUrl"] = ReturnUrl, ["rememberMe"] = Input.RememberMe });
}
else if (result.IsLockedOut)
{
RedirectManager.RedirectTo("Account/Lockout");
}
else
{
errorMessage = $@"账号或密码错误";
}
}

WebAPI:

app.MapPost("Login", async (CheckDto Input,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager) =>
{
var emailResult = await userManager.FindByEmailAsync(dto.username);
if (emailResult is null) return Results.BadRequest("账号未注册");
if (await userManager.CheckPasswordAsync(emailResult, dto.password))
{
await signInManager.SignInAsync(emailResult, false);
return Results.Ok();
}
return Results.BadRequest("密码错误"); //401不能返回错误信息,故使用400
});

WebAssembly + Cookie + UserInfo Endpoint

此方式是微软推荐的鉴权方式,相比于JWT安全性较高

注册服务(使用.NET 8以后新增的方法)

builder.Services.AddAuthorization();
builder.Services.AddIdentityApiEndpoints<ApplicationUser>()
.AddEntityFrameworkStores<ApplicationDbContext>(); var app = builder.Build(); app.UseAuthentication();
app.UseAuthorization(); app.MapIdentityApi<ApplicationUser>();

源码解读:

    /// <summary>
/// Adds a set of common identity services to the application to support <see cref="IdentityApiEndpointRouteBuilderExtensions.MapIdentityApi{TUser}(IEndpointRouteBuilder)"/>
/// and configures authentication to support identity bearer tokens and cookies.
/// </summary>
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
/// <param name="configure">Configures the <see cref="IdentityOptions"/>.</param>
/// <returns>The <see cref="IdentityBuilder"/>.</returns>
public static IdentityBuilder AddIdentityApiEndpoints<TUser>(this IServiceCollection services, Action<IdentityOptions> configure)
where TUser : class, new()
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configure); services
.AddAuthentication(IdentityConstants.BearerAndApplicationScheme)
.AddScheme<AuthenticationSchemeOptions, CompositeIdentityHandler>(IdentityConstants.BearerAndApplicationScheme, null, compositeOptions =>
{
compositeOptions.ForwardDefault = IdentityConstants.BearerScheme;
compositeOptions.ForwardAuthenticate = IdentityConstants.BearerAndApplicationScheme;
})
.AddBearerToken(IdentityConstants.BearerScheme)
.AddIdentityCookies(); return services.AddIdentityCore<TUser>(configure)
.AddApiEndpoints();
}
/// <summary>
/// Add endpoints for registering, logging in, and logging out using ASP.NET Core Identity.
/// </summary>
/// <typeparam name="TUser">The type describing the user. This should match the generic parameter in <see cref="UserManager{TUser}"/>.</typeparam>
/// <param name="endpoints">
/// The <see cref="IEndpointRouteBuilder"/> to add the identity endpoints to.
/// Call <see cref="EndpointRouteBuilderExtensions.MapGroup(IEndpointRouteBuilder, string)"/> to add a prefix to all the endpoints.
/// </param>
/// <returns>An <see cref="IEndpointConventionBuilder"/> to further customize the added endpoints.</returns>
public static IEndpointConventionBuilder MapIdentityApi<TUser>(this IEndpointRouteBuilder endpoints)
where TUser : class, new()
{
ArgumentNullException.ThrowIfNull(endpoints); var timeProvider = endpoints.ServiceProvider.GetRequiredService<TimeProvider>();
var bearerTokenOptions = endpoints.ServiceProvider.GetRequiredService<IOptionsMonitor<BearerTokenOptions>>();
var emailSender = endpoints.ServiceProvider.GetRequiredService<IEmailSender<TUser>>();
var linkGenerator = endpoints.ServiceProvider.GetRequiredService<LinkGenerator>(); // We'll figure out a unique endpoint name based on the final route pattern during endpoint generation.
string? confirmEmailEndpointName = null; var routeGroup = endpoints.MapGroup(""); // NOTE: We cannot inject UserManager<TUser> directly because the TUser generic parameter is currently unsupported by RDG.
// https://github.com/dotnet/aspnetcore/issues/47338
routeGroup.MapPost("/register", async Task<Results<Ok, ValidationProblem>>
([FromBody] RegisterRequest registration, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>(); if (!userManager.SupportsUserEmail)
{
throw new NotSupportedException($"{nameof(MapIdentityApi)} requires a user store with email support.");
} var userStore = sp.GetRequiredService<IUserStore<TUser>>();
var emailStore = (IUserEmailStore<TUser>)userStore;
var email = registration.Email; if (string.IsNullOrEmpty(email) || !_emailAddressAttribute.IsValid(email))
{
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(email)));
} var user = new TUser();
await userStore.SetUserNameAsync(user, email, CancellationToken.None);
await emailStore.SetEmailAsync(user, email, CancellationToken.None);
var result = await userManager.CreateAsync(user, registration.Password); if (!result.Succeeded)
{
return CreateValidationProblem(result);
} await SendConfirmationEmailAsync(user, userManager, context, email);
return TypedResults.Ok();
}); routeGroup.MapPost("/login", async Task<Results<Ok<AccessTokenResponse>, EmptyHttpResult, ProblemHttpResult>>
([FromBody] LoginRequest login, [FromQuery] bool? useCookies, [FromQuery] bool? useSessionCookies, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>(); var useCookieScheme = (useCookies == true) || (useSessionCookies == true);
var isPersistent = (useCookies == true) && (useSessionCookies != true);
signInManager.AuthenticationScheme = useCookieScheme ? IdentityConstants.ApplicationScheme : IdentityConstants.BearerScheme; var result = await signInManager.PasswordSignInAsync(login.Email, login.Password, isPersistent, lockoutOnFailure: true); if (result.RequiresTwoFactor)
{
if (!string.IsNullOrEmpty(login.TwoFactorCode))
{
result = await signInManager.TwoFactorAuthenticatorSignInAsync(login.TwoFactorCode, isPersistent, rememberClient: isPersistent);
}
else if (!string.IsNullOrEmpty(login.TwoFactorRecoveryCode))
{
result = await signInManager.TwoFactorRecoveryCodeSignInAsync(login.TwoFactorRecoveryCode);
}
} if (!result.Succeeded)
{
return TypedResults.Problem(result.ToString(), statusCode: StatusCodes.Status401Unauthorized);
} // The signInManager already produced the needed response in the form of a cookie or bearer token.
return TypedResults.Empty;
}); routeGroup.MapPost("/refresh", async Task<Results<Ok<AccessTokenResponse>, UnauthorizedHttpResult, SignInHttpResult, ChallengeHttpResult>>
([FromBody] RefreshRequest refreshRequest, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
var refreshTokenProtector = bearerTokenOptions.Get(IdentityConstants.BearerScheme).RefreshTokenProtector;
var refreshTicket = refreshTokenProtector.Unprotect(refreshRequest.RefreshToken); // Reject the /refresh attempt with a 401 if the token expired or the security stamp validation fails
if (refreshTicket?.Properties?.ExpiresUtc is not { } expiresUtc ||
timeProvider.GetUtcNow() >= expiresUtc ||
await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) is not TUser user) {
return TypedResults.Challenge();
} var newPrincipal = await signInManager.CreateUserPrincipalAsync(user);
return TypedResults.SignIn(newPrincipal, authenticationScheme: IdentityConstants.BearerScheme);
}); routeGroup.MapGet("/confirmEmail", async Task<Results<ContentHttpResult, UnauthorizedHttpResult>>
([FromQuery] string userId, [FromQuery] string code, [FromQuery] string? changedEmail, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.FindByIdAsync(userId) is not { } user)
{
// We could respond with a 404 instead of a 401 like Identity UI, but that feels like unnecessary information.
return TypedResults.Unauthorized();
} try
{
code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code));
}
catch (FormatException)
{
return TypedResults.Unauthorized();
} IdentityResult result; if (string.IsNullOrEmpty(changedEmail))
{
result = await userManager.ConfirmEmailAsync(user, code);
}
else
{
// As with Identity UI, email and user name are one and the same. So when we update the email,
// we need to update the user name.
result = await userManager.ChangeEmailAsync(user, changedEmail, code); if (result.Succeeded)
{
result = await userManager.SetUserNameAsync(user, changedEmail);
}
} if (!result.Succeeded)
{
return TypedResults.Unauthorized();
} return TypedResults.Text("Thank you for confirming your email.");
})
.Add(endpointBuilder =>
{
var finalPattern = ((RouteEndpointBuilder)endpointBuilder).RoutePattern.RawText;
confirmEmailEndpointName = $"{nameof(MapIdentityApi)}-{finalPattern}";
endpointBuilder.Metadata.Add(new EndpointNameMetadata(confirmEmailEndpointName));
}); routeGroup.MapPost("/resendConfirmationEmail", async Task<Ok>
([FromBody] ResendConfirmationEmailRequest resendRequest, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.FindByEmailAsync(resendRequest.Email) is not { } user)
{
return TypedResults.Ok();
} await SendConfirmationEmailAsync(user, userManager, context, resendRequest.Email);
return TypedResults.Ok();
}); routeGroup.MapPost("/forgotPassword", async Task<Results<Ok, ValidationProblem>>
([FromBody] ForgotPasswordRequest resetRequest, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is not null && await userManager.IsEmailConfirmedAsync(user))
{
var code = await userManager.GeneratePasswordResetTokenAsync(user);
code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); await emailSender.SendPasswordResetCodeAsync(user, resetRequest.Email, HtmlEncoder.Default.Encode(code));
} // Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have
// returned a 400 for an invalid code given a valid user email.
return TypedResults.Ok();
}); routeGroup.MapPost("/resetPassword", async Task<Results<Ok, ValidationProblem>>
([FromBody] ResetPasswordRequest resetRequest, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>(); var user = await userManager.FindByEmailAsync(resetRequest.Email); if (user is null || !(await userManager.IsEmailConfirmedAsync(user)))
{
// Don't reveal that the user does not exist or is not confirmed, so don't return a 200 if we would have
// returned a 400 for an invalid code given a valid user email.
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken()));
} IdentityResult result;
try
{
var code = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(resetRequest.ResetCode));
result = await userManager.ResetPasswordAsync(user, code, resetRequest.NewPassword);
}
catch (FormatException)
{
result = IdentityResult.Failed(userManager.ErrorDescriber.InvalidToken());
} if (!result.Succeeded)
{
return CreateValidationProblem(result);
} return TypedResults.Ok();
}); var accountGroup = routeGroup.MapGroup("/manage").RequireAuthorization(); accountGroup.MapPost("/2fa", async Task<Results<Ok<TwoFactorResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] TwoFactorRequest tfaRequest, [FromServices] IServiceProvider sp) =>
{
var signInManager = sp.GetRequiredService<SignInManager<TUser>>();
var userManager = signInManager.UserManager;
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
} if (tfaRequest.Enable == true)
{
if (tfaRequest.ResetSharedKey)
{
return CreateValidationProblem("CannotResetSharedKeyAndEnable",
"Resetting the 2fa shared key must disable 2fa until a 2fa token based on the new shared key is validated.");
} if (string.IsNullOrEmpty(tfaRequest.TwoFactorCode))
{
return CreateValidationProblem("RequiresTwoFactor",
"No 2fa token was provided by the request. A valid 2fa token is required to enable 2fa.");
} if (!await userManager.VerifyTwoFactorTokenAsync(user, userManager.Options.Tokens.AuthenticatorTokenProvider, tfaRequest.TwoFactorCode))
{
return CreateValidationProblem("InvalidTwoFactorCode",
"The 2fa token provided by the request was invalid. A valid 2fa token is required to enable 2fa.");
} await userManager.SetTwoFactorEnabledAsync(user, true);
}
else if (tfaRequest.Enable == false || tfaRequest.ResetSharedKey)
{
await userManager.SetTwoFactorEnabledAsync(user, false);
} if (tfaRequest.ResetSharedKey)
{
await userManager.ResetAuthenticatorKeyAsync(user);
} string[]? recoveryCodes = null;
if (tfaRequest.ResetRecoveryCodes || (tfaRequest.Enable == true && await userManager.CountRecoveryCodesAsync(user) == 0))
{
var recoveryCodesEnumerable = await userManager.GenerateNewTwoFactorRecoveryCodesAsync(user, 10);
recoveryCodes = recoveryCodesEnumerable?.ToArray();
} if (tfaRequest.ForgetMachine)
{
await signInManager.ForgetTwoFactorClientAsync();
} var key = await userManager.GetAuthenticatorKeyAsync(user);
if (string.IsNullOrEmpty(key))
{
await userManager.ResetAuthenticatorKeyAsync(user);
key = await userManager.GetAuthenticatorKeyAsync(user); if (string.IsNullOrEmpty(key))
{
throw new NotSupportedException("The user manager must produce an authenticator key after reset.");
}
} return TypedResults.Ok(new TwoFactorResponse
{
SharedKey = key,
RecoveryCodes = recoveryCodes,
RecoveryCodesLeft = recoveryCodes?.Length ?? await userManager.CountRecoveryCodesAsync(user),
IsTwoFactorEnabled = await userManager.GetTwoFactorEnabledAsync(user),
IsMachineRemembered = await signInManager.IsTwoFactorClientRememberedAsync(user),
});
}); accountGroup.MapGet("/info", async Task<Results<Ok<InfoResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
} return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
}); accountGroup.MapPost("/info", async Task<Results<Ok<InfoResponse>, ValidationProblem, NotFound>>
(ClaimsPrincipal claimsPrincipal, [FromBody] InfoRequest infoRequest, HttpContext context, [FromServices] IServiceProvider sp) =>
{
var userManager = sp.GetRequiredService<UserManager<TUser>>();
if (await userManager.GetUserAsync(claimsPrincipal) is not { } user)
{
return TypedResults.NotFound();
} if (!string.IsNullOrEmpty(infoRequest.NewEmail) && !_emailAddressAttribute.IsValid(infoRequest.NewEmail))
{
return CreateValidationProblem(IdentityResult.Failed(userManager.ErrorDescriber.InvalidEmail(infoRequest.NewEmail)));
} if (!string.IsNullOrEmpty(infoRequest.NewPassword))
{
if (string.IsNullOrEmpty(infoRequest.OldPassword))
{
return CreateValidationProblem("OldPasswordRequired",
"The old password is required to set a new password. If the old password is forgotten, use /resetPassword.");
} var changePasswordResult = await userManager.ChangePasswordAsync(user, infoRequest.OldPassword, infoRequest.NewPassword);
if (!changePasswordResult.Succeeded)
{
return CreateValidationProblem(changePasswordResult);
}
} if (!string.IsNullOrEmpty(infoRequest.NewEmail))
{
var email = await userManager.GetEmailAsync(user); if (email != infoRequest.NewEmail)
{
await SendConfirmationEmailAsync(user, userManager, context, infoRequest.NewEmail, isChange: true);
}
} return TypedResults.Ok(await CreateInfoResponseAsync(user, userManager));
});
}

可以看到,基架生成的info接口返回的用户信息非常有限,实际业务中需要自行写一个返回角色/Claims信息的userinfo接口(注入UserManager和RoleManager查询)

根据基架生成的API,编写前端代码:

注册服务:

// 注册LocalStorage服务
builder.Services.AddBlazoredLocalStorageAsSingleton();
// 注册鉴权服务
builder.Services.AddSingleton<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
// 注册通用客户端的鉴权拦截器(令牌过期重试)
builder.Services.AddSingleton<AuthenticationStateHandler>();
// 注册鉴权专用客户端
builder.Services.AddHttpClient("auth", client =>
{
client.BaseAddress = new Uri("API_URL");
});
// 注册业务通用客户端
builder.Services.AddHttpClient("backend", client =>
{
client.BaseAddress = new Uri("API_URL");
}).AddHttpMessageHandler<AuthenticationStateHandler>();
// 设为默认客户端
builder.Services.AddSingleton(sp => sp.GetRequiredService<IHttpClientFactory>().CreateClient("backend"));
// 注册鉴权基架
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();

AuthenticationStateHandler:

public class AuthenticationStateHandler(AuthenticationStateProvider stateProvider, NavigationManager navigationManager) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var response = await base.SendAsync(request, cancellationToken);
//如果令牌过期,刷新令牌并重试请求
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
var authState = await stateProvider.GetAuthenticationStateAsync();
if (authState.User.Identity?.IsAuthenticated ?? false)
{
if (await (stateProvider as CookieAuthenticationStateProvider).RefreshTokenAsync())
{
return await SendAsync(request, cancellationToken);
}
}
navigationManager.NavigateTo("/login");
}
return response;
}
}

CookieAuthenticationStateProvider:

public sealed class CookieAuthenticationStateProvider(IServiceProvider serviceProvider, IHttpClientFactory httpClientFactory, ILocalStorageService localStorage, ISyncLocalStorageService syncLocalStorage) : AuthenticationStateProvider
{
//token过期时间
private static TimeSpan UserCacheRefreshInterval = TimeSpan.FromHours(1);
//上次获取token时间
private static DateTimeOffset UserLastCheckTime = DateTimeOffset.FromUnixTimeSeconds(0);
//缓存用户状态
private ClaimsPrincipal CachedUser = new(new ClaimsIdentity());
//默认用户状态(未登录)
private static readonly Task<AuthenticationState> defaultUnanthenticatedTask = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())));
//刷新令牌
private string? refresh_token { get; set; } = syncLocalStorage.GetItem<string>("refresh_token");
//访问令牌
private string? access_token { get; set; } = syncLocalStorage.GetItem<string>("access_token");
//鉴权专用客户端示例
private HttpClient client = httpClientFactory.CreateClient("auth");
//解析令牌获取身份信息
private async Task ParseTokenAsync()
{
var response = await client.GetAsync("/info"); //推荐自己写userinfo接口代替
if (response.IsSuccessStatusCode)
{
var infoResponse = await response.Content.ReadFromJsonAsync<InfoResponse>();
if (infoResponse != null)
{
var claims = new List<Claim>
{
new Claim(ClaimTypes.Email, infoResponse.Email),
new Claim("IsEmailConfirmed", infoResponse.IsEmailConfirmed.ToString())
};
CachedUser = new ClaimsPrincipal(new ClaimsIdentity(claims, "Bearer"));
}
}
else
{
CachedUser = new ClaimsPrincipal(new ClaimsIdentity());
}
}
//设置客户端携带令牌
private void SetClientToken()
{
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token);
serviceProvider.GetRequiredService<HttpClient>().DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", access_token);
}
//处理令牌接口返回值
private async Task<bool> ParseResponseAsync(AccessTokenResponse token)
{
if (token != null)
{
refresh_token = token.RefreshToken;
access_token = token.AccessToken;
await localStorage.SetItemAsync("access_token", access_token);
await localStorage.SetItemAsync("refresh_token", refresh_token);
UserCacheRefreshInterval = TimeSpan.FromSeconds(token.ExpiresIn);
UserLastCheckTime = DateTimeOffset.UtcNow;
SetClientToken();
await ParseTokenAsync();
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
return true;
}
return false;
}
//注册
public async Task<RegisterResponse> RegisterAsync(RegisterModel model)
{
var response = await client.PostAsJsonAsync(@"register", model);
var reg = await response.Content.ReadFromJsonAsync<RegisterResponse>();
if (reg.Succeeded) await ParseResponseAsync(reg.TokenResponse);
return reg;
}
//登录
public async Task<bool> LoginAsync(string username, string password)
{
var response = await client.PostAsJsonAsync(@"login", new LoginRequest { Username = username, Password = password } );
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return false;
}
var token = await response.Content.ReadFromJsonAsync<AccessTokenResponse>();
return await ParseResponseAsync(token);
}
//登出
public async Task LogoutAsync()
{
await client.PostAsync(@"logout",new StringContent(string.Empty));
refresh_token = null;
access_token = null;
await localStorage.RemoveItemAsync("access_token");
await localStorage.RemoveItemAsync("refresh_token");
UserLastCheckTime = DateTimeOffset.FromUnixTimeSeconds(0);
CachedUser = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(defaultUnanthenticatedTask);
}
//刷新令牌
public async Task<bool> RefreshTokenAsync()
{
if (refresh_token is null) return false;
SetClientToken();
var response = await client.PostAsJsonAsync(@"refresh", new { RefreshToken = refresh_token });
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
return false;
}
var token = await response.Content.ReadFromJsonAsync<AccessTokenResponse>();
return await ParseResponseAsync(token);
}
//获取用户鉴权信息
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
if (DateTimeOffset.UtcNow - UserLastCheckTime < UserCacheRefreshInterval || await RefreshTokenAsync())
{
return new AuthenticationState(CachedUser);
}
return await defaultUnanthenticatedTask;
}
}

WebAssembly + JWT

此方法安全性较低,不推荐

注册服务

// JWT 配置
var jwtSettings = builder.Configuration.GetSection("JwtSettings");
var secretKey = Encoding.UTF8.GetBytes(jwtSettings["SecretKey"]!); // 添加认证服务
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtSettings["Issuer"],
ValidAudience = jwtSettings["Audience"],
IssuerSigningKey = new SymmetricSecurityKey(secretKey)
};
}); // 添加授权服务
builder.Services.AddAuthorization(); // 添加 JWT 服务
builder.Services.AddScoped<JwtService>(); // 添加 Identity 服务(如果需要)
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 6;
options.SignIn.RequireConfirmedEmail = false;
}).AddRoles<IdentityRole<Guid>>()
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders()
.AddRoleManager<RoleManager<IdentityRole<Guid>>>()
.AddUserManager<UserManager<ApplicationUser>>(); var app = builder.Build(); app.UseAuthentication();
app.UseAuthorization();

appsettings.json

  "JwtSettings": {
"SecretKey": "your-very-looong-secret-key-here",
"Issuer": "your-issuer",
"Audience": "your-audience"
}

JwtService

public class JwtService(IConfiguration configuration)
{
public AccessTokenResponse GenerateTokens(ApplicationUser user, IList<string> roles)
{
// 生成访问令牌
var accessToken = GenerateAccessToken(user, roles); // 生成刷新令牌
var refreshToken = GenerateRefreshToken(); // 计算过期时间(以秒为单位)
var expiresIn = Convert.ToInt32(TimeSpan.FromHours(1).TotalSeconds); return new AccessTokenResponse(accessToken, expiresIn, refreshToken);
} private string GenerateAccessToken(ApplicationUser user, IList<string> roles)
{
var secretKey = Encoding.UTF8.GetBytes(configuration["JwtSettings:SecretKey"]!);
var signingCredentials = new SigningCredentials(
new SymmetricSecurityKey(secretKey),
SecurityAlgorithms.HmacSha256Signature
); var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(ClaimTypes.Name, user.UserName!)
}; claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role))); var token = new JwtSecurityToken(
issuer: configuration["JwtSettings:Issuer"],
audience: configuration["JwtSettings:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: signingCredentials
); return new JwtSecurityTokenHandler().WriteToken(token);
} private string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
} public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token)
{
var secretKey = configuration["JwtSettings:SecretKey"]!; var tokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = false, // 不验证过期时间
ValidateIssuerSigningKey = true,
ValidIssuer = configuration["JwtSettings:Issuer"],
ValidAudience = configuration["JwtSettings:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey))
}; var tokenHandler = new JwtSecurityTokenHandler();
var principal = tokenHandler.ValidateToken(token, tokenValidationParameters, out SecurityToken securityToken); if (securityToken is not JwtSecurityToken jwtSecurityToken ||
!jwtSecurityToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature,
StringComparison.InvariantCultureIgnoreCase))
{
return null;
} return principal;
}
}

IdentityEndpoints

    public static void MapIdentityEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/Account").WithTags("Account"); group.MapPost("/Login", async (
LoginRequest model,
UserManager<ApplicationUser> userManager,
JwtService jwtService) =>
{
var user = await userManager.FindByNameAsync(model.Username);
if (user == null)
{
return Results.Unauthorized();
} var isPasswordValid = await userManager.CheckPasswordAsync(user, model.Password);
if (!isPasswordValid)
{
return Results.Unauthorized();
} var roles = await userManager.GetRolesAsync(user);
var tokenResponse = jwtService.GenerateTokens(user, roles); // 保存刷新令牌到用户记录
user.RefreshToken = tokenResponse.RefreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7); // 刷新令牌7天有效
await userManager.UpdateAsync(user); return Results.Ok(tokenResponse);
})
.AllowAnonymous()
.WithName("Login")
.WithOpenApi(); group.MapGet("/User", (HttpContext context) =>
{
var user = context.User;
return Results.Ok(new
{
Username = user.Identity?.Name,
UserId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value,
Roles = user.Claims
.Where(c => c.Type == ClaimTypes.Role)
.Select(c => c.Value)
.ToList()
});
})
.RequireAuthorization()
.WithName("User")
.WithOpenApi(); group.MapPost("/Refresh", async (
RefreshTokenModel model,
UserManager<ApplicationUser> userManager,
JwtService jwtService,
HttpContext context) =>
{
// 从请求头获取过期的访问令牌
string? accessToken = context.Request.Headers["Authorization"]
.FirstOrDefault()?.Split(" ").Last(); if (string.IsNullOrEmpty(accessToken))
{
return Results.BadRequest("Access token is required");
} // 从过期的访问令牌中获取用户信息
var principal = jwtService.GetPrincipalFromExpiredToken(accessToken);
if (principal == null)
{
return Results.BadRequest("Invalid access token");
} var username = principal.Identity?.Name;
var user = await userManager.FindByNameAsync(username!); if (user == null ||
user.RefreshToken != model.RefreshToken ||
user.RefreshTokenExpiryTime <= DateTime.UtcNow)
{
return Results.BadRequest("Invalid refresh token");
} // 生成新的令牌
var roles = await userManager.GetRolesAsync(user);
var newTokenResponse = jwtService.GenerateTokens(user, roles); // 更新数据库中的刷新令牌
user.RefreshToken = newTokenResponse.RefreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user); return Results.Ok(newTokenResponse);
})
.AllowAnonymous()
.WithName("Refresh")
.WithOpenApi(); ;
group.MapPost("/Logout", async (
UserManager<ApplicationUser> userManager,
HttpContext context) =>
{
var user = context.User;
var appUser = await userManager.FindByNameAsync(user.Identity?.Name!);
if (appUser == null)
{
return Results.NotFound();
} // 清除刷新令牌
appUser.RefreshToken = null;
appUser.RefreshTokenExpiryTime = null;
await userManager.UpdateAsync(appUser); return Results.Ok();
})
.RequireAuthorization()
.WithName("Logout")
.WithOpenApi();
group.MapPost("/Register", async (
RegisterModel model,
UserManager<ApplicationUser> userManager,
JwtService jwtService) =>
{
// 验证模型
if (model.Password != model.ConfirmPassword)
{
return Results.BadRequest(new RegisterResponse(
false,
["密码和确认密码不匹配"], null, null
));
} // 检查用户名是否已存在
var existingUser = await userManager.FindByNameAsync(model.Username);
if (existingUser != null)
{
return Results.BadRequest(new RegisterResponse(
false,
["用户名已存在"], null, null
));
} // 检查邮箱是否已存在
var existingEmail = await userManager.FindByEmailAsync(model.Email);
if (existingEmail != null)
{
return Results.BadRequest(new RegisterResponse(
false,
["邮箱已被使用"], null, null
));
} // 创建新用户
var user = new ApplicationUser
{
UserName = model.Username,
Email = model.Email,
EmailConfirmed = true // 如果需要邮箱验证,设置为 false
}; // 添加用户
var result = await userManager.CreateAsync(user, model.Password); if (!result.Succeeded)
{
return Results.BadRequest(new RegisterResponse(
false,
result.Errors.Select(e => e.Description), null, null
));
} // 添加默认角色
// await userManager.AddToRoleAsync(user, "User"); // 生成令牌
var roles = await userManager.GetRolesAsync(user);
var tokenResponse = jwtService.GenerateTokens(user, roles); // 保存刷新令牌
user.RefreshToken = tokenResponse.RefreshToken;
user.RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(7);
await userManager.UpdateAsync(user); // 返回成功响应和令牌
return Results.Ok(new RegisterResponse(true, [], tokenResponse, "注册成功"));
})
.AllowAnonymous()
.WithName("Register")
.WithOpenApi();
}

前端只需要修改上一案例中的ParseTokenAsync方法

        private async Task ParseTokenAsync()
{
var handler = new JwtSecurityTokenHandler();
var token = handler.ReadJwtToken(access_token);
CachedUser = new ClaimsPrincipal(new ClaimsIdentity(token.Claims, "Bearer"));
}

结语

详细案例文章、视频、源码请等待后续发布

[Blazor] 一文理清 Blazor Identity 鉴权验证的更多相关文章

  1. 开放平台鉴权以及OAuth2.0介绍

    OAuth 2.0 协议 OAuth是一个开发标准,允许用户授权第三方网站或应用访问他们存储在另外的服务提供者上的信息,而不需要将用户名和密码提供给第三方网站或分享他们数据的内容. OAuth 2.0 ...

  2. Docker mongodb 3.4 分片 一主 一副 一仲 鉴权集群部署.

    非docker部署 为了避免过分冗余,并且在主节点挂了,还能顺利自动提升,所以加入仲裁节点 为什么要用docker部署,因为之前直接在虚拟机启动10个mongod 进程.多线程并发测试的时候,mong ...

  3. mongodb 3.4 分片 一主 一副 一仲 鉴权集群部署.

    Docker方式部署 为了避免过分冗余,并且在主节点挂了,还能顺利自动提升,所以加入仲裁节点 mongodb版本: 环境:一台虚拟机 三个configsvr 副本: 端口为 27020,27021,2 ...

  4. apigw鉴权分析(1-4)新浪微博开放平台 - 鉴权分析

    一.访问入口 http://open.weibo.com/wiki/%E6%8E%88%E6%9D%83%E6%9C%BA%E5%88%B6%E8%AF%B4%E6%98%8E 微博开放接口的调用,如 ...

  5. apigw鉴权分析(1-2)腾讯开放平台 - 鉴权分析

    一.访问入口 http://wiki.open.qq.com/wiki/%E8%85%BE%E8%AE%AF%E5%BC%80%E6%94%BE%E5%B9%B3%E5%8F%B0%E7%AC%AC% ...

  6. apigw鉴权分析(1-1)阿里数加 - 鉴权方式分析

    一.访问方式 1.访问阿里云首页 https://www.aliyun.com/?utm_medium=text&utm_source=bdbrand&utm_campaign=bdb ...

  7. jmeter测试wordpress使用base64进行鉴权

    1.下载插件 对密码进行加密 2.安装插件 3.使用插件 4.使用base64对上面获取到的密码进行二次加密 echo -n "用户:密码" | base64 将以上命令键入lin ...

  8. ASP.NET Core 项目简单实现身份验证及鉴权

    ASP.NET Core 身份验证及鉴权 目录 项目准备 身份验证 定义基本类型和接口 编写验证处理器 实现用户身份验证 权限鉴定 思路 编写过滤器类及相关接口 实现属性注入 实现用户权限鉴定 测试 ...

  9. shiro jwt 构建无状态分布式鉴权体系

    一:JWT 1.令牌构造 JWT(json web token)是可在网络上传输的用于声明某种主张的令牌(token),以JSON 对象为载体的轻量级开放标准(RFC 7519). 一个JWT令牌的定 ...

  10. 七牛云 PHP SDK服务器鉴权失败!参数解释

    昨天搞了一下午,用7牛官方的SDK demo 1.上传凭证 $policy = array( 'callbackUrl' => 'http://api.example.com/qiniu/upl ...

随机推荐

  1. Java日期时间API系列15-----Jdk8中java.time包中的新的日期时间API类,java日期计算2,年月日时分秒的加减等

    通过Java日期时间API系列8-----Jdk8中java.time包中的新的日期时间API类的LocalDate源码分析 ,可以看出java8设计非常好,实现接口Temporal, Tempora ...

  2. 2022年7月中国数据库排行榜:墨天轮榜单榜眼易主,PolarDB得分涨幅最大

    信创元年,后起之秀,大有可为.2022年7月的 墨天轮中国数据库流行度排行榜 风起云涌,本月排行榜共有232个数据库参与排名,相比上月,新增 ShuangzhaoDB 数据库.榜单前十名的唯一变化是达 ...

  3. C#查漏补缺----对象内存结构与布局

    环境变量 .Net Core 8.0 Windows 11 64位 内存布局 引用类型 在.NET中,数据会按照类型分为不同的对象,对于引用类型的实例,由一个对象标头(Object Header)和方 ...

  4. Flask源码分析(目录)

    目录 推荐阅读 it程序猿面试必备装逼技能 Flask 源码分析 1.flask 源码解析:简介 2.flask 源码解析:应用启动流程 3.flask 源码解析:路由 4.flask 源码解析:上下 ...

  5. SpringBoot进阶教程(八十二)Spring Security图形验证码

    在之前的博文<SpringBoot进阶教程(八十)Spring Security>中,已经介绍了在Spring Security中如何基于formLogin认证.基于HttpBasic认证 ...

  6. KubeSphere 部署 Kafka 集群实战指南

    本文档将详细阐述如何利用 Helm 这一强大的工具,快速而高效地在 K8s 集群上安装并配置一个 Kafka 集群. 实战服务器配置(架构 1:1 复刻小规模生产环境,配置略有不同) 主机名 IP C ...

  7. 云原生周刊:K8s v1.28 中的结构化身份验证配置

    开源项目推荐 KubeLinter KubeLinter 是一种静态分析工具,用于检查 Kubernetes YAML 文件和 Helm 图表,以确保其中表示的应用程序遵循最佳实践. DB Opera ...

  8. Oracle 11.2.0.4 Data Guard 部署练习【一主一备/单机】

    Oracle DG 理论及实验笔记 DATAGUARD 是通过建立一个 PRIMARY 和 STANDBY 组来确立其参照关系. STANDBY 一旦创建, DATAGUARD 就会通过将主数据库 ( ...

  9. .NET 9 中没有 wasi 实验性支持

    2023年10月份写个一篇<本计划在 .NET 8 中推出的 WASI 推迟到 .NET 9>[1],根据此问题,在 .NET 9 RTM 中似乎不会有wasi-experimental, ...

  10. 关于C++当中全局变量的释放问题

    一.由来 主要是在修改公司的一个MFC项目的时候遇到的问题,我在MFC页面的析构函数当中对一个全局图像变量进行了释放,具体如下: ai_engine_OCR::~ai_engine_OCR() { / ...