Blazor Server App 驗證與授權

本文討論與記錄『Blazor Cookie-Base 驗證』。

從結論開始

本文討論與記錄『Blazor Cookie-Base 驗證』。

Blaozr 的登入框架以ASP.NET Core登入框架為基礎。也就是要有ASP.NET Core登入框架的前置知識。

在驗證(Authenticate)部份全部使用ASP.NET Core登入框架,但是只能在Page(.cshtml檔)處理驗證。不能在元件(.blaozr檔)處理驗證這產生了點隔閡,要花心思調校登入程序縮小隔閡。

在授權(Autorize)部份有相應設計,還滿好用易用的。

Identity 驗證 vs Cookie-Base 驗證

Identity 驗證

  • 特性:已是成品。UI與DB與程式碼都寫好了。

  • 優點:功能完整且強大,只要知道怎麼用即可。

  • 缺點:不易改寫程序,因為東西很多。

  • 適用:網站自己獨立的認證系統就很方便。

  • 其他:網路上文章很多。實作基礎仍用到『Auth. Cookie』。

※ 補充文件(未讀收先藏) on 2022-11-24

完整的 identity 認證與授權介紹用於 Razer Page。

Cookie-Base 驗證

  • 特性:全部自己來。

  • 優點:可以很輕量。可以整合外部來的SSO驗證。

  • 缺點:全部自己來,要自己組織完整的流程。

  • 適用:有SSO驗證需求。

此例選『Cookie-Base驗證』,因為SSO驗證比較符合現在的商業環境。

驗證(Authenticate)框架

因為只能在Page(.cshtml檔)處理Authenticate,只好調校登入設計。 有看到文件說是因為SignalR通訊特性下無法處理 Cookie-Base Auth 註冊。

跳過過程…

調校結果原則,驗證三大步驟:

  1. Autheticate

    • 可以在元件(.blaozr)處理。

    • 檢查帳號、密碼、圖形驗證碼等等。

  2. Authorize

    • 可以在元件(.blaozr)處理。

    • 取得登入者的個人與授權資料(AuthUser)。

    • 儲放AuthUser,建議放在Database。 (註:Session因綜多考量已不再使用。)

  3. 註冊Auth-Cookie

    • 只能在Page(.cshtml)處理註冊Auth-Cookie

    • 向browser註冊Auth-Cookie,名稱預設為『.AspNetCore.Cookies』。

    • 用 HTTP GET 送指令,為了安全GET參數用JWT加密包裝。

    • 驗證完成後要重導(returnUrl)頁面。

授權(Autorize)框架

網路上文章很多,首先要決定是 Role base 或是 Polyce base。 指令有:@attribute [Authorize]、、async Task。 最關鍵的部份是:Custom-AuthenticationStateProvider。

AuthenticationStateProvider的功能說明

  • Blazor 認證資料來自 HttpContext.User ,我們不建議直接用。

  • 使用者透過<AuthorizeView />CascadingAuthenticationState使用認證資料。

  • 認證資料送達中間透過AuthenticationStateProvider傳遞,這樣就能上下其手,這樣我們就能加值認證資料。

AuthenticationStateProvider service (引用)

  • Blazor has a built-in service called AuthenticationStateProvider service.

  • This service obtains authentication state data from ASP.NET Core's HttpContext.User.

  • This is how authentication state integrates with existing ASP.NET Core authentication mechanisms.

  • It is this service that is used by AuthorizeView component and CascadingAuthenticationState component to get the authentication state.

  • Don't use AuthenticationStateProvider directly. Use the AuthorizeView component.

  • The main drawback to using AuthenticationStateProvider directly is that the component isn't notified automatically if the underlying authentication state data changes.

※ 補充文件(未讀收先藏) on 2022-11-24

以 ProtectedSessionStorage 為基底(標準答案是以 HttpContext 為基底)確定認證並授權。

