Blazor与IdentityServer4的集成

 本文合并整理自 CSDN博主「65号腕」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。

Blazor与IdentityServer4的集成(一)

IdentityServer4是开源的基于.Net Core的鉴权中间件,有兴趣的可以去https://github.com/IdentityServer/IdentityServer4自行了解。

Blazor分为WebAssembly和Server两种模式,由于到目前为止,笔者主要学习的是WebAssembly模式,所以本文就来尝试一下WebAssembly与IdentityServer4的集成。

1. 安装IdentityServer4

安装IdentityServer4其实很简单,官方提供了命令行让你可以从零开始创建一个可以运行的网站,首先创建一个空白解决方案,然后进入解决方案所在目录,并按顺序执行以下命令:

//安装IdentityServer4的项目模板
dotnet new -i IdentityServer4.Templates

//创建一个空项目,并配置IdentityServer4中间件
dotnet new is4empty -n IdentityServer(这是项目名字,你也可以指定其他名字)

//进入项目所在目录
cd IdentityServer

//安装QuickStart, 此时会创建需要的Controller,View等
dotnet new is4ui

最后将新创建的项目添加到你的解决方案,并修改一下Startup.cs文件:

 

 

 主要修改有两个,一个是把QuickStart提供的测试用户加入系统(这个很重要,否则Blazor那边将取不到用户的基本资料),另一个是打开MVC服务。

现在运行起来就能看到以下页面了,尝试点击红色框登录试一试,测试的用户名和密码在Quickstart\TestUsers.cs文件当中。

 

2. 配置IdentityServer4

接下来就是配置IdentityServer, 包括三个方面:

  •     Identity Resource
  •     Api Scope
  •     Client


全部配置全都在Config.cs里面,修改起来也很方便,这里主要介绍一下Client的配置,我们需要创建一个Client用来给Blazor登录使用,代码如下:

public static IEnumerable<Client> Clients =>
    new Client[]
    {
        new Client
        {
            //唯一id,用来区分不同的Client
            ClientId = "blazorwasm",
            //使用的授权方式
            AllowedGrantTypes = GrantTypes.Code,
            //这里设置为不需要安全码,当然也可以指定安全码
            RequireClientSecret = false,
            //Blazor运行时的URL
            AllowedCorsOrigins =     { "https://localhost:5000" },
            //登录成功之后将要跳转的Blazor的URL
            RedirectUris = { "https://localhost:5000/authentication/login-callback" },
            //登出之后将要跳转的Blazor的URL
            PostLogoutRedirectUris = { "https://localhost:5000/" },
            //允许的Scope,openid包含用户id,profile包含用户基本资料,api为自定义的scope,也可以为其他名字
            AllowedScopes = { "openid", "profile",  "api" },
        }
    };

到这里,IdentityServer已经完全可以满足我们Blazor登录的需求了。

3. 配置Blazor

接下来我们就添加一个Blazor WebAssembly项目,然后开始Blazor这边的工作。

3.1 安装依赖

首先需要安装微软的认证库Microsoft.AspNetCore.Components.WebAssembly.Authentication,通过nuget安装即可。

然后修改_Imports.razor,导入必要的命名空间

@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication

最后修改index.html,引入所需要的javascript文件:

<script src="_content/Microsoft.AspNetCore.Components.WebAssembly.Authentication/AuthenticationService.js"></s

3.2 配置连接

该方案是通过Open Id的方式来连接IdentityServer,因此需要配一个Open Id 的客户端连接,跟IdentityServer的配置相匹配。

首先修改Program.cs的main函数,添加Open Id服务

builder.Services.AddOidcAuthentication(options =>
{
    builder.Configuration.Bind("Local", options.ProviderOptions);
});

当中的Local对应的是appsettings.json里面的一个配置节,默认是没有该文件的,因此我们需要先在wwwroot目录下添加该文件,内容如下:

