在Identity Server 4项目集成Blazor组件
Identity Server系列目录
- Blazor Server访问Identity Server 4单点登录 - SunnyTrudeau - 博客园 (cnblogs.com)
- Blazor Server访问Identity Server 4单点登录2-集成Asp.Net角色 - SunnyTrudeau - 博客园 (cnblogs.com)
- Blazor Server访问Identity Server 4-手机验证码登录 - SunnyTrudeau - 博客园 (cnblogs.com)
- Blazor MAUI客户端访问Identity Server登录 - SunnyTrudeau - 博客园 (cnblogs.com)
最近才知道可以在Asp.Net core MVC的cshtml页面中,嵌入Blazor组件,所以决定把Blazor编写的手机验证码登录组件放到Identity Server 4服务端,其他网站登录的时候,发起oidc认证流程,跳转到Identity Server服务端登录,这样的方案比较符合id4登录的流程。
在Identity Server项目支持嵌入Blazor组件
AspNetId4Web认证服务器是Asp.Net Core MVC项目,在Asp.Net core MVC的cshtml页面中,嵌入Blazor组件的方法,最好的介绍,就在官网,跟着官网写一遍代码,就可以了。
预呈现和集成 ASP.NET Core Razor 组件 | Microsoft Docs
以下内容从官网介绍修改而来,官网最新介绍代码是基于Net 6的风格,而Identity Server项目仍然是NetCore 3.1风格:
1. 在项目的布局文件中
将以下 <base> 标记添加到 Views/Shared/_Layout.cshtml (MVC) 中的 <head> 元素:
<base href="~/" />
在紧接着应用布局的 Scripts 呈现部分 @RenderSection("scripts", required: false) 的前方为 blazor.server.js 脚本添加 <script> 标记。
Views/Shared/_Layout.cshtml (MVC):
<script src="_framework/blazor.server.js"></script>
2. 将具有以下内容的导入文件添加到项目的根文件夹
_Imports.razor:
@using System.Net.Http
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AspNetId4Web
3. 在注册服务的 Startup.cs 中注册 Blazor Server 服务
services.AddServerSideBlazor();
4. 将 Blazor 中心终结点添加到映射路由的 Startup.cs 的终结点
在调用 MapControllerRoute (MVC) 后放置以下行:
endpoints.MapBlazorHub();
本项目中把Blazor组件嵌入到cshtml页面中,因此到此为止就够了,就是这么简单。如果要使用带有路由的Blazor页面,那么还要多修改更多,我自己测试过带路由的Blazor页面,也是可以用的。但是Identity Server项目需要借助MVC页面实现SignIn,写入cookies,这些需求用Blazor页面反而没法实现,所以还是保留MVC页面,仅使用Blazor组件。
将手机验证码Blazor组件嵌入到Identity Server服务端
把之前写好的PhoneCodeLogin.razor复制到Identity Server项目的Views\Shared目录下,修改为不带路由的组件。最后验证通过后,仍然需要跳转到Account控制器实现SignIn登录。
@using AspNetId4Web <div class="card" style="500px"> <div class="card-header"> <h5> 手机验证码登录 </h5> </div> <div class="card-body"> @if (!string.IsNullOrWhiteSpace(ErrorMsg)) { <div class="text-danger m-4"> @ErrorMsg </div> } <div class="form-group form-inline"> <label for="PhoneNumber" class="control-label">手机号</label> <input id="PhoneNumber" @bind="PhoneNumber" class="form-control" placeholder="请输入手机号" /> </div> <div class="form-group form-inline"> <label for="VerificationCode" class="control-label">验证码</label> <input id="VerificationCode" @bind="VerificationCode" class="form-control" placeholder="请输入验证码" /> @if (CanGetVerificationCode) { <button type="button" class="btn btn-link" @onclick="GetVerificationCode"> 获取验证码 </button> } else { <label>@GetVerificationCodeMsg</label> } </div> </div> <div class="card-footer"> <button type="button" class="btn btn-primary" @onclick="Login"> 登录 </button> </div> </div> @code { [Parameter] public string ReturnUrl { get; set; } [Inject] private PhoneCodeService phoneCodeService { get; set; } [Inject] private IJSRuntime jsRuntime { get; set; } private string PhoneNumber; private string VerificationCode; private string ErrorMsg; //获取验证码按钮当前状态 private bool CanGetVerificationCode = true; private string GetVerificationCodeMsg; //获取验证码 private async void GetVerificationCode() { if (CanGetVerificationCode) { //发送验证码到手机号 var result = await phoneCodeService.SendPhoneCode(PhoneNumber); if (result.IsError) { ErrorMsg = result.Msg; //通知页面更新 StateHasChanged(); return; } else { ErrorMsg = ""; } CanGetVerificationCode = false; //1分钟倒计时 for (int i = 60; i >= 0; i--) { GetVerificationCodeMsg = $"获取验证码({i})"; await Task.Delay(1000); //通知页面更新 StateHasChanged(); } CanGetVerificationCode = true; //通知页面更新 StateHasChanged(); } } //登录 private async void Login() { //手机验证码登录 var result = await phoneCodeService.PhoneCodeLogin(PhoneNumber, VerificationCode); if (result.IsError) { ErrorMsg = result.Msg; //通知页面更新 StateHasChanged(); return; } string uri = $"Account/SignInByPhoneNumber?phoneNumber={PhoneNumber}&returnUrl={Uri.EscapeDataString(ReturnUrl)}"; //要跳转到MVC控制器SignIn登录,如果直接在razor页面登录,报错Headers are read-only, response has already started await jsRuntime.InvokeVoidAsync("window.location.assign", uri); } }
新建一个MVC网页LoginByPhoneCode.cshtml,把手机验证码Blazor组件嵌入到这个网页里,非常简单
@using AspNetId4Web.Views.Shared @model LoginViewModel <component type="typeof(PhoneCodeLogin)" render-mode="ServerPrerendered" param-ReturnUrl=@Model.ReturnUrl />
修改Account控制器的Login方法,改用手机验证码MVC网页
/// <summary> /// Entry point into the login workflow /// </summary> [HttpGet] public async Task<IActionResult> Login(string returnUrl) { // build a model so we know what to show on the login page var vm = await BuildLoginViewModelAsync(returnUrl); if (vm.IsExternalLoginOnly) { // we only have one option for logging in and it's an external provider return RedirectToAction("Challenge", "External", new { scheme = vm.ExternalLoginScheme, returnUrl }); } //return View(vm); //改用手机验证码登录页面 return View("LoginByPhoneCode", vm); }
Account控制器增加SignInByPhoneNumbe方法,根据手机号SignIn,大部分代码其实可以从Login方法复制
/// <summary> /// 根据手机号SignIn /// </summary> [HttpGet] public async Task<IActionResult> SignInByPhoneNumber(string phoneNumber, string returnUrl) { //根据手机号查找用户 var user = await _userManager.Users.AsNoTracking().FirstAsync(x => x.PhoneNumber == phoneNumber); //SignIn登录 await _signInManager.SignInAsync(user, false); // check if we are in the context of an authorization request var context = await _interaction.GetAuthorizationContextAsync(returnUrl); await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id.ToString(), user.UserName, clientId: context?.Client.ClientId)); if (context != null) { if (context.IsNativeClient()) { // The client is native, so this change in how to // return the response is for better UX for the end user. return this.LoadingPage("Redirect", returnUrl); } // we can trust returnUrl since GetAuthorizationContextAsync returned non-null return Redirect(returnUrl); } // request for a local page if (Url.IsLocalUrl(returnUrl)) { return Redirect(returnUrl); } else if (string.IsNullOrEmpty(returnUrl)) { return Redirect("~/"); } else { // user might have clicked on a malicious link - should be logged throw new Exception("invalid return URL"); } }
编写一个手机验证码服务PhoneCodeService,实现创建验证码,检查验证码。
// <summary> /// 手机验证码服务 /// </summary> public class PhoneCodeService { private readonly IMemoryCache _memoryCache; private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; public PhoneCodeService( IMemoryCache memoryCache, IServiceProvider serviceProvider, ILogger<PhoneCodeService> logger) { _memoryCache = memoryCache; _serviceProvider = serviceProvider; _logger = logger; } /// <summary> /// 发送验证码到手机号 /// </summary> /// <param name="phoneNumber"></param> /// <returns></returns> public async Task<(bool IsError, string Msg)> SendPhoneCode(string phoneNumber) { //根据手机号获取用户信息 var appUser = await GetUserByPhoneNumberAsync(phoneNumber); if (appUser == null) { return (true, "手机号无效"); } //发送验证码到手机号,需要调用短信服务平台Web Api,这里模拟发送 string verificationCode = (new Random()).Next(1000, 9999).ToString(); //验证码缓存10分钟 _memoryCache.Set(phoneNumber, verificationCode, TimeSpan.FromMinutes(10)); _logger.LogInformation($"发送验证码{verificationCode}到手机号{phoneNumber}, 有效期{DateTime.Now.AddMinutes(10)}"); return (false, "发送验证码成功"); } /// <summary> /// 手机验证码登录 /// </summary> /// <param name="phoneNumber">手机号</param> /// <param name="verificationCode">验证码</param> /// <returns></returns> public async Task<(bool IsError, string Msg)> PhoneCodeLogin(string phoneNumber, string verificationCode) { try { //获取手机号对应的缓存验证码 if (!_memoryCache.TryGetValue(phoneNumber, out string cacheVerificationCode)) { //如果获取不到缓存验证码,说明手机号不存在,或者验证码过期,但是发送验证码时已经验证过手机号是存在的,所以只能是验证码过期 return (true, "验证码过期"); } if (verificationCode != cacheVerificationCode) { return (true, "验证码错误"); } //根据手机号获取用户信息 var appUser = await GetUserByPhoneNumberAsync(phoneNumber); if (appUser == null) { return (true, "手机号无效"); } //验证通过 return (false, "验证通过"); } catch (Exception ex) { return (true, ex.Message); } } /// <summary> /// 根据手机号获取用户信息 /// </summary> /// <param name="phoneNumber">手机号</param> /// <returns></returns> public async Task<ApplicationUser> GetUserByPhoneNumberAsync(string phoneNumber) { using var scope = _serviceProvider.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var appUser = await context.Users.AsNoTracking() .FirstOrDefaultAsync(x => x.PhoneNumber == phoneNumber); return appUser; } }
Config.cs新增一个客户端配置,用于对接Blazor Server项目的oidc认证
new Client() { ClientId="BlazorServerOidc", ClientName = "BlazorServerOidc", ClientSecrets=new []{new Secret("BlazorServerOidc.Secret".Sha256())}, AllowedGrantTypes = GrantTypes.Code, AllowedCorsOrigins = { "https://localhost:5501" }, RedirectUris = { "https://localhost:5501/signin-oidc" }, PostLogoutRedirectUris = { "https://localhost:5501/signout-callback-oidc" }, //效果等同客户端项目配置options.GetClaimsFromUserInfoEndpoint = true //AlwaysIncludeUserClaimsInIdToken = true, AllowedScopes = { "openid", "profile", "scope1", "role", } },
创建Blazor Server项目采用oidc认证
新建Blazor Server项目BlzOidc,NuGet安装Microsoft.AspNetCore.Authentication.OpenIdConnect、IdentityModel
<PackageReference Include="IdentityModel" Version="5.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="6.0.0" />
把启动端口改为5501
"profiles": {
"BlzOidc": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:5501",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
Program.cs需要添加oidc认证相关的服务,包括role声明的特殊处理,开启认证和授权中间件,添加MVC控制器路由相关服务。
using BlzOidc.Data; using IdentityModel; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; namespace BlzOidc; public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); builder.Services.AddSingleton<WeatherForecastService>(); //添加认证相关的服务 ConfigureAuthServices(builder.Services); //从Blazor组件跳转到MVC控制器登录,需要借助MVC控制器 builder.Services.AddControllers(); var app = builder.Build(); // Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error"); } app.UseStaticFiles(); app.UseRouting(); //添加认证与授权中间件 app.UseAuthentication(); app.UseAuthorization(); //从Blazor组件跳转到MVC控制器登录,需要借助MVC控制器 app.MapDefaultControllerRoute(); app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); app.Run(); } //添加认证相关的服务 private static void ConfigureAuthServices(IServiceCollection services) { //清除微软定义的clamis JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); //默认采用cookie认证方案,添加oidc认证方案 services.AddAuthentication(options => { options.DefaultScheme = "cookies"; options.DefaultChallengeScheme = "oidc"; }) //配置cookie认证 .AddCookie("cookies") .AddOpenIdConnect("oidc", options => { //id4服务的地址 options.Authority = "https://localhost:5001"; //id4配置的ClientId以及ClientSecrets options.ClientId = "BlazorServerOidc"; options.ClientSecret = "BlazorServerOidc.Secret"; //认证模式 options.ResponseType = "code"; //保存token到本地 options.SaveTokens = true; //很重要,指定从Identity Server的UserInfo地址来取Claim //效果等同id4配置AlwaysIncludeUserClaimsInIdToken = true options.GetClaimsFromUserInfoEndpoint = true; //指定要取哪些资料(除Profile之外,Profile是默认包含的) options.Scope.Add("scope1"); options.Scope.Add("role"); //这里是个ClaimType的转换,Identity Server的ClaimType和Blazor中间件使用的名称有区别,需要统一。 //User.Identity.Name=JwtClaimTypes.Name options.TokenValidationParameters.NameClaimType = "name"; options.TokenValidationParameters.RoleClaimType = "role"; options.Events.OnUserInformationReceived = (context) => { //id4返回的角色是字符串数组或者字符串,blazor server的角色是字符串,需要转换,不然无法获取到角色 ClaimsIdentity claimsId = context.Principal.Identity as ClaimsIdentity; var roleElement = context.User.RootElement.GetProperty(JwtClaimTypes.Role); if (roleElement.ValueKind == System.Text.Json.JsonValueKind.Array) { var roles = roleElement.EnumerateArray().Select(e => e.ToString()); claimsId.AddClaims(roles.Select(r => new Claim(JwtClaimTypes.Role, r))); } else { claimsId.AddClaim(new Claim(JwtClaimTypes.Role, roleElement.ToString())); } return Task.CompletedTask; }; }); } }
App.razor需要添加认证相关属性。
<CascadingAuthenticationState> <Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)"> <NotAuthorized> @if (!(context.User.Identity?.IsAuthenticated == true)) { <RedirectToLogin></RedirectToLogin> } else { <p>You are not authorized to access this resource.</p> } </NotAuthorized> </AuthorizeRouteView> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router> </CascadingAuthenticationState>
增加RedirectToLogin.razor用于跳转到MVC控制器oidc登录。
@inject NavigationManager Navigation @code { protected override void OnAfterRender(bool firstRender) { Navigation.NavigateTo($"account/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}", true); } }
增加AccountController用于跳转登录,还是比较麻烦的。
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Mvc; namespace BlzOidc.Controllers { public class AccountController : Controller { [HttpGet] public IActionResult Login(string returnUrl) { if (string.IsNullOrEmpty(returnUrl)) returnUrl = "/"; // start challenge and roundtrip the return URL and scheme var authProps = new AuthenticationProperties { RedirectUri = returnUrl }; //发起oidc认证,跳转到Identity Server登录 return Challenge(authProps, "oidc"); } [HttpGet] public async Task<IActionResult> Logout() { if (User?.Identity?.IsAuthenticated == true) { // delete local authentication cookie await HttpContext.SignOutAsync("cookies"); } var authProps = new AuthenticationProperties { RedirectUri = "/" }; //跳转到Identity Server退出登录 return SignOut(authProps, "oidc"); } } }
增加一个显示登录信息的Blazor组件LoginDisplay.razor,放在MainLayout.razor顶部。
@using Microsoft.AspNetCore.Components.Authorization @inject NavigationManager Navigation <AuthorizeView> <Authorized> Hello, @context.User.Identity?.Name! <a href="account/logout">Log out</a> </Authorized> <NotAuthorized> <a href="account/login">Log in</a> </NotAuthorized> </AuthorizeView> @code { }
主页Index.razor显示登录用户信息。
@page "/" <PageTitle>Index</PageTitle> <h1>Hello, world!</h1> Welcome to your new app. <SurveyPrompt Title="How is Blazor working for you?" /> <AuthorizeView> <Authorized> <p>您已经登录</p> <div class="card"> <div class="card-header"> <h2>context.User.Claims</h2> </div> <div class="card-body"> <dl> <dt>context.User.Identity.Name</dt> <dd>@context.User.Identity?.Name</dd> @foreach (var claim in context.User.Claims) { <dt>@claim.Type</dt> <dd>@claim.Value</dd> } </dl> </div> </div> </Authorized> <NotAuthorized> <p>您还没有登录,请先登录</p> </NotAuthorized> </AuthorizeView>
FetchData.razor获取天气的页面增加授权访问属性,如果没有登录,点击网页,会自动跳转登录
@page "/fetchdata"
@attribute [Authorize]
测试登录跳转
编译AspNetId4Web提示错误:命名空间“Microsoft.AspNetCore.Components.Web”中不存在类型或命名空间名“Virtualization”(是否缺少程序集引用?)
解决方法:把AspNetId4Web框架从netcore 3.1改为net5.0,然后把项目依赖的NuGet库全部升级到最新即可。
同时运行AspNetId4Web认证服务器项目和BlzOidc项目。在BlzOidc主页点击登录,它根据oidc配置跳转到AspNetId4Web项目,显示手机验证码登录页面。点击【获取验证码】,可以查看AspNetId4Web项目控制台输出得知验证码,然后输入验证码,点击【登录】。
登录成功后,从AspNetId4Web项目跳转回到BlzOidc主页,显示登录用户信息,多角色也可以正确处理,跟之前的DEMO一样。如果没有登录直接点击获取天气的页面,也可以自动跳转到AspNetId4Web项目登录。
问题
退出登录时,Identity Server服务端控制台显示错误信息,没明白,因为不影响整体功能,所以暂时不理它。
[22:00:31 Error] IdentityServer4.Stores.ProtectedDataMessageStore
Exception reading protected message
System.InvalidOperationException: Each parameter in constructor 'Void .ctor(IdentityServer4.Models.LogoutMessage, System.DateTime)' on type 'IdentityServer4.Models.Message`1[IdentityServer4.Models.LogoutMessage]' must bind to an object property or field on deserialization. Each parameter name must match with a property or field on the object. The match can be case-insensitive.
另外,退出登录时,还要在Identity Server服务端点击【Yes】按钮,并且最后停留在Identity Server网页上,这个不是我想要的。
DEMO代码地址:https://gitee.com/woodsun/blzid4
参考资料:
Blazor与IdentityServer4的集成 - towerbit - 博客园 (cnblogs.com)
感谢作者。