Blazor Server Custom Authentication [Blazor Tutorial C# - Part 11]

授權檢查之 Role base vs Policy base

Authorize - Role base

  • 實作比較簡單。指定角色即可。

Authorize - Policy base

  • 特性:可程式化。

  • 較有彈性。

  • 實作很麻煩,繞好幾圈。

關鍵程式碼

加值授權角色 客制 CustomAuthenticationStateProvider 取代預設的 AuthenticationStateProvider

CustomAuthenticationStateProvider.cs
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
using System.Threading.Tasks;

namespace BlazorServerTutor.Services
{
    /// Blazor Tutorial : Authentication | Custom AuthenticationStateProvider - EP12
    /// 請參考: https://www.youtube.com/watch?v=BmAnSNfFGsc&list=PL4WEkbdagHIR0RBe_P4bai64UDqZEbQap&index=12&ab_channel=CuriousDrive
    /// AuthenticationStateProvider vs AuthenticationState
    /// 請參考: https://www.eugenechiang.com/2020/12/26/authenticationstateprovider-vs-authenticationstate/

    /// ※原則上不建議直接使用 Custom-AuthenticationStateProvider 
    public class CustomAuthenticationStateProvider : AuthenticationStateProvider
    {
        readonly HttpContextAccessor _httpAccessor;
        readonly AccountService _accountSvc;

        public CustomAuthenticationStateProvider(HttpContextAccessor httpAccessor, AccountService accountSvc)
        {
            _httpAccessor = httpAccessor;
            _accountSvc = accountSvc;
        }

        public override async Task<AuthenticationState> GetAuthenticationStateAsync()
        {
            /// 現在使用者(HttpContext.User)
            var user = _httpAccessor.HttpContext.User;

            /// 必需已驗證
            if (user.Identity.IsAuthenticated && user.Identity is ClaimsIdentity)
            {
                /// 取得完整的登入者資料
                var authUser = _accountSvc.GetAuthDataFromPool(user.Identity.Name);
                if (authUser != null && authUser.AuthFuncList.Length > 0)
                {
                    /// FuncId 授權資料也併入Role清單。
                    /// 此處的 Roles 數量可以上百筆,不會影響 Auth Cookie。
                    /// 對 <AuthorizeView />, Task<AuthenticationState> 會多出這裡加入的角色。

                    var claimsIdentity = (ClaimsIdentity)user.Identity;

                    ///※ 加值 UserData 依完整的登入者資料(AuthUser) 
                    claimsIdentity.AddClaim(new Claim(ClaimTypes.UserData, JsonSerializer.Serialize(authUser)));

                    ///※ 加值 Role 數量
                    foreach (var funcId in authUser.AuthFuncList)
                        claimsIdentity.AddClaim(new Claim(ClaimTypes.Role, funcId));
                }
            }

            //var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
            return await Task.FromResult(new AuthenticationState(user));
        }
    }
}

也要註冊 CustomAuthenticationStateProvider 才能有效用。

Startup.cs
public class Startup
{
	...
	public void ConfigureServices(IServiceCollection services)
	{
	   /// for BLAZOR COOKIE Auth
     /// ref → https://blazorhelpwebsite.com/ViewBlogPost/36
     services.Configure<CookiePolicyOptions>(options =>
     {
       options.CheckConsentNeeded = context => true;
       options.MinimumSameSitePolicy = SameSiteMode.Lax; // SameSiteMode.None;
     });
     services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
         .AddCookie(); 
                
		...
		...
		
		/// ※ 註冊 CustomAuthenticationStateProvider 以取代預設行為。 
		services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();

		/// 註冊登入授權服務
		services.AddSingleton<AccountService>();
		
		...
		...
		
		/// for BLAZOR COOKIE Auth
    services.AddHttpContextAccessor();
    services.AddScoped<HttpContextAccessor>();
    //services.AddHttpClient();
    //services.AddScoped<HttpClient>();
	}
	
	// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
  public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
  {
    ...

    /// for BLAZOR COOKIE Auth
    app.UseCookiePolicy();
    app.UseAuthentication();
    app.UseAuthorization();
    
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapBlazorHub();
      endpoints.MapFallbackToPage("/_Host");
    });
  }	
}