{
  "Local": {
    "Authority": "https://localhost:5001",
    "ClientId": "blazorwasm",
    "DefaultScopes": [
      "api"
    ],
    "PostLogoutRedirectUri": "/",
    "ResponseType": "code"
  }
}
  • Authority: 对应的是IdentityServer的URL
  • ClientId: 对应IdentityServer里面配置的ClientId
  • DefaultScopes: 需要是IdentityServer里面配置的AllowedScopes中的一个或多个,由于openid和profile是默认添加的,因此不需要声明。
  • PostLogoutRedirectUri: 登出之后的跳转地址
  • ResponseType: 对应IdentityServer里面配置的GrantTypes

3.3 编写组件

一切准备就绪,现在可以开始编写Blazor组件了。

首先我们修改一下App.razor,需要在最外层加上CascadingAuthenticationState,如下:

<CascadingAuthenticationState>
    ...
</CascadingAuthenticationState>

这个组件将允许各组件之间共享认证状态。

然后我们再添加一个登录页面 Pages/Authentication.razor,代码如下:

@page "/authentication/{action}"

<RemoteAuthenticatorView Action="@Action" />
@code {
    [Parameter]
    public string Action { get; set; }
}

她调用了认证库里面的RemoteAuthenticatorView组件,来帮助我们实现跳转。

接下来我们添加一个组件,用来显示登录状态以及登录登出按钮, 文件名为 Shared/LoginDisplay.razor, 代码如下:

@inject NavigationManager Navigation
@inject SignOutSessionStateManager SignOutManager

<AuthorizeView>
    <Authorized>
        Hello, @context.User.Identity.Name!
        <button class="nav-link btn btn-link" @onclick="BeginSignOut">
            Log out
        </button>
    </Authorized>
    <NotAuthorized>
        <a href="authentication/login">Log in</a>
    </NotAuthorized>
</AuthorizeView>

@code {
   
    private async Task BeginSignOut(MouseEventArgs args)
    {
        await SignOutManager.SetSignOutState();
        Navigation.NavigateTo("authentication/logout");
    }
}

功能也比较简单,登录登出都是路由到上一步添加的Authentication.razor。

最后我们在布局页MainLayout.razor的适当位置添加LoginDisplay组件即可。

最后运行效果如下:

 

 4. 源码

https://gitee.com/bigname65/blazor-identity-server4

 

Blazor与IdentityServer4的集成(二)

我们在上一篇Blazor与IdentityServer4的集成(一)中完成了基本的配置和登录登出的操作。

本篇主要完成授权的操作,包括:

  •     未登录的用户访问特定页面时自动跳到登录页面
  •     限定页面给不同的角色访问
  •     根据不同的角色显示页面不同的区域


基于上一篇的实例,我们假定有两个角色,分别是管理员(Admin)和普通用户(User),普通用户可以访问Counter页面,管理员除了Counter页面还可以访问Fetch data页面。

1. 配置IdentityServer

首先要做的就是给用户加上角色定义,找到 “Quickstart/TestUsers.cs” 文件, 这里面定义了两个测试用户,我们给alice分配Admin角色,给bob分配User角色:

return new List<TestUser>
{
    new TestUser
    {
        SubjectId = "818727",
        //...
        Username = "alice",
        Claims =
        {
            //...
            //添加Admin角色
            new Claim(JwtClaimTypes.Role,"Admin")
        }
    },
    new TestUser
    {
        SubjectId = "88421113",
        Username = "bob",
        //...
        Claims =
        {
            //...
            //添加User角色
            new Claim(JwtClaimTypes.Role,"User")
        }
    }
};

当然这里只是测试使用,如果应用到实际项目中,需要实现自己的ProfileService。

