在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="width: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)

感谢作者。

 

posted on 2021-11-21 22:10  SunnyTrudeau  阅读(490)  评论(0编辑  收藏  举报