系統進入點也要改寫用 CascadingAuthenticationState 再包裏一層才能取得授權狀態

App.razor
<CascadingAuthenticationState>
    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" >
                <Authorizing>
                    <h1>Authorizing</h1>
                    <p>You are being authorized...</p>
                </Authorizing>
                <NotAuthorized>
                    <h1>401 NotAuthorized</h1>
                    <p>You cannot see this.</p>
                </NotAuthorized>
            </AuthorizeRouteView>
        </Found>
        <NotFound>
            <LayoutView Layout="@typeof(NoLayout)">
                <h1>404 NotFound!</h1>
                <p>Your princess is in another castle.</p>
            </LayoutView>
        </NotFound>
    </Router>
</CascadingAuthenticationState>

授權檢查應用

AuthExample.razor
@page "/auth-example"
@attribute [Authorize(Roles = "Admin,AP0101")] ///*--- 用 Role base 檢查
@inject AccountService accoutSvc

<h1>AP0101: Auth Example</h1>

<AuthorizeView Roles="Admin">  ///*--- 用 Role base 檢查
    <Authorized>
        <h2>登入狀態 : Admin</h2>
        <b>我有管理者(Admin)權限。</b>
    </Authorized>
</AuthorizeView>

<h2>登入狀態 : AuthorizeView</h2>
<AuthorizeView>
    <Authorized>
        <b>Hello, @context.User.Identity.Name!</b>
        <p>
            Roles: @(String.Join(",",context.User.Claims.Where(c => c.Type == System.Security.Claims.ClaimTypes.Role).Select(c => c.Value)))
        </p>
    </Authorized>
    <Authorizing>
        <p>登入中</p>
    </Authorizing>
    <NotAuthorized>
        <p>未登入</p>
    </NotAuthorized>
</AuthorizeView>

<h2>登入狀態 : AuthUser</h2>
<pre>
    @Utils.JsonSerialize(userInfo, true, true)
</pre>

@code{
    [CascadingParameter]  ///*--- 檢查
    public Task<AuthenticationState> AuthStateTask { get; set; }

    AccountService.AuthUser userInfo = null;

    protected override void OnAfterRender(bool firstRender)
    {
        if (firstRender)
        {
            var authState = AuthStateTask.Result;
            if (authState.User.Identity.IsAuthenticated)
            {
                // 取完整的登入者資料
                userInfo = accoutSvc.UnpackUserClaimsData(authState.User.Identity);
                //※ 在 OnAfterRender 需下強制刷新指令
                StateHasChanged();
            }
        }
    }
}

登入畫面與主程序

Login.razor
@page "/login"
@attribute [AllowAnonymous]
@inject AccountService accountSvc
@inject IJSRuntime jsr

<h3>Login</h3>

<AuthorizeView>
    <Authorized>
        <b>Hello, @context.User.Identity.Name!</b>
        <a class="ml-md-auto btn btn-primary"
           href="/Signout"
           target="_top">Logout</a>
    </Authorized>
    <Authorizing>
        <p>登入中</p>
    </Authorizing>
    <NotAuthorized>
        <input type="text" placeholder="帳號" @bind="@userId" />
        &nbsp;&nbsp;
        <input type="password" placeholder="密碼" @bind="@mima" />
        &nbsp;&nbsp;
        <input type="text" placeholder="驗證碼" @bind="@vcode" />

        <button class="btn btn-primary" @onclick="HandleLogin">登入</button>

        <a class="d-none" @ref="signinElement" href="@loginCommand"></a>
    </NotAuthorized>
</AuthorizeView>

