[Blazor][筆記][權限] Blazor 自訂驗證與授權

系統的驗證與授權,這是每一代新技術出來的時候都要面對的課題,從.NET Framework 2.0時代的 MemberShip,到後來的Identity,雖然開發工具都包了一個簡單產生對應資料表的機制來讓過程簡化,但是,實際上小喵我從來都不是用他精靈產生的資料表,而是配合當下的技術來產生一套可以搭配自訂資料表的權限控管。不免俗的,Blazor的到來,也要來嘗試著找出,不使用內建的方式建立資料表,而是想辦法配合自己的資料表來運作權限控管。

有別於 WebForm 與 MVC 在 web.config 中設定<authentication><authenlization>即可運作,Blazor的機制複雜了一些,因此特別寫此篇來筆記一下這個過程,未來再以此篇的筆記結果,來衍生符合自己需求的一套機制。相關內容,就一起看下去吧~

緣起

系統的驗證與授權,這是每一代新技術出來的時候都要面對的課題,從.NET Framework 2.0時代的 MemberShip,到後來的Identity,雖然開發工具都包了一個簡單產生對應資料表的機制來讓過程簡化,但是,實際上小喵我從來都不是用他精靈產生的資料表,而是配合當下的技術來產生一套可以搭配自訂資料表的權限控管。不免俗的,Blazor的到來,也要來嘗試著找出,不使用內建的方式建立資料表,而是想辦法配合自己的資料表來運作權限控管。

有別於 WebForm 與 MVC 在 web.config 中設定<authentication><authenlization>即可運作,Blazor的機制複雜了一些,因此特別寫此篇來筆記一下這個過程,未來再以此篇的筆記結果,來衍生符合自己需求的一套機制。相關內容,就一起看下去吧~

參考

這篇的過程,是參考小喵找到的一個Blazor教學系列影片中的其中一篇,相關內容算是很完整,小喵很推薦有興趣研究Blazor的朋友可以去參考該作者的一些文章或者影片

完整的系列教學影片列表: Blazor C# Tuorials (Youtube)
自訂權限控管:Blazor Tutorial : Authentication | Custom AuthenticationStateProvider - EP12

建立 Blazor伺服器應用程式

首先,先建立一個空的 Blazor應用程式。記得驗證要【無驗證】,Https的部分暫時取消

我們參考YT影片中,他主要是以伺服器應用程式的專案來處理,並且為了讓這個由無到有的建立過程筆記,所以我們建立一個空白的Blazor伺服器應用程式。並確保驗證的部分使用無驗證。

StartUp.cs設定使用驗證與授權

依據影片的指導,我們在【Startup.cs】中,新增以下的內容,來使用Authentication, Autorization

app.UseAuthentication();
app.UseAuthorization();

Blazored.SessionStorage

系統登入後,如果按了重新整理時,會發現原本已經登入的狀態又變成登出,因此我們需要把登入的狀態,存在 HTML5 中 WebStorage 的 SessionStorage 中,關於這部分,有神人已經寫好相關的機制,我們只需透過 Nuget 把相關機制裝起來,這樣就可以方便處理儲存、讀取、清除 SessionStoarge 的部分。

安裝後,需要在【Startup.cs】中,加上相關的程式碼

namespace,加上using

using Blazored.SessionStorage;

在ConfigureServices中,加上

services.AddBlazoredSessionStorage();

撰寫使用者資料物件類別(ViewModel) UserVM

這裡先暫時先滿足【登入】所需的ViewModel,後續再逐步的完整這個類別

我們在Data資料夾,新增一個名稱為【UserVM】的類別,相關內容如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace BlazorAuthH20201119.Data
{
    public class UserVM
    {
        public string UserId { get; set; } = "";
        public string PW { get; set; } = "";
        public string UserName { get; set; } = "";
        public string EMail { get; set; } = "";
        public string UserToken { get; set; } = "";

    }
}

 

撰寫自訂的【CustomAuthenticationStateProvider】

小喵希望把資料存取相關的,撰寫在DAOs這個資料夾
所以,在專案中,新增一個資料夾【DAOs】,並在該資料夾,新增一個類別,名稱就叫做【CustomAuthenticationStateProvider】,繼承【CustomAuthenticationStateProvider】,並實作。在這個類別中,登入、登出,都會寫在裡面。

這裡面,會:

  1. 透過Blazored.SessionStorage,來存放登入後的帳號
  2. 透過ClaimIdentity,來處理相關的登入登出相關的程式碼。

相關的內容如下:

using Microsoft.AspNetCore.Components.Authorization;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Blazored.SessionStorage;
using BlazorAuthH20201119.Data;


