原文:https://chrissainty.com/securing-your-blazor-apps-authentication-with-clientside-blazor-using-webapi-aspnet-core-identity/
由于Blazor框架已經有所更新,翻譯中有些內容我根據實際情況做了更改,
設定:創建解決方案
選擇Blazor應用

專案名稱

選擇Blazor WebAssembly App(這里要勾選Asp.Net Core Host),如果找不到Blazor WebAssembly App,請先在命令列執行以下命令:
dotnet new -i Microsoft.AspNetCore.Blazor.Templates::3.1.0-preview1.19508.20

解決方案創建之后,我們將開始對AuthenticationWithBlazorWebAssembly.Server這個專案進行一些更改,
配置WebAPI
在配置WebAPI之前我先安裝一些NuGet包:
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="3.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Blazor.Server" Version="3.0.0-preview9.19465.2" /> <PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="3.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="3.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="3.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0"> <PackageReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="3.0.0" />
設定Identity資料庫:連接字串
在進行任何設定之前,資料庫方面需要一個連接字串,這通常是保存在appsettings.json中的,但Blazor托管模版并未提供此檔案,所以我們需要手動添加此檔案,
在AuthenticationWithBlazorWebAssembly.Server專案右鍵添加 -> 新建項,然后選擇應用設定檔案,
{ "ConnectionStrings": { "DefaultConnection": "Server=(localdb)\\MSSQLLocalDB;Database=AuthenticationWithBlazorWebAssembly;Trusted_Connection=True;MultipleActiveResultSets=true" } }
該檔案帶有一個已經設定好的連接字串,你可以隨時將其指向需要的地方,我們只需要添加一個資料庫名就可以了,其余的保持默認值,
設定Identity資料庫:DbContext
在AuthenticationWithBlazorWebAssembly.Server專案跟目錄創建一個名為Data的目錄,然后使用下面代碼添加一個名為ApplicationDbContext的類檔案,
public class ApplicationDbContext : IdentityDbContext { public ApplicationDbContext(DbContextOptions options) : base(options) { } }
因為我們使用Identity需要將資訊存盤在資料庫中,所以我們不是從DbContext繼承,而是從IdentityDbContext繼承,IdentityDbContext基類包含EF配置管理Identity資料庫表需要的所有配置,
設定Identity資料庫:注冊服務
在Startup類中,我們需要添加一個建構式,接收IConfiguration引數和一個屬性來存盤它,IConfiguration允許我們訪問appsettings.json檔案,如:連接字串,
public IConfiguration Configuration { get; } public Startup(IConfiguration configuration) { Configuration = configuration; }
接下來我們將以下代碼添加到ConfigureServices方法的頂部,
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<ApplicationDbContext>(options => options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddDefaultIdentity<IdentityUser>() .AddEntityFrameworkStores<ApplicationDbContext>(); //這里省略掉其他代碼 }
這里兩行代碼將ApplicationDbContext添加到服務集合中,然后為ASP.NET Core Identity注冊各種服務并通過ApplicationDbContext使用Entity Framework作為資料存盤,
設定Identity資料庫:創建資料庫
現在可以為資料庫創建初始遷移,在程式包管理器控制臺運行以下命令,
Add-Migration CreateIdentitySchema -o Data/Migations
命令運行完成,你應該能在Data > Migrations檔案夾中看到遷移檔案,在控制臺中運行命令Update-Database將遷移應用到資料庫,
在運行遷移命令時遇到任何問題,請確保在程式包管理器中選擇AuthenticationWithBlazorWebAssembly.Server專案作為默認專案,
啟用身份驗證:注冊服務
接下來在API中啟用身份驗證,同樣,在ConfigureServices中,在上一節添加的代碼之后添加以下代碼,
public void ConfigureServices(IServiceCollection services) { //這里省略到其他代碼 services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = Configuration["JwtIssuer"], ValidAudience = Configuration["JwtAudience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["JwtSecurityKey"])) }; });
//這里省略掉其他代碼
}
上面代碼想服務容器添加和設定一些身份驗證所需的服務,然后為JSON Web Tokens(JWT)添加處理程式,并配置接收到的JWTs應該如何驗證,你可以根據需求調整這些設定,
啟用身份驗證:應用程式設定
有一些設定要從appsettings.json檔案中加載,
Configuration["JwtIssuer"]Configuration["JwtAudience"]Configuration["JwtSecurityKey"]
我們還未將它們添加到appsettings檔案中,現在添加它們并添加一個設定用來控制令牌的持續時間,稍后我們會使用這個設定,
"JwtSecurityKey": "RANDOM_KEY_MUST_NOT_BE_SHARED", "JwtIssuer": "https://localhost", "JwtAudience": "https://localhost", "JwtExpiryInDays": 1,
保證JwtSecurityKey 的安全是非常重要的,因為這是用來對API產生的令牌簽名的,如果泄露那么你的應用程式將不在安全,
由于我們在本地運行所有內容,所以我將Issuer和Audience設定為localhost,如果在生產環境使用它,我們需要將Issuer 設定為API運行的域名,將Audience設定為客戶端應用程式運行的域名,
啟用身份驗證:添加中間件
最后,我們需要在Configure 方法中將必要的中間件添加到管道中,這將在API中啟用身份驗證和授權功能,將以下代碼添加到app.UseEndpoints中間件前面,
app.UseAuthentication();
app.UseAuthorization();
這就是Startup類所需要的所有東西,現在API已經啟用了身份驗證,
你可以通過向WeatherForecastController中的Get方法添加[Authorize]屬性來測驗一切是否正常,然后啟用應用程式并導航到Fetch Data頁面,應該不會加載任何資料,應該會在控制臺中看到401錯誤,
添加賬戶(account)控制器
為了讓人們登錄到我們的應用程式,他們需要能夠注冊,我們將添加一個帳戶控制器,它將負責創建新帳戶,
[Route("api/[controller]")] [ApiController] public class AccountsController : ControllerBase { //private static UserModel LoggedOutUser = new UserModel { IsAuthenticated = false }; private readonly UserManager<IdentityUser> _userManager; public AccountsController(UserManager<IdentityUser> userManager) { _userManager = userManager; } [HttpPost] public async Task<IActionResult> Post([FromBody]RegisterModel model) { var newUser = new IdentityUser { UserName = model.Email, Email = model.Email }; var result = await _userManager.CreateAsync(newUser, model.Password); if (!result.Succeeded) { var errors = result.Errors.Select(x => x.Description); return BadRequest(new RegisterResult { Successful = false, Errors = errors }); } return Ok(new RegisterResult { Successful = true }); } }
Post操作使用ASP.NET Core Identity從RegisterModel來創建系統的新用戶,
我們還沒用添加注冊模型,現在使用以下代碼添加到AuthenticationWithBlazorWebAssembly.Shared專案中,稍后我們的Blazor應用程式將會使用到它,
public class RegisterModel { [Required] [EmailAddress] [Display(Name = "Email")] public string Email { get; set; } [Required] [StringLength(100, ErrorMessage = "The {0} must be at least {2} and at max {1} characters long.", MinimumLength = 6)] [DataType(DataType.Password)] [Display(Name = "Password")] public string Password { get; set; } [DataType(DataType.Password)] [Display(Name = "Confirm password")] [Compare("Password", ErrorMessage = "The password and confirmation password do not match.")] public string ConfirmPassword { get; set; } }
如果一切順利,則會回傳一個成功的RegisterResult,否則會回傳一個失敗的RegisterResult,我們一樣將它添加到AuthenticationWithBlazorWebAssembly.Shared專案中,
public class RegisterResult { public bool Successful { get; set; } public IEnumerable<string> Errors { get; set; } }
添加登錄(login)控制器
現在我們有了用戶注冊的方式,我們還需要用戶登錄方式,
[Route("api/[controller]")] [ApiController] public class LoginController : ControllerBase { private readonly IConfiguration _configuration; private readonly SignInManager<IdentityUser> _signInManager; public LoginController(IConfiguration configuration, SignInManager<IdentityUser> signInManager) { _configuration = configuration; _signInManager = signInManager; } [HttpPost] public async Task<IActionResult> Login([FromBody] LoginModel login) { var result = await _signInManager.PasswordSignInAsync(login.Email, login.Password, false, false); if (!result.Succeeded) return BadRequest(new LoginResult { Successful = false, Error = "Username and password are invalid." }); var claims = new[] { new Claim(ClaimTypes.Name, login.Email) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtSecurityKey"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var expiry = DateTime.Now.AddDays(Convert.ToInt32(_configuration["JwtExpiryInDays"])); var token = new JwtSecurityToken( _configuration["JwtIssuer"], _configuration["JwtAudience"], claims, expires: expiry, signingCredentials: creds ); return Ok(new LoginResult { Successful = true, Token = new JwtSecurityTokenHandler().WriteToken(token) }); } }
登錄控制器(login controller)使用ASP.NET Core Identity SignInManger驗證用戶名和密碼,如果它們都正確,則生成一個新的JSON Web Token并在LoginResult回傳給客戶端,
像之前一樣,我們需要將LoginModel和LoginResult添加到AuthenticationWithBlazorWebAssembly.Shared專案中,
public class LoginModel { [Required] public string Email { get; set; } [Required] public string Password { get; set; } public bool RememberMe { get; set; } }
public class LoginResult { public bool Successful { get; set; } public string Error { get; set; } public string Token { get; set; } }
這就是API需要的所有東西,我們現在已經將其配置為通過JSON web tokens進行身份驗證,接下來我們需要為Blazor WebAssembly(客戶端)應用程式添加注冊新用戶和登錄控制器,
配置Blazor客戶端
接下來我們關注Blazor,首先需要安裝Blazored.LocalStorage,我們稍后將需要它在登錄時從API中持久化驗證令牌,
我們還需要在App組件中使用AuthorizeRouteView組件替換RouteView組件(這里需要使用Microsoft.AspNetCore.Components.Authorization NuGet包并在_Imports.razor添加@using Microsoft.AspNetCore.Components.Authorization),
<Router AppAssembly="@typeof(Program).Assembly"> <Found Context="routeData"> <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> </Found> <NotFound> <LayoutView Layout="@typeof(MainLayout)"> <p>Sorry, there's nothing at this address.</p> </LayoutView> </NotFound> </Router>
此組件提供型別為Task<AuthenticationState>的級聯引數,AuthorizeView通過使用它來確定當前用戶的身份驗證狀態,
但是任何組件都可以請求引數并使用它來執行程序邏輯,例如:
@page "/" <button @onclick="@LogUsername">Log username</button> @code { [CascadingParameter] private Task<AuthenticationState> authenticationStateTask { get; set; } private async Task LogUsername() { var authState = await authenticationStateTask; var user = authState.User; if (user.Identity.IsAuthenticated) { Console.WriteLine($"{user.Identity.Name} is authenticated."); } else { Console.WriteLine("The user is NOT authenticated."); } } }
創建自定義AuthenticationStateProvider
因為我們使用Blazor WebAssembly,所以我們需要為AuthenticationStateProvider提供自定義實作,因為在客戶端應用程式有太多的選項,所以無法設計一個適用于所有人的默認類,
我們需要重寫GetAuthenticationStateAsync方法,在此方法中,我們需要確定當前用戶是否經過身份驗證,我們還將添加兩個輔助方法,當用戶登錄或注銷時,我們將使用這些方法更新身份驗證狀態,
public class ApiAuthenticationStateProvider : AuthenticationStateProvider { private readonly HttpClient _httpClient; private readonly ILocalStorageService _localStorage; public ApiAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage) { _httpClient = httpClient; _localStorage = localStorage; } public override async Task<AuthenticationState> GetAuthenticationStateAsync() { var savedToken = await _localStorage.GetItemAsync<string>("authToken"); if (string.IsNullOrWhiteSpace(savedToken)) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", savedToken); return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt"))); } public void MarkUserAsAuthenticated(string token) { var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(token), "jwt")); var authState = Task.FromResult(new AuthenticationState(authenticatedUser)); NotifyAuthenticationStateChanged(authState); } public void MarkUserAsLoggedOut() { var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity()); var authState = Task.FromResult(new AuthenticationState(anonymousUser)); NotifyAuthenticationStateChanged(authState); } private IEnumerable<Claim> ParseClaimsFromJwt(string jwt) { var claims = new List<Claim>(); var payload = jwt.Split('.')[1]; var jsonBytes = ParseBase64WithoutPadding(payload); var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes); keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles); if (roles != null) { if (roles.ToString().Trim().StartsWith("[")) { var parsedRoles = JsonSerializer.Deserialize<string[]>(roles.ToString()); foreach (var parsedRole in parsedRoles) { claims.Add(new Claim(ClaimTypes.Role, parsedRole)); } } else { claims.Add(new Claim(ClaimTypes.Role, roles.ToString())); } keyValuePairs.Remove(ClaimTypes.Role); } claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString()))); return claims; } private byte[] ParseBase64WithoutPadding(string base64) { switch (base64.Length % 4) { case 2: base64 += "=="; break; case 3: base64 += "="; break; } return Convert.FromBase64String(base64); } }
這里有很多代碼,讓我們一步一步來分析,
CascadingAuthenticationState組件呼叫GetAuthenticationStateAsync方法來確定當前用戶是否經過驗證,
上面的代碼,我們檢查local storge是否有驗證令牌,如果local storge中沒有令牌,那么我們將回傳一個新的AuthenticationState,其中包含一個空的ClaimsPrincipal,這就說明當前用戶用戶沒有經過身份驗證,
如果有令牌,讀取并設定HttpClient的默認Authorization Header,并回傳一個包含ClaimsPrincipal新的AuthenticationState的令牌宣告,該宣告(Claims)使用ParseClaimsFromJwt方法從令牌中提取,此方法解碼令牌并回傳其中包含的宣告,
MarkUserAsAuthenticated輔助方法用于登錄時呼叫NotifyAuthenticationStateChanged方法,該方法觸發AuthenticationStateChanged事件,這將通過CascadingAuthenticationState組件級聯新的身份驗證狀態,
MarkUserAsLoggedOut用于用戶注銷時,
Auth Service
Auth Service將在組件中注冊用戶并登錄到應用程式和用戶注銷使用,
public class AuthService : IAuthService { private readonly HttpClient _httpClient; private readonly AuthenticationStateProvider _authenticationStateProvider; private readonly ILocalStorageService _localStorage; public AuthService(HttpClient httpClient, AuthenticationStateProvider authenticationStateProvider, ILocalStorageService localStorage) { _httpClient = httpClient; _authenticationStateProvider = authenticationStateProvider; _localStorage = localStorage; } public async Task<RegisterResult> Register(RegisterModel registerModel) { var result = await _httpClient.PostJsonAsync<RegisterResult>("api/accounts", registerModel); return result; } public async Task<LoginResult> Login(LoginModel loginModel) { var loginAsJson = JsonSerializer.Serialize(loginModel); var response = await _httpClient.PostAsync("api/Login", new StringContent(loginAsJson, Encoding.UTF8, "application/json")); var loginResult = JsonSerializer.Deserialize<LoginResult>(await response.Content.ReadAsStringAsync(), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (!response.IsSuccessStatusCode) { return loginResult; } await _localStorage.SetItemAsync("authToken", loginResult.Token); ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(loginResult.Token); _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("bearer", loginResult.Token); return loginResult; } public async Task Logout() { await _localStorage.RemoveItemAsync("authToken"); ((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut(); _httpClient.DefaultRequestHeaders.Authorization = null; } }
Register方法提交registerModel給accounts controller并回傳RegisterResult給呼叫者,
Login 方法類似于Register 方法,它將LoginModel 發送給login controller,但是,當回傳一個成功的結果時,它將回傳一個授權令牌并持久化到local storge,
最后我們呼叫ApiAuthenticationStateProvider上的方法MarkUserAsAuthenticated ,設定HttpClient的默認authorization header,
Logout 這個方法就是執行與Login 方法相反的操作,
注冊組件(Register Component)
我們已經到了最后階段了,現在我們可以將注意力轉向UI,并創建一個允許人們在站點注冊的組件,
@page "/register" @inject IAuthService AuthService @inject NavigationManager NavigationManager <h1>Register</h1> @if (ShowErrors) { <div class="alert alert-danger" role="alert"> @foreach (var error in Errors) { <p>@error</p> } </div> } <div class="card"> <div class="card-body"> <h5 class="card-title">Please enter your details</h5> <EditForm Model="RegisterModel" OnValidSubmit="HandleRegistration"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label for="email">Email address</label> <InputText Id="email" class="form-control" @bind-Value=https://www.cnblogs.com/chen8854/p/"RegisterModel.Email" /> <ValidationMessage For="@(() => RegisterModel.Email)" /> </div> <div class="form-group"> <label for="password">Password</label> <InputText Id="password" type="password" class="form-control" @bind-Value=https://www.cnblogs.com/chen8854/p/"RegisterModel.Password" /> <ValidationMessage For="@(() => RegisterModel.Password)" /> </div> <div class="form-group"> <label for="confirmpassword">Confirm Password</label> <InputText Id="confirmpassword" type="password" class="form-control" @bind-Value=https://www.cnblogs.com/chen8854/p/"RegisterModel.ConfirmPassword" /> <ValidationMessage For="@(() => RegisterModel.ConfirmPassword)" /> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> </div> </div> @code { private RegisterModel RegisterModel = new RegisterModel(); private bool ShowErrors; private IEnumerable<string> Errors; private async Task HandleRegistration() { ShowErrors = false; var result = await AuthService.Register(RegisterModel); if (result.Successful) { NavigationManager.NavigateTo("/login"); } else { Errors = result.Errors; ShowErrors = true; } } }
注冊組件包含一個表單讓用戶輸入他們的電子郵件和密碼,提交表單時,會呼叫AuthService 的方法Register ,如果注冊成功那么用戶會被導航到登錄頁,否則,會將錯誤顯示給用戶,
登錄組件(Login Component)
現在我們可以注冊一個新的帳戶,我們需要能夠登錄,登錄組件將用于此,
@page "/login" @inject IAuthService AuthService @inject NavigationManager NavigationManager <h1>Login</h1> @if (ShowErrors) { <div class="alert alert-danger" role="alert"> <p>@Error</p> </div> } <div class="card"> <div class="card-body"> <h5 class="card-title">Please enter your details</h5> <EditForm Model="loginModel" OnValidSubmit="HandleLogin"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label for="email">Email address</label> <InputText Id="email" Class="form-control" @bind-Value=https://www.cnblogs.com/chen8854/p/"loginModel.Email" /> <ValidationMessage For="@(() => loginModel.Email)" /> </div> <div class="form-group"> <label for="password">Password</label> <InputText Id="password" type="password" Class="form-control" @bind-Value=https://www.cnblogs.com/chen8854/p/"loginModel.Password" /> <ValidationMessage For="@(() => loginModel.Password)" /> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> </div> </div> @code { private LoginModel loginModel = new LoginModel(); private bool ShowErrors; private string Error = ""; private async Task HandleLogin() { ShowErrors = false; var result = await AuthService.Login(loginModel); if (result.Successful) { NavigationManager.NavigateTo("/"); } else { Error = result.Error; ShowErrors = true; } } }
與注冊組件類似的設計,我們也提供一個表單用于用戶輸入電子郵件和密碼,表單提交時,將呼叫AuthService的方法Login,如果登錄成功,用戶將被重定向到主頁,否則將顯示錯誤訊息,
注銷組件(Logout Component)
我們現在可以注冊和登錄,但我們也需要注銷的功能,我用了一個頁面組件來做這個,但是你也可以通過點擊某個地方的按鈕來實作,
@page "/logout" @inject IAuthService AuthService @inject NavigationManager NavigationManager @code { protected override async Task OnInitializedAsync() { await AuthService.Logout(); NavigationManager.NavigateTo("/"); } }
這個組件沒有用戶界面,當用戶導航到它時,將呼叫AuthService上的方法Logout,然后將用戶重定向回主頁,
添加一個LoginDisplay組件并更新MainLayout組件
最后的任務是添加一個LoginDisplay組件并更新MainLayout 組件,
LoginDisplay 組件與Blazor Server模版一樣,如果未經驗證,它將顯示登錄與注冊鏈接,否則顯示電子郵件和注銷鏈接,
<AuthorizeView>
<Authorized>
Hello, @context.User.Identity.Name!
<a href=https://www.cnblogs.com/chen8854/p/"/logout">Log out</a>
</Authorized>
<NotAuthorized>
<a href=https://www.cnblogs.com/chen8854/p/"/register">Register</a>
<a href=https://www.cnblogs.com/chen8854/p/"/login">Log in</a>
</NotAuthorized>
</AuthorizeView>
我們現在只需要更新MainLayout組件,
@inherits LayoutComponentBase <div class="sidebar"> <NavMenu /> </div> <div class="main"> <div class="top-row px-4"> <LoginDisplay /> <a href=https://www.cnblogs.com/chen8854/p/"http://blazor.net" target="_blank" class="ml-md-auto">About</a> </div> <div class="content px-4"> @Body </div> </div>
注冊服務(Registering Services)
最后在Startup類中注冊服務,
services.AddBlazoredLocalStorage(); services.AddAuthorizationCore(); services.AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>(); services.AddScoped<IAuthService, AuthService>();
如果一切都按計劃進行,那么你應該得到這樣的結果,

總結
這篇文章展示了如何WebAPI和ASP.NET Core Identity創建一個帶有身份驗證的Blazor WebAssembly(Blazor客戶端)應用程式,
展示WebAPI如何處理和簽發令牌(JSON web tokens),以及如何設定各種控制器操作來為客戶端應用程式提供服務,最后,展示如何配置Blazor來使用API和它簽發的令牌來設定應用的身份驗證狀態,
最后也提供我學習本文跟隨作者所寫的原始碼(GITHUB),
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/98059.html
標籤:.NET Core
下一篇:Asp.Net Core中配置使用Kindeditor富文本編輯器實作圖片上傳和截圖上傳及檔案管理和上傳(開源代碼.net core3.0)