@code{
    string userId = "smart";
    string mima = "mima";
    string vcode = "123456";

    string loginCommand = "/";
    ElementReference signinElement;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (loginCommand != "/")
        {
            ///*--- 登入主程序:3.註冊Auth-Cookie
            // 畫面刷新後,若已準備好 loginCommand 就觸發登入動作。
            await jsr.InvokeVoidAsync("invokeClick", signinElement);
        }
    }

    async Task HandleLogin()
    {
        ///*--- 登入主程序:1.Authenticate
        string ticketToken = accountSvc.Authenticate(userId, mima, vcode, null);
        if (ticketToken == null) {
            await jsr.InvokeVoidAsync("showAlert", "登入認證失敗!");
            return;
        }

        ///*--- 登入主程序:2.Authorize
        var authUser = accountSvc.Authorize(userId);

        // 準備好登入指令 loginCommand
        loginCommand = $"/Signin?tid={encode(ticketToken)}";
        // ※ 畫面刷新後 loginCommand 才會生效。
    }

    private string encode(string param)
    {
        return System.Web.HttpUtility.UrlEncode(param);
    }
}

Account Service

AccountService.cs
using Jose;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Text;
using System.Text.Json;

namespace BlazorServerTutor.Services
{
    /// <summary>
    /// 請使用 AddSingleton Injection.
    /// </summary>
    public class AccountService
    {
        #region resource

        public record Ticket
        {
            public Guid ticketId;
            public string userId;
            public string returnUrl;
            public DateTime expires;
        }

        public record AuthUser
        {
            public string UserId { get; init; }
            public string UserName { get; init; }
            public string DeptId { get; init; }
            public string DeptName { get; init; }
            public string Email { get; init; }
            public string[] Roles { get; init; }
            public string[] AuthFuncList { get; init; }
            public Guid AuthGuid { get; set; }
            public DateTimeOffset IssuedUtc { get; set; }
            public DateTimeOffset ExpiresUtc { get; set; }
        }
        #endregion

        //# Injection Member
        readonly ILogger<AccountService> _logger;
        readonly IConfiguration _config;
        readonly IMemoryCache _cache;

        /// <summary>
        /// 門票緩衝池
        /// </summary>
        readonly Dictionary<Guid, Ticket> _ticketPool = new();
        readonly object _lockObj = new object();

        /// <summary>
        /// ctor
        /// </summary>
        public AccountService(ILogger<AccountService> logger, IConfiguration config, IMemoryCache cache)
        {
            _logger = logger;
            _config = config;
            _cache = cache;
        }

        protected string PackageTicketToken(Guid ticketId)
        {
            // 有AES256加密與HASH, 密碼需32字元256bit
            byte[] key256 = Encoding.UTF8.GetBytes(_config["Jwt:Key256"]);
            string token = JWT.Encode(ticketId, key256, JweAlgorithm.A256KW, JweEncryption.A256CBC_HS512);
            return token;
        }

        protected Guid UnpackageTicketToken(string token)
        {
            // 有AES256加密與HASH, 密碼需32字元256bit
            byte[] key256 = Encoding.UTF8.GetBytes(_config["Jwt:Key256"]);
            var ticketId = JWT.Decode<Guid>(token, key256);
            return ticketId;
        }

        /// <summary>
        /// 認證檢查
        /// </summary>
        public string Authenticate(string userId, string credential, string vcode, string returnUrl)
        {
            if (String.IsNullOrWhiteSpace(userId))
                return null;

            if (String.IsNullOrWhiteSpace(credential))
                return null;

            //## verify vcode;
            if (!"123456".Equals(vcode))
                return null;

            //## 驗證帳號與密碼
            // ...先假設成功...

            //## 製作 ticket
            var ticket = new Ticket
            {
                ticketId = Guid.NewGuid(),
                userId = userId,
                returnUrl = returnUrl,
                expires = DateTime.Now.AddSeconds(5)
            };

            lock (_lockObj)
            {
                _ticketPool.Add(ticket.ticketId, ticket);
            }

            //# success
            string ticketToken = this.PackageTicketToken(ticket.ticketId);
            return ticketToken;
        }