namespace BlazorAuthH20201119.DAO
{
    public class CustomAuthenticationStateProvider : AuthenticationStateProvider
    {
        private ISessionStorageService _sessionStorageService;

        public CustomAuthenticationStateProvider(ISessionStorageService sessionStorageService)
        {
            _sessionStorageService = sessionStorageService;
        }

        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            string UserToken = await _sessionStorageService.GetItemAsync<string>("UserToken");

            ClaimsIdentity identity;

            if (UserToken != null)
            {
                if (UserToken != "")
                {
                    UserVM oUser = getUserByUserToken(UserToken);

                    if (oUser.UserId != "")
                    {
                        identity = new ClaimsIdentity(new[] {
                            new Claim("UserToken",oUser.UserToken),
                            new Claim(ClaimTypes.Name,oUser.UserName),
                            new Claim(ClaimTypes.Email,oUser.EMail),
                            new Claim("UserId",oUser.UserId),
                        }, "apiauth_type");
                    }
                    else 
                    {
                        identity = new ClaimsIdentity();
                    }
                }
                else
                {
                    identity = new ClaimsIdentity();
                }
            }
            else
            {
                identity = new ClaimsIdentity();
            }

            var user = new ClaimsPrincipal(identity);
            return await Task.FromResult(new AuthenticationState(user));
        }

        private UserVM getUserByUserToken(string UserToken)
        {
            UserVM oUser = new UserVM();
            if (UserToken == "94607556-FE6D-46DB-93B8-42873170404E")
            {
                oUser = fakeSetUserData();
            }
            return oUser;
        }

        public void MarkUserAsLogin(UserVM oUser)
        {
            ClaimsIdentity identity;

            if (oUser != null)
            {
                if (doLogin(ref oUser))
                {
                    identity = new ClaimsIdentity(new[] {
                        new Claim("UserToken",oUser.UserToken),
                        new Claim(ClaimTypes.Name,oUser.UserName),
                        new Claim(ClaimTypes.Email,oUser.EMail),
                        new Claim("UserId",oUser.UserId),
                    }, "apiauth_type");
                    _sessionStorageService.SetItemAsync<string>("UserToken", oUser.UserToken);
                }
                else
                {
                    identity = new ClaimsIdentity();
                }
            }
            else
            {
                identity = new ClaimsIdentity();
            }

            var user = new ClaimsPrincipal(identity);
            NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
        }

        private bool doLogin(ref UserVM oUser)
        {
            bool rc = false;
            //模擬資料庫驗證帳號密碼,取得User資訊
            if ((oUser.UserId == "topcat") && (oUser.PW == "abc123"))
            {
                //模擬驗證成功,將使用者資訊放入oUser中
                oUser = fakeSetUserData();
                rc = true;
            }
            else
            {
                throw new Exception("登入失敗,請確認您的帳號或密碼是否正確");
            }
            return rc;
        }

        /// <summary>
        /// 模擬從資料庫取資料放入UserVM物件中
        /// </summary>
        /// <returns>
        /// 成功回傳UserVM物件
        /// </returns>
        private UserVM fakeSetUserData()
        {
            UserVM oUser = new UserVM();
            oUser.UserId = "topcat";
            oUser.PW = "abc123";
            oUser.UserName = "小喵";
            oUser.EMail = "topcat@aaa.bb.cc";
            oUser.UserToken = "94607556-FE6D-46DB-93B8-42873170404E";
            return oUser;
        }

        public void MarkUserAsLogout()
        {
            ClaimsIdentity identity = new ClaimsIdentity();
            identity = new ClaimsIdentity();
            var user = new ClaimsPrincipal(identity);
            _sessionStorageService.ClearAsync();
            NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(user)));
        }

    }
}

這裡面要稍微說明一下的是,這篇只是用以說明開發的過程,實際上由於存放的方式是使用 WebStorage ,其實 Client 端可以透過很簡單的方式去改變這個內容,所以實際上不應該拿登入的帳號當作是個人的識別,而是應該以Token的方式來做為身分的識別,才是比較恰當的方式。在此特別聲明一下,實際上應該要更嚴謹的方式處理登入狀態的儲存。

 

CustomAuthenticationStateProvider加入Startup.cs

在【ConfigureServices】這裡面加上【services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();】

到此,Startup.cs的內容大致完成,相關內容範例如下:

using BlazorAuthH20201119.DAOs;
using BlazorAuthH20201119.Data;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Blazored.SessionStorage;

namespace BlazorAuthH20201119
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddSingleton<WeatherForecastService>();
            
            services.AddBlazoredSessionStorage();

            services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }

            app.UseStaticFiles();

            app.UseRouting();

            app.UseAuthentication();
            app.UseAuthorization();

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

 