然后就是修改IdentityServer的配置,把角色属性暴露出去,找到 “Config.cs” 文件

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
        new IdentityResource[]
        {
            //...
            new IdentityResource("role", new string[]{JwtClaimTypes.Role })
        };
    //...
    public static IEnumerable<Client> Clients =>
        new Client[]
        {
            new Client
            {
                //...
                AllowedScopes = { "openid", "profile", "role",  "api" },
            }
        };
}

主要修改有两个,一个是增加角色的IdentityResource,名为role,另一个是将role增加到Client的scope里面,允许客户端获取角色信息。

2. Blazor相关修改

2.1 修改启动参数

主要有两个, 一个是修改 “wwwroot/appsettings.json” 如下:

{
  "Local": {
    //...
    "DefaultScopes": [
      "role",
      //...
    ],
    //...
  }
}

她的目的是登录的时候告诉IdentityServer,需要返回role给Blazor。

另一个修改是Program.cs:

public static async Task Main(string[] args)
{
    //...
    builder.Services.AddOidcAuthentication(options =>
    {
        //增加该行
        options.UserOptions.RoleClaim = "role";

    });
    //...
}

她的意思是初始化上下文的时候从ClaimType=role的claims里面初始化角色信息,只有这样才能和IdentityServer返回的ClaimType对应上(当然你也可以在IdentityServer使用其他的ClaimType,只要两边一致就行了)。

2.2 添加RedirectToLogin组件

我们把该组件放到Shared目录下.

@inject NavigationManager Navigation
@code {
    protected override void OnInitialized()
    {
        Navigation.NavigateTo($"authentication/login?returnUrl=" +
            Uri.EscapeDataString(Navigation.Uri));
    }
}

她的作用就是用来自动跳转到登录页,“authentication/login” 这个地址其实就是上一篇添加的Authentication组件,她会进一步完成与IdentityServer的交互。

2.3 修改App.razor

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData"
                                DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <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>

这里用到了AuthorizeRouteView这个组件,她可以帮忙完成授权操作,授权失败之后分两种情况:

    如果是未登录用户,则启用RedirectToLogin组件,跳到登录页。
    如果已登录,但没有足够的权限,则提示用户。

2.4 给页面添加授权

接下来就是给不同的页面添加不同的授权声明,按前面的假定条件,我们修改Counter组件,添加以下权限:

@attribute [Authorize(Roles = "admin,user")]

做过MVC的对Authorize属性一定不陌生了,她在Microsoft.AspNetCore.Authorization命名空间下,我们在_Imports.razor页面添加该引用就可以了,不需要在每个页面单独引用。
这里就是限定了该页面只有admin和user这两个角色可以访问。
修改FetchData组件,添加:

@attribute [Authorize(Roles = "admin")]

到这里已经可以看到效果了, 点击菜单访问需要授权的页面的时候会自动跳转,登录完自动返回:

 

 

 

2.5 根据角色隐藏菜单

很多时候我们都不希望用户看到不属于她的菜单,这时候就需要用到AuthorizeView组件,她一样也可以指定角色,我们修改以下菜单组件“NavMenu.razor”如下:

<AuthorizeView Roles="Admin,User">
    <li class="nav-item px-3">
        <NavLink class="nav-link" href="counter">
            <span class="oi oi-plus" aria-hidden="true"></span> Counter
        </NavLink>
    </li>
</AuthorizeView>
<AuthorizeView Roles="Admin">
    <li class="nav-item px-3">
        <NavLink class="nav-link" href="fetchdata">
            <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
        </NavLink>
    </li>
</AuthorizeView>

3. 源码

https://gitee.com/bigname65/blazor-identity-server4

 

Blazor与IdentityServer4的集成(三)-如何处理多角色的问题?

 

前一篇完成了基于角色的权限控制,但是如果一个用户有多个角色,就会有问题。

原因是因为IdentityServer的多个角色是放在同一个Claim里面,她以JSON数组的形式存在,但是前面提到的Blazor认证组件是识别不了的。

因此我们需要做一个转换,Blazor提供了方式让我们灵活的实现自己的扩展。