        /// <summary>
        /// 取得授權資料
        /// </summary>
        public AuthUser Authorize(string userId)
        {
            if (string.IsNullOrWhiteSpace(userId))
                throw new ArgumentNullException(nameof(userId));

            AuthUser authUser = null;

            // 先假設驗證一定成功
            if (userId == "admin")
            {
                authUser = new AuthUser
                {
                    UserId = userId,
                    UserName = "管海邊",
                    DeptId = "D638",
                    DeptName = "資訊部",
                    Email = "admin@mail.server",
                    Roles = new[] { "Admin", "User" },
                    AuthFuncList = new[] { "AP0101", "AP0102", "AP0201" },
                    AuthGuid = Guid.NewGuid(),
                    IssuedUtc = DateTimeOffset.UtcNow,
                    ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(20)
                };
            }
            else
            {
                authUser = new AuthUser
                {
                    UserId = userId,
                    UserName = "郝聰明",
                    DeptId = "D638",
                    DeptName = "資訊部",
                    Email = "smart@mail.server",
                    Roles = new[] { "User" },
                    AuthFuncList = new[] { "AP0101", "AP0102", "AP0201" },
                    AuthGuid = Guid.NewGuid(),
                    IssuedUtc = DateTimeOffset.UtcNow,
                    ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(20)
                };
            }

            lock (_lockObj)
            {
                ///
                ///※ 授權資料建議存入Database,可用 MemoryCache 加速。
                ///
                _cache.Set<AuthUser>($"AuthData:{userId}", authUser);
                /// 此處開發測試中,暫不考慮資料庫部份。
            }

            // success
            _logger.LogInformation($"Authenticate {userId}");
            return authUser;
        }

        public AuthUser GetAuthDataFromPool(string userId)
        {
            lock (_lockObj)
            {
                var authUser = _cache.Get<AuthUser>($"AuthData:{userId}");

                // 若已過時,則清除
                if (authUser != null && DateTime.UtcNow > authUser.ExpiresUtc)
                {
                    _cache.Remove($"AuthData:{userId}");
                    return null;
                }

                return authUser;
            }
        }

        /// <summary>
        /// 取出(登入認證)門票
        /// </summary>
        public Ticket TakeOutTicket(string ticketToken)
        {
            Guid ticketId = this.UnpackageTicketToken(ticketToken);

            lock (_lockObj)
            {
                Ticket ticket;
                _ticketPool.Remove(ticketId, out ticket);
                return ticket;
            }
        }
        
        /// <summary>
        /// 解開 ClaimsIdentity,解開使用者的識別聲明資訊。
        /// </summary>
        public AuthUser UnpackUserClaimsData(System.Security.Principal.IIdentity identity)
        {
            var claimsIdentity = (System.Security.Claims.ClaimsIdentity)identity;
            var authUserJson = claimsIdentity.FindFirst(System.Security.Claims.ClaimTypes.UserData).Value;
            return JsonSerializer.Deserialize<AuthUser>(authUserJson);
        }

        /// <summary>
        /// 封裝 ClaimsIdentity: 將使用者的登入資訊包裝成 ClaimsIdentity 以用於 Cookie-Base Auth.。
        /// </summary>
        public ClaimsIdentity PackUserClaimsData(AuthUser authUser)
        {
            // 使用者聲明
            var claims = new List<Claim>
                {
                    new Claim(ClaimTypes.Name, authUser.UserId),
                    new Claim(ClaimTypes.Sid, authUser.AuthGuid.ToString()), // 登入識別序號
                    new Claim(ClaimTypes.GivenName, authUser.UserName) // 顯示名稱
                };

            // 『角色』可能有多個
            foreach (string role in authUser.Roles)
            {
                claims.Add(new Claim(ClaimTypes.Role, role));
            }

            var claimsIdentity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);

