在Identity Server 4项目集成Blazor组件


Identity Server 4项目集成Blazor组件

Identity Server系列目录

  1. Blazor Server访问Identity Server 4单点登录 - SunnyTrudeau - 博客园 (cnblogs.com)
  2. Blazor Server访问Identity Server 4单点登录2-集成Asp.Net角色 - SunnyTrudeau - 博客园 (cnblogs.com)
  3. Blazor Server访问Identity Server 4-手机验证码登录 - SunnyTrudeau - 博客园 (cnblogs.com)
  4. Blazor MAUI客户端访问Identity Server登录 - SunnyTrudeau - 博客园 (cnblogs.com)

最近才知道可以在Asp.Net core MVCcshtml页面中,嵌入Blazor组件,所以决定把Blazor编写的手机验证码登录组件放到Identity Server 4服务端,其他网站登录的时候,发起oidc认证流程,跳转到Identity Server服务端登录,这样的方案比较符合id4登录的流程。

Identity Server项目支持嵌入Blazor组件

AspNetId4Web认证服务器是Asp.Net Core MVC项目,在Asp.Net core MVCcshtml页面中,嵌入Blazor组件的方法,最好的介绍,就在官网,跟着官网写一遍代码,就可以了。

预呈现和集成 ASP.NET Core Razor 组件 | Microsoft Docs

以下内容从官网介绍修改而来,官网最新介绍代码是基于Net 6的风格,而Identity Server项目仍然是NetCore 3.1风格:

1. 在项目的布局文件中

将以下 标记添加到 Views/Shared/_Layout.cshtml (MVC) 中的 元素:

在紧接着应用布局的 Scripts 呈现部分 @RenderSection("scripts", required: false) 的前方为 blazor.server.js 脚本添加

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

class="card" style="width:500px">
class="card-header">
手机验证码登录
class="card-body"> @if (!string.IsNullOrWhiteSpace(ErrorMsg)) {
class="text-danger m-4"> @ErrorMsg
}
class="form-group form-inline"> "PhoneNumber" @bind="PhoneNumber" class="form-control" placeholder="请输入手机号" />
class="form-group form-inline"> "VerificationCode" @bind="VerificationCode" class="form-control" placeholder="请输入验证码" /> @if (CanGetVerificationCode) { } else { }
class="card-footer">
@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

"typeof(PhoneCodeLogin)" render-mode="ServerPrerendered" param-ReturnUrl=@Model.ReturnUrl />

修改Account控制器的Login方法,改用手机验证码MVC网页

/// 
        /// Entry point into the login workflow
        /// 
        [HttpGet]
        public async Task 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方法复制

/// 
        /// 根据手机号SignIn
        /// 
        [HttpGet]
        public async Task 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,实现创建验证码,检查验证码。

// 
    /// 手机验证码服务
    /// 
    public class PhoneCodeService
    {
        private readonly IMemoryCache _memoryCache;
        private readonly IServiceProvider _serviceProvider;
        private readonly ILogger _logger;

        public PhoneCodeService(
            IMemoryCache memoryCache,
            IServiceProvider serviceProvider,
            ILogger logger)
        {
            _memoryCache = memoryCache;
            _serviceProvider = serviceProvider;
            _logger = logger;
        }

        /// 
        /// 发送验证码到手机号
        /// 
        /// 
        /// 
        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, "发送验证码成功");
        }

        /// 
        /// 手机验证码登录
        /// 
        /// 手机号
        /// 验证码
        /// 
        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);
            }
        }

        /// 
        /// 根据手机号获取用户信息
        /// 
        /// 手机号
        /// 
        public async Task GetUserByPhoneNumberAsync(string phoneNumber)
        {
            using var scope = _serviceProvider.CreateScope();
            var context = scope.ServiceProvider.GetRequiredService();

            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项目BlzOidcNuGet安装Microsoft.AspNetCore.Authentication.OpenIdConnectIdentityModel

    <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();

        //添加认证相关的服务
        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需要添加认证相关属性。


    "@typeof(Program).Assembly">
        "routeData">
            "@routeData" DefaultLayout="@typeof(MainLayout)">
                
                    @if (!(context.User.Identity?.IsAuthenticated == true))
                    {
                        
                    }
                    else
                    {
                        

You are not authorized to access this resource.

}
"@typeof(MainLayout)">

Sorry, there's nothing at this address.

增加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 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


    
        Hello, @context.User.Identity?.Name!
        "account/logout">Log out
    
    
        "account/login">Log in
    


@code {

}

主页Index.razor显示登录用户信息。

@page "/"

Index

Hello, world!

Welcome to your new app. "How is Blazor working for you?" />

您已经登录

class="card">
class="card-header">

context.User.Claims

class="card-body">
context.User.Identity.Name
@context.User.Identity?.Name
@foreach (var claim in context.User.Claims) {
@claim.Type
@claim.Value
}

您还没有登录,请先登录

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)

感谢作者。