首先建一个自己的工厂类:

using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication.Internal;

   public class CustomUserFactory 
        : AccountClaimsPrincipalFactory<RemoteUserAccount>
    {
        public CustomUserFactory(IAccessTokenProviderAccessor accessor)
        : base(accessor)
        {
        }

        public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
            RemoteUserAccount account,
            RemoteAuthenticationUserOptions options)
        {
            var user = await base.CreateUserAsync(account, options);

            if (user.Identity.IsAuthenticated)
            {
                var identity = (ClaimsIdentity)user.Identity;
                var roleClaims = identity.FindAll(identity.RoleClaimType);

                if (roleClaims != null && roleClaims.Any())
                {
                    foreach (var existingClaim in roleClaims)
                    {
                        identity.RemoveClaim(existingClaim);
                    }

                    var rolesElem = account.AdditionalProperties[identity.RoleClaimType];

                    if (rolesElem is JsonElement roles)
                    {
                        if (roles.ValueKind == JsonValueKind.Array)
                        {
                            foreach (var role in roles.EnumerateArray())
                            {
                                identity.AddClaim(new Claim(options.RoleClaim, role.GetString()));
                            }
                        }
                        else
                        {
                            identity.AddClaim(new Claim(options.RoleClaim, roles.GetString()));
                        }
                    }
                }
            }

            return user;
        }
    }

主要就是从当前用户的声明中提取角色信息并重新组装。

然后在Program.cs中注册该工厂类就可以了

builder.Services.AddOidcAuthentication(options =>
{
    //...
}).AddAccountClaimsPrincipalFactory<CustomUserFactory>();

源码请查看:

https://gitee.com/bigname65/blazor-identity-server4

 

Blazor与IdentityServer4的集成(四)-如何实现基于权限的授权

 

前两篇(二),(三)实现了基于角色的授权,可以基于角色显示不同的页面,或者显示页面中不同的区域。但是实际项目当中,授权往往更精细,也更灵活。角色很有可能不是固定的,而是由用户自己创建,然后分配了不同的操作权限。

本篇就以这种动态权限分配的场景,来看看如何实现授权的控制。

1. 配置IdentityServer

IdentityServer作为认证与授权的中心,首先需要把权限信息暴露出来。前两篇我们增加了类型为role的Claim,这次我们再增加一个类型为permission的Claim。

修改Config.cs:

public static class Config
{
    public static IEnumerable<IdentityResource> IdentityResources =>
        new IdentityResource[]
        {
            //...
            //增加名为permission的IdentityResource,使用类型为permission的Claim
            new IdentityResource("permission",new string[]{ "permission" })
        };

    public static IEnumerable<Client> Clients =>
        new Client[]
        {
            new Client
            {
                ClientId = "blazorwasm",
                //...
                //允许客户端获取名称为permission的Resource
                AllowedScopes = { "permission",  /*others*/ },
            }
        };
}

修改Quicksart/TestUsers.cs,给不同的测试用户增加不同的Claim,这里增加了4个权限:create,retrieve,update,delete来作为演示。注意这里只是测试用户,实际项目需要实现自己的ProfileService。

public static List<TestUser> Users
{
    get
    {
        return new List<TestUser>
        {
            new TestUser
            {
                Username = "alice",
                Claims =
                {
                    //...
                    new Claim("permission","create"),
                    new Claim("permission","retrieve"),
                    new Claim("permission","update"),
                    new Claim("permission","delete")
                }
            },
            new TestUser
            {
                Username = "bob",
                Claims =
                {
                    //...
                    new Claim("permission","retrieve")
                }
            },
            new TestUser
            {
                Username = "power",
                Claims =
                {
                    //...
                    new Claim("permission","retrieve"),
                    new Claim("permission","update")
                }
            }
        };
    }
}

2. 配置Blazor客户端

2.1 修改请求的Scope