            return claimsIdentity;
        }
    }
}

Signout

Signout.cshtml
@page
@model BlazorServerTutor.Pages.SignoutModel

<h1>Signout</h1>
@* 這是虛畫面 *@
Signout.cshtml.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Threading.Tasks;

namespace BlazorServerTutor.Pages
{
    [Authorize]
    public class SignoutModel : PageModel
    {
        /// 用 HTTP GET 登出
        public async Task<IActionResult> OnGetAsync()
        {
            // Clear the existing external cookie
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            return LocalRedirect(Url.Content("~/")); // 轉址
        }
    }
}

Signin

Signin.cshtml
@page
@model BlazorServerTutor.Pages.SigninModel

<h1>Signin</h1>
@* 這是虛畫面 *@
Signin.cshtml.cs
using BlazorServerTutor.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;

namespace BlazorServerTutor.Pages
{
    [AllowAnonymous]
    public class SigninModel : PageModel
    {
        readonly AccountService _accountSvc;
        readonly ILogger<SigninModel> _logger;

        public SigninModel(ILogger<SigninModel> logger, AccountService accountSvc)
        {
            _accountSvc = accountSvc;
            _logger = logger;
        }

        /// 用HTTP GET 登入: 註冊 Auth-Cookie
        public async Task<IActionResult> OnGetAsync(string tid)
        {
            try
            {
                if (String.IsNullOrWhiteSpace(tid))
                    return BadRequest();

                //## 拿出(登入認證)門票
                var ticket = _accountSvc.TakeOutTicket(tid);
                if (ticket == null)
                    return BadRequest();

                if (DateTime.Now >= ticket.expires)
                    return StatusCode(408, "[ticket]已過時。");

                // 準備參數
                string returnUrl = String.IsNullOrWhiteSpace(ticket.returnUrl) ? Url.Content("~/") : ticket.returnUrl;

                //## 取授權資料 -----------------------
                
                var authUser = _accountSvc.GetAuthDataFromPool(ticket.userId);

                //## 註冊 Cookie-Base Auth  -----------------------

                //# 先清除 Cookie-Base Auth
                try
                {
                    // Clear the existing external cookie
                    await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
                }
                catch { }

                //# 準備 Cookie-Base Auth 

                // 使用者聲明
                var claimsIdentity = _accountSvc.PackUserClaimsData(authUser);

                var authProperties = new AuthenticationProperties
                {
                    IsPersistent = false,
                    IssuedUtc = authUser.IssuedUtc,
                    ExpiresUtc = authUser.ExpiresUtc,
                    RedirectUri = this.Request.Host.Value
                    //AllowRefresh = <bool>,
                    // Refreshing the authentication session should be allowed.
                };

                //# 執行 Cookie-Base Auth 註冊
                await HttpContext.SignInAsync(
                    CookieAuthenticationDefaults.AuthenticationScheme,
                    new ClaimsPrincipal(claimsIdentity),
                    authProperties);

                // success
                _logger.LogInformation($"登入認證成功,帳號:{ticket.userId}。");
                return LocalRedirect(returnUrl);
            }
            catch (Exception ex)
            {
                _logger.LogCritical(ex, $"登入認證出現例外!");
                return Unauthorized();
            }
        }
    }
}
Utils.cs
namespace BlazorServerTutor
{
    public static class Utils
    {
        /// <summary>
        /// 特殊應用:取登入資訊之聲名之GivenName。
        /// </summary>
        public static string ClaimsGivenName(System.Security.Principal.IIdentity identity) 
        {
            return (identity as System.Security.Claims.ClaimsIdentity)?.FindFirst(System.Security.Claims.ClaimTypes.GivenName)?.Value ?? identity.Name;
        }
    }
}

沒圖沒真象

登入授權結果

參考文章

EOF

Last updated