• 在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. 在项目的布局文件中

    将以下 <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项目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<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)

    感谢作者。

  • 相关阅读:
    端口转发工具ngr0k
    深入理解JavaScript系列(2):揭秘命名函数表达式(转)
    SQL Server DATEDIFF() 函数
    em(倍)与px的区别
    左右页面布局
    深入理解JavaScript系列(1):编写高质量JavaScript代码的基本要点(转)
    《JavaScript高级程序设计》(第二版)
    autocomplete 之 ASP.NET
    JQuery 优缺点略谈
    几个常见CSS错误和解决办法
  • 原文地址:https://www.cnblogs.com/sunnytrudeau/p/15586224.html
Copyright © 2020-2023  润新知