Blazor Server App 驗證與授權
本文討論與記錄『Blazor Cookie-Base 驗證』。
從結論開始
本文討論與記錄『Blazor Cookie-Base 驗證』。
Blaozr 的登入框架以ASP.NET Core登入框架為基礎。也就是要有ASP.NET Core登入框架的前置知識。
在驗證(Authenticate)部份全部使用ASP.NET Core登入框架,但是只能在Page(.cshtml檔)處理驗證。不能在元件(.blaozr檔)處理驗證這產生了點隔閡,要花心思調校登入程序縮小隔閡。
在授權(Autorize)部份有相應設計,還滿好用易用的。
補充 on 2021/12/06
基於登入程序的安全性與種種考量,現階段 Blazor App 的登入仍離不開『Auth. Cookie』,不過可以減少依賴程度只保留『Auth. Ticket』的訊息即可。登入的使用者資訊與授權資訊用AuthenticationStateProvider
來覆寫。
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 註冊。
跳過過程…
調校結果原則,驗證三大步驟:
Autheticate
可以在元件(.blaozr)處理。
檢查帳號、密碼、圖形驗證碼等等。
Authorize
可以在元件(.blaozr)處理。
取得登入者的個人與授權資料(AuthUser)。
儲放AuthUser,建議放在Database。 (註:Session因綜多考量已不再使用。)
註冊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 andCascadingAuthenticationState
component to get the authentication state.Don't use
AuthenticationStateProvider
directly. Use theAuthorizeView
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 為基底)確定認證並授權。
授權檢查之 Role base vs Policy base
Authorize - Role base
實作比較簡單。指定角色即可。
Authorize - Policy base
特性:可程式化。
較有彈性。
實作很麻煩,繞好幾圈。
關鍵程式碼
加值授權角色
客制 CustomAuthenticationStateProvider
取代預設的 AuthenticationStateProvider
。
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
才能有效用。
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
再包裏一層才能取得授權狀態
<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>
授權檢查應用
@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();
}
}
}
}
登入畫面與主程序
@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" />
<input type="password" placeholder="密碼" @bind="@mima" />
<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
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
@page
@model BlazorServerTutor.Pages.SignoutModel
<h1>Signout</h1>
@* 這是虛畫面 *@
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
@page
@model BlazorServerTutor.Pages.SigninModel
<h1>Signin</h1>
@* 這是虛畫面 *@
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();
}
}
}
}
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