修改wwwroot/appsettings.json,这是要告诉IdentityServer返回permission信息。

{
  "Local": {
    "DefaultScopes": [
      //others
      "permission",
    ]
    //...
  }
}

2.2 修改CustomUserFactory.cs

上一篇我们增加了CustomUserFactory,目的是为了处理多个role的情况,这次的permission也一样,也会涉及到多个,因此我们用同样的方法处理就行了,修改一下,使她可以同时处理role和permission的Claim:

public async override ValueTask<ClaimsPrincipal> CreateUserAsync(
    RemoteUserAccount account,
    RemoteAuthenticationUserOptions options)
{
    var user = await base.CreateUserAsync(account, options);

    if (user.Identity.IsAuthenticated)
    {
        var identity = (ClaimsIdentity)user.Identity;
        ParseArrayClaims(account, identity, identity.RoleClaimType, options.RoleClaim);
        ParseArrayClaims(account, identity, "permission", "permission");
    }

    return user;
}

private void ParseArrayClaims(RemoteUserAccount account, 
    ClaimsIdentity identity, 
    string srcClaimType, string destClaimType)
{
    var srcClaims = identity.FindAll(srcClaimType);

    if (srcClaims != null && srcClaims.Any())
    {
        foreach (var existingClaim in srcClaims)
        {
            identity.RemoveClaim(existingClaim);
        }

        var srcEle = account.AdditionalProperties[srcClaimType];

        if (srcEle is JsonElement srcClaimValues)
        {
            if (srcClaimValues.ValueKind == JsonValueKind.Array)
            {
                foreach (var srcValue in srcClaimValues.EnumerateArray())
                {
                    identity.AddClaim(new Claim(destClaimType, srcValue.GetString()));
                }
            }
            else
            {
                identity.AddClaim(new Claim(destClaimType, srcClaimValues.GetString()));
            }
        }
    }
}

2.3 基于决策的授权

前两篇用到了Authorize属性,以及AuthorizeView组件,她们的用法如下:

[Authorize(Roles = "xxxx")]
<AuthorizeView Roles="xxx">
</AuthorizeView>

那么最重要的就是定义决策了,我们需要修改program.cs:

builder.Services.AddAuthorizationCore(option =>
{
    string[] permissions = new string[]
    {
        "create",
        "retrieve",
        "update",
        "delete"
    };
    foreach (var p in permissions)
    {
        //为每个permission生成一个决策
        option.AddPolicy(p, policy =>
        {
            //该决策需要一个对应的type=permission的Claim
            policy.RequireClaim("permission", new string[] { p });
        });
    }
});

打完收工,相关源码请查看:

https://gitee.com/bigname65/blazor-identity-server4

Blazor与IdentityServer4的集成(五)-保护你的Web Api

前面几篇集成了Blazor和IdentityServer,完成了登录以及指定页面的授权操作。本篇来实现与Web Api的集成,当然主要还是Web Api的认证操作。

在MVC模式下,通常是借助Cookie,但是Web Api更主流的还是借助Access Token(尤其是Json Web Token),具体的概念这里就不展开说了。

结合IdentityServer的例子,具体流程如下:

  •     Blazor通过IdentityServer完成登录。
  •     Blazor向IdentityServer请求一个Access Token用来访问对应的Api(默认是Json Web Token)。
  •     Blazor访问Api的时候在Http头里面带上Token。
  •     Api解析Token,并向IdentityServer验证合法性,然后完成认证操作。

1 配置IdentityServer

其实在前面的例子中,我们已经配好了IdentityServer,回顾一下IdentityServer的Config.cs文件:

我们已经配置好了ApiScopes:

public static IEnumerable<ApiScope> ApiScopes =>
    new ApiScope[]
    { 
        //这里的ApiScope就是Web Api对应的标识
        new ApiScope("api")
    };

public static IEnumerable<Client> Clients =>
    new Client[]
    {
        new Client
        {
            //对应上面的ApiScope
            AllowedScopes = {  "api" },
        }
    };