修改App.razor

將RouteView改成【AuthorizeRouteView】,NotFound的部分用【CascadingAuthenticationState】包起來,相關內容如下:

<Router AppAssembly="@typeof(Program).Assembly">
    <Found Context="routeData">
        <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <CascadingAuthenticationState>
            <LayoutView Layout="@typeof(MainLayout)">
                <h1>404 Error</h1>
                <p>Sorry, there's nothing at this address.</p>
            </LayoutView>
        </CascadingAuthenticationState>
    </NotFound>
</Router>

 

修改【Pages/_Host.cshtml】

將【component】的 render-mode 改為【Server】

<component type="typeof(App)" render-mode="Server" />

 

建立Login.razor

我們建立登入的頁面,讓帳號密碼綁上UserVM,並且透過CustomAuthenticationStateProvider來進行登入的工作。相關程式碼如下:

@page "/Login"
@using BlazorAuthH20201119.Data
@using BlazorAuthH20201119.DAO
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager
@inject IJSRuntime JsRuntime;

<div class="row">
    <div class="col-sm-1"></div>
    <div class="col-sm-10">
        <div class="card border-primary mb-3">
            <div class="card-header"><h2>Login</h2></div>
            <div class="card-body">
                <fieldset>
                    <div class="form-group row">
                        <label for="txtUserId" class="col-sm-2 col-form-label">帳號:</label>
                        <div class="col-sm-10">
                            <input type="text" class="form-control" id="txtUserId" @bind="oUser.UserId">
                        </div>
                    </div>
                    <div class="form-group row">
                        <label for="txtPW" class="col-sm-2 col-form-label">帳號:</label>
                        <div class="col-sm-10">
                            <input type="password" class="form-control" id="txtPW" @bind="oUser.PW">
                        </div>
                    </div>
                </fieldset>
            </div>
            <div class="card-footer text-right">
                <button class="btn btn-primary" @onclick="(()=>doLogin())">登入</button>
            </div>
        </div>

    </div>
    <div class="col-sm-1"></div>
</div>

@code {
    private UserVM oUser = new UserVM();

    private void doLogin()
    {
        try
        {
            ((CustomAuthenticationStateProvider)AuthenticationStateProvider).MarkUserAsLogin(oUser);
            NavigationManager.NavigateTo("/");
        }
        catch (Exception ex)
        {
            JsRuntime.InvokeVoidAsync("alert", ex.Message);

        }
    }

}

 

修改首頁(Index.razor)的內容

透過AuthorizeView,針對【登入】與【未登入】的狀態,來設定不同的內容,相關程式碼如下:

@page "/"

<h1>Hello, world!</h1>
<AuthorizeView>
    <Authorized>
        歡迎您(@context.User.Identity.Name)使用本系統~
    </Authorized>
    <NotAuthorized>
        您目前已經登出
    </NotAuthorized>
</AuthorizeView>


<SurveyPrompt Title="How is Blazor working for you?" />

 

修改上方共同的登入/登出按鈕

修改【/Shared/MainLayout.razor】,登入時放上【登出】按鈕,登出時放上【登入】按鈕,相關內容如下:

@inherits LayoutComponentBase
@using BlazorAuthH20201119.DAO
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject NavigationManager NavigationManager

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <div class="main">
        <div class="top-row px-4">
            <AuthorizeView>
                <Authorized>
                    <button @onclick="(()=>doLogout())" class="btn btn-secondary">登出</button>
                    
                </Authorized>
                <NotAuthorized>
                    <button @onclick="(()=>goLogin())" class="btn btn-secondary">登入</button>
                </NotAuthorized>
            </AuthorizeView>
            <a href="https://docs.microsoft.com/aspnet/" target="_blank">About</a>
        </div>

        <div class="content px-4">
            @Body
        </div>
    </div>
</div>


@code{

    public void doLogout()
    {
        ((CustomAuthenticationStateProvider)AuthenticationStateProvider).MarkUserAsLogout();
        NavigationManager.NavigateTo("/");
    }

    public void goLogin()
    {
        NavigationManager.NavigateTo("/Login");
    }

}

末記

這篇筆記Blazor搭配自訂登入驗證的方式,裡面的內容為了讓測試的人可以直接上手,裡面用直接的程式碼替代資料庫存取的部分,實際應用時,需改寫相關的程式碼驗證資料庫中的資料。

 

 

 


以下是簽名:


Microsoft MVP
Visual Studio and Development Technologies
(2005~2019/6) 
topcat
Blog:http://www.dotblogs.com.tw/topcat