注意区分ApiScope和ApiResource, 这里的ApiScope并不会生成Access Token里面的Audience属性,因此我们设置EmitStaticAudienceClaim=true(见IdentityServer的Starup文件)来指定一个固定的Audience。
如果需要使用Api的名称作为Audience,则要使用ApiResource
参考:https://identityserver4.readthedocs.io/en/latest/topics/resources.html#authorization-based-on-scopes
When using the scope-only model, no aud (audience) claim will be added to the token, since this concept does not apply. If you need an aud claim, you can enable the EmitStaticAudience setting on the options. This will emit an aud claim in the issuer_name/resources format. If you need more control of the aud claim, use API resources.

2 添加Web Api项目

首先我们要创建一个Web Api项目,使用默认的模板就行了,她包含一个WeatherForecast的Api(即WeatherForecastController文件),为了方便测试,我们给她加上[Authorize]属性修饰符,表示该Api需要认证之后才能访问,直接访问的话你会发现她返回一个401的错误码,表示未经授权。

2.1 添加对应的包

为了能解析Blazor传递过来的Token,我们首先需要添加一个包:

Microsoft.AspNetCore.Authentication.JwtBearer

她包含了Json Web Token的相关操作。

2.2 修改Starup

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    //添加认证服务
    services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
        .AddJwtBearer(options =>
        {
            //指定IdentityServer的地址
            options.Authority = "https://localhost:5001";
            //由于我们在IdentityServer中指定了EmitStaticAudienceClaim=true(见IdentityServer的Starup文件)
            //所以Audience是固定的
            options.Audience = "https://localhost:5001/resources";
        });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    //...
    app.UseRouting();

    //添加认证与授权的中间件
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

3 修改Blazor

3.1 appsettings.json

前面的例子已经添加了appsettings.json, 并且包含了名为api的Scope,对应IdentityServer里面声明的ApiScope。

{
  "Local": {
    "Authority": "https://localhost:5001",
    "ClientId": "blazorwasm",
    "DefaultScopes": [
      "role",
      "permission",
      "api"
    ],
    "PostLogoutRedirectUri": "/",
    "ResponseType": "code"
  }
}

3.2 添加包

涉及到一些Http扩展,我们需要添加包:

Microsoft.Extensions.Http

3.3 添加MessageHandler

这是一个自定义的消息处理程序,其实就是指定了访问Api的时候需要附带哪个scope的认证。

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;

namespace BlazorWasm
{
    public class CustomAuthorizationMessageHandler : AuthorizationMessageHandler
    {
        public CustomAuthorizationMessageHandler(IAccessTokenProvider provider,
            NavigationManager navigationManager)
            : base(provider, navigationManager)
        {
            ConfigureHandler(
                //这是Web Api的根地址
                authorizedUrls: new[] { "https://localhost:5002" },
                //对应Api Scope, 表示请求上面的Web Api之前需要先获取该Scope对应的Access Token,并附在Http头里面
                scopes: new[] { "api"});
        }
    }
}

3.4 修改Program.cs

注册上面的自定义处理程序

builder.Services.AddScoped<CustomAuthorizationMessageHandler>();
builder.Services.AddHttpClient("ServerAPI",
        //修改HttpClient的根地址
        client => client.BaseAddress = new Uri("https://localhost:5002"))
        //声明使用以上自定义的处理程序
    .AddHttpMessageHandler<CustomAuthorizationMessageHandler>();
builder.Services.AddScoped(sp => sp.GetRequiredService<IHttpClientFactory>()
    .CreateClient("ServerAPI"));

3.5 修改默认的FetchData页面

只要把请求的URL改成Web Api的相对地址就行了:

protected override async Task OnInitializedAsync()
{
    forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("WeatherForecast");
}

到这里已经可以运行了,具体的源代码在:

https://gitee.com/bigname65/blazor-identity-server4

4 关于Token的过期时间

Access Token都有过期时间,默认通常是一个小时,但是我们不需要为这个担心,上面的处理程序已经帮我们处理好了一切,当Token过期的时候,她会自动请求一个新的。

Blazor与IdentityServer4的集成(六)-Blazor Server如何使用Identity Server?

前面几篇都是基于Blazor WebAssembly 的,这次来尝试一下Blazor的Server模式。根据官方的介绍,Blazor Server的认证方式其实和MVC的认证方式是一样的,同时Identity Server 也有专门介绍MVC的集成:
https://identityserver4.readthedocs.io/en/latest/quickstarts/2_interactive_aspnetcore.html#creating-an-mvc-client
那么这里就以此为基础来开始我们的代码。

1. Blazor Server

首先当然是创建一个Blazor Server项目,使用默认模板就行,然后还要添加包:Microsoft.AspNetCore.Authentication.OpenIdConnect, 她将用来与Identity Server的交互。

1.1 修改Startup.cs

Startup文件,基本上就是根据Identity Server的官方指引来修改:

public void ConfigureServices(IServiceCollection services)
{
    //...

    //添加认证相关的服务
    JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
            
    services.AddAuthentication(options =>
    {
        options.DefaultScheme = "Cookies";
        options.DefaultChallengeScheme = "oidc";
    })
        .AddCookie("Cookies")
        .AddOpenIdConnect("oidc", options =>
        {
            //Identity Server 的地址
            options.Authority = "https://localhost:5001";
            //Identity Server配置的Client 以及 Secret
            options.ClientId = "blazorserver";
            options.ClientSecret = "secret";
            //认证模式
            options.ResponseType = "code";
            //保存token到本地
            options.SaveTokens = true;
            
        });

}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    //...

    app.UseRouting();

    //添加认证与授权中间件
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapDefaultControllerRoute().RequireAuthorization();
        endpoints.MapBlazorHub();
        endpoints.MapFallbackToPage("/_Host");
    });
}

1.2 修改App.razor

其实跟WebAssembly是一样的,都用到了CascadingAuthenticationState和AuthorizeRouteView

<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData"
                                DefaultLayout="@typeof(MainLayout)">
                <NotAuthorized>
                    @if (!context.User.Identity.IsAuthenticated)
                    {
                        <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>

1.3 添加RedirectToLogin组件

App.razor 里面用到了该组件,这个系统是没有的,得我们自己添加,代码如下:

@inject NavigationManager Navigation
@code {
    protected override void OnAfterRender(bool firstRender)
    {
        Navigation.NavigateTo($"account/login?returnUrl={Uri.EscapeDataString(Navigation.Uri)}", true);
    }
}

也很简单,就是跳转到account/login页面,但是需要注意的是这个login并不是一个Blazor组件,而是一个Controller(MVC模式),至于能不能不用Controller,而是像WebAssembly一样使用纯粹的Blazor的模式,这个笔者到目前为止还不清楚。

1.4 添加AccountController

接上文,我们需要添加一个Controller来处理登录登出操作,其实作用也就是构造链接跳转到Identity Server,这个中间件都已经帮我们做了,所以代码也很少:

using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

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 props = new AuthenticationProperties
        {
            RedirectUri = returnUrl
        };

        return Challenge(props, "oidc");
    }

    [HttpGet]
    public async Task<IActionResult> Logout()
    {
        if (User?.Identity.IsAuthenticated == true)
        {
            // delete local authentication cookie
            await HttpContext.SignOutAsync();

        }

        return SignOut(new AuthenticationProperties { RedirectUri = "/" }, "oidc");
    }
}

2. Identity Server

Identity Server 我们还是使用之前WebAssembly的项目,添加一个新的Client就行:

public static IEnumerable<Client> Clients =>
    new Client[]
    {
        new Client
        {
            //唯一id,用来区分不同的Client
            ClientId = "blazorserver",
            //使用的授权方式
            AllowedGrantTypes = GrantTypes.Code,
            //这里设置安全码,当然也可以不指定
            ClientSecrets = { new Secret("secret".Sha256()) },
            //Blazor运行时的URL
            AllowedCorsOrigins =     { "https://localhost:5005" },
            //登录成功之后将要跳转的Blazor的URL
            RedirectUris = { "https://localhost:5005/signin-oidc" },
            //登出之后将要跳转的Blazor的URL
            PostLogoutRedirectUris = { "https://localhost:5005/signout-callback-oidc" },
            //允许的Scope,openid包含用户id,profile包含用户基本资料
            AllowedScopes = { "openid", "profile", "role","permission"},
        }
    };

到这里已经可以实现登录操作了,就这么简单。

3. 关于授权

前面的代码只完成了认证操作,你如果想在当前用户的Claim里面获取基本资料或者授权信息,不好意思,是没有的,因此我们还需要做一些额外的工作。

还是修改Blazor项目的Startup文件

services.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
    .AddCookie("Cookies")
    .AddOpenIdConnect("oidc", options =>
    {
        //...
        //添加以下代码
        //指定从Identity Server的UserInfo地址来取Claim
        options.GetClaimsFromUserInfoEndpoint = true;
        //指定要取哪些资料(除Profile之外,Profile是默认包含的)
        options.Scope.Add("role");
        options.Scope.Add("permission");
        //这里是个ClaimType的转换,Identity Server的ClaimType和Blazor中间件使用的名称有区别,需要统一。
        options.TokenValidationParameters.RoleClaimType = "role";
        options.TokenValidationParameters.NameClaimType = "name";
        options.Events.OnUserInformationReceived = (context) =>
            {
                //回顾之前关于WebAssembly的例子,涉及到数组的转换,这里也一样要处理

                ClaimsIdentity claimsId = context.Principal.Identity as ClaimsIdentity;

                var roleElement = context.User.RootElement.GetProperty("role");
                if (roleElement.ValueKind == System.Text.Json.JsonValueKind.Array)
                {
                    var roles = context.User.RootElement.GetProperty("role").EnumerateArray().Select(e =>
                    {
                        return e.ToString();
                    });
                    claimsId.AddClaims(roles.Select(r => new Claim("role", r)));
                }
                else
                {
                    claimsId.AddClaim(new Claim("role", roleElement.ToString()));
                }

                var permissionElement = context.User.RootElement.GetProperty("permission");
                if (permissionElement.ValueKind == System.Text.Json.JsonValueKind.Array)
                {
                    var permissions = permissionElement.EnumerateArray().Select(e =>
                    {
                        return e.ToString();
                    });
                    claimsId.AddClaims(permissions.Select(p => new Claim("permission", p)));
                }
                else
                {
                    claimsId.AddClaim(new Claim("permission", permissionElement.ToString()));
                }


                return Task.CompletedTask;
            };
    });

// 这里是基于决策的授权操作,WebAssembly的例子中有相关的说明,Blazor Server的使用方式也一样
services.AddAuthorizationCore(option =>
{
    string[] permissions = new string[]
    {
        "create",
        "retrieve",
        "update",
        "delete"
    };
    foreach (var p in permissions)
    {
        option.AddPolicy(p, policy =>
        {
            policy.RequireClaim("permission", new string[] { p });
        });
    }
});

好了,这样我们就可以在当前用户的Claim里面取到基本资料以及角色,权限等信息了,那么应该如何使用?答案是和WebAssembly一样的,你可以使用@attribute来添加Authorize属性,也可以使用AuthorizeView组件。

完整的源代码可以查看以下地址:

https://gitee.com/bigname65/blazor-identity-server4



posted @ 2021-03-01 12:08  树欲静·而风不止  阅读(2251)  评论(0编辑  收藏  举报