ASP.NET Core 打造一個簡單的圖書館管理系統(三)基本登錄頁面以及授權邏輯的建立
前言:
本系列文章主要為我之前所學知識的一次微小的實踐,以我學校圖書館管理系統為雛形所作。
本系列文章主要參考資料:
微軟文檔:https://docs.microsoft.com/zh-cn/aspnet/core/getting-started/?view=aspnetcore-2.1&tabs=windows
《Pro ASP.NET MVC 5》、《Bootstrap 開發精解》、《鋒利的 jQuery》
此系列皆使用 VS2017+C# 作為開發環境。如果有什麽問題或者意見歡迎在留言區進行留言。
項目 github 地址:https://github.com/NanaseRuri/LibraryDemo
本章內容:Identity 框架的配置、對賬戶進行授權的配置、數據庫的初始化方法、自定義 TagHelper
一到四為對 Student 即 Identity框架的使用,第五節為對 Admin 用戶的配置
一、自定義賬號和密碼的限制
在 Startup.cs 的 ConfigureServices 方法中可以對 Identity 的賬號和密碼進行限制:
1 services.AddIdentity<Student, IdentityRole>(opts =>
2 {
3 opts.User.RequireUniqueEmail = true;
4 opts.User.AllowedUserNameCharacters = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM0123456789";
5 opts.Password.RequiredLength = 6;
6 opts.Password.RequireNonAlphanumeric = false;
7 opts.Password.RequireLowercase = false;
8 opts.Password.RequireUppercase = false;
9 opts.Password.RequireDigit = false;
10 }).AddEntityFrameworkStores<StudentIdentityDbContext>()
11 .AddDefaultTokenProviders();
RequireUniqueEmail 限制每個郵箱只能用於一個賬號。
此處 AllowedUserNameCharacters 方法限制用戶名能夠使用的字符,需要單獨輸入每個字符。
剩下的設置分別為限制密碼必須有符號 / 包含小寫字母 / 包含大寫字母 / 包含數字。
二、對數據庫進行初始化
在此創建一個 DatabaseInitiator 用以對數據庫進行初始化:
1 public static async Task Initial(IServiceProvider serviceProvider)
2 {
3 UserManager<Student> userManager = serviceProvider.GetRequiredService<UserManager<Student>>();
4 if (userManager.Users.Any())
5 {
6 return;
7 }
8 IEnumerable<Student> initialStudents = new[]
9 {
10 new Student()
11 {
12 UserName = "U201600001",
13 Name = "Nanase",
14 Email = "[email protected]",
15 PhoneNumber = "12345678910",
16 Degree = Degrees.CollegeStudent,
17 MaxBooksNumber = 10,
18 },
19 new Student()
20 {
21 UserName = "U201600002",
22 Name = "Ruri",
23 Email = "[email protected]",
24 PhoneNumber = "12345678911",
25 Degree = Degrees.DoctorateDegree,
26 MaxBooksNumber = 15
27 },
28 };
29
30 foreach (var student in initialStudents)
31 {
32 await userManager.CreateAsync(student, student.UserName.Substring(student.UserName.Length - 6,6));
33 }
34 }
為確保能夠進行初始化,在 Configure 方法中調用該靜態方法:
1 app.UseMvc(routes =>
2 {
3 routes.MapRoute(
4 name: "default",
5 template: "{controller=Home}/{action=Index}/{id?}");
6 });
7 DatabaseInitiator.Initial(app.ApplicationServices).Wait();
Initial 方法中 serviceProvider 參數將在傳入 ConfigureServices 方法調用後的 ServiceProvider,此時在 Initial 方法中初始化的數據也會使用 ConfigureServices 中對賬號和密碼的限制。
此處我們使用賬號的後六位作為密碼。啟動網頁後查看數據庫的數據:
三、建立驗證所用的控制器以及視圖
首先創建一個視圖模型用於存儲賬號的信息,為了方便實現多種登錄方式,此處創建一個 LoginType 枚舉:
[UIHint] 特性構造函數傳入一個字符串用來告知在 <input/> 中時用什麽模板來展示數據。
public enum LoginType
{
UserName,
Email,
Phone
}
public class LoginModel
{
[Required(ErrorMessage = "請輸入您的學號 / 郵箱 / 手機號碼")]
[Display(Name = "學號 / 郵箱 / 手機號碼")]
public string Account { get; set; }
[Required(ErrorMessage = "請輸入您的密碼")]
[UIHint("password")]
[Display(Name = "密碼")]
public string Password { get; set; }
[Required]
public LoginType LoginType { get; set; }
}
使用支架特性創建一個 StudentAccountController
1 public class StudentAccountController : Controller
2 {
3 public IActionResult Login(string returnUrl)
4 {
5 LoginModel loginInfo=new LoginModel();
6 ViewBag.returnUrl = returnUrl;
7 return View(loginInfo);
8 }
9 }
先創建普通的 Login 視圖:
1 @model LoginModel
2
3 @{
4 ViewData["Title"] = "Login";
5 }
6
7 <h2>Login</h2>
8 <br/>
9 <div class="text-danger" asp-validation-summary="All"></div>
10 <br/>
11 <form asp-action="Login" method="post">
12 <input type="hidden" name="returnUrl" value="@ViewBag.returnUrl"/>
13 <div class="form-group">
14 <label asp-for="Account"></label>
15 <input asp-for="Account" class="form-control" placeholder="請輸入你的學號 / 郵箱 / 手機號"/>
16 </div>
17 <div class="form-group">
18 <label asp-for="Password"></label>
19 <input asp-for="Password" class="form-control" placeholder="請輸入你的密碼"/>
20 </div>
21 <div class="form-group">
22 <label>登錄方式</label>
23 <select asp-for="LoginType">
24 <option disabled value="">登錄方式</option>
25 <LoginType login-type="@Enum.GetNames(typeof(LoginType))"></LoginType>
26 </select>
27 </div>
28 <input type="submit" class="btn btn-primary"/>
29 </form>
在此為添加多種登錄方式,並使視圖更加清晰,創建了一個 LoginTypeTagHelper ,TagHelper 可制定自定義 HTML 標記並在最終生成視圖時轉換成標準的 HTML 標記。
1 [HtmlTargetElement("LoginType")]
2 public class LoginTypeTagHelper:TagHelper
3 {
4 public string[] LoginType { get; set; }
5
6 public override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
7 {
8 foreach (var loginType in LoginType)
9 {
10 switch (loginType)
11 {
12 case "UserName": output.Content.AppendHtml($"<option selected=\"selected/\" value=\"{loginType}\">學號</option>");
13 break;
14 case "Email": output.Content.AppendHtml(GetOption(loginType, "郵箱"));
15 break;
16 case "Phone": output.Content.AppendHtml(GetOption(loginType, "手機號碼"));
17 break;
18 default: break;
19 }
20 }
21 return Task.CompletedTask;
22 }
23
24 private static string GetOption(string loginType,string innerText)
25 {
26 return $"<option value=\"{loginType}\">{innerText}</option>";
27 }
28 }
然後創建一個用於對信息進行驗證的動作方法。
為了獲取數據庫的數據以及對數據進行驗證授權,需要通過 DI(依賴註入) 獲取對應的 UserManager 和 SignInManager 對象,在此針對 StudentAccountController 的構造函數進行更新。
StudentAccountController 整體:
1 public class StudentAccountController : Controller
2 {
3 private UserManager<Student> _userManager;
4 private SignInManager<Student> _signInManager;
5
6 public StudentAccountController(UserManager<Student> studentManager, SignInManager<Student> signInManager)
7 {
8 _userManager = studentManager;
9 _signInManager = signInManager;
10 }
11
12 public IActionResult Login(string returnUrl)
13 {
14 LoginModel loginInfo = new LoginModel();
15 ViewBag.returnUrl = returnUrl;
16 return View(loginInfo);
17 }
18
19 [HttpPost]
20 [ValidateAntiForgeryToken]
21 public async Task<IActionResult> Login(LoginModel loginInfo, string returnUrl)
22 {
23 if (ModelState.IsValid)
24 {
25 Student student =await GetStudentByLoginModel(loginInfo);
26
27 if (student == null)
28 {
29 return View(loginInfo);
30 }
31 SignInResult signInResult = await _signInManager.PasswordSignInAsync(student, loginInfo.Password, false, false);
32
33 if (signInResult.Succeeded)
34 {
35 return Redirect(returnUrl ?? "/StudentAccount/"+nameof(AccountInfo));
36 }
37
38 ModelState.AddModelError("", "賬號或密碼錯誤");
39
40 }
41
42 return View(loginInfo);
43 }
44
45 [Authorize]
46 public IActionResult AccountInfo()
47 {
48 return View(CurrentAccountData());
49 }
50
51 Dictionary<string, object> CurrentAccountData()
52 {
53 var userName = HttpContext.User.Identity.Name;
54 var user = _userManager.FindByNameAsync(userName).Result;
55
56 return new Dictionary<string, object>()
57 {
58 ["學號"]=userName,
59 ["姓名"]=user.Name,
60 ["郵箱"]=user.Email,
61 ["手機號"]=user.PhoneNumber,
62 };
63 }
_userManager 以及 _signInManager 將通過 DI 獲得實例;[ValidateAntiForgeryToken] 特性用於防止 XSRF 攻擊;returnUrl 參數用於接收或返回之前正在訪問的頁面,在此處若 returnUrl 為空則返回 AccountInfo 頁面;[Authorize] 特性用於確保只有已授權的用戶才能訪問對應動作方法;CurrentAccountData 方法用於獲取當前用戶的信息以在 AccountInfo 視圖中呈現。
由於未進行授權,在此直接訪問 AccountInfo 方法默認會返回 /Account/Login 頁面請求驗證,可通過在 ConfigureServices 方法進行配置以覆蓋這一行為,讓頁面默認返回 /StudentAccount/Login :
1 services.ConfigureApplicationCookie(opts =>
2 {
3 opts.LoginPath = "/StudentAccount/Login";
4 });
為了使 [Authorize] 特性能夠正常工作,需要在 Configure 方法中使用 Authentication 中間件,如果沒有調用app.UseAuthentication(),則訪問帶有 [Authorize] 的方法會再度要求進行驗證。中間件的順序很重要:
1 app.UseAuthentication();
2 app.UseHttpsRedirection();
3 app.UseStaticFiles();
4 app.UseCookiePolicy();
同時在 ConfigureServices 中對 Cookie 策略進行配置:
1 services.Configure<CookiePolicyOptions>(options =>
2 {
3 options.CheckConsentNeeded = context => true;
4 options.MinimumSameSitePolicy = SameSiteMode.None;
5 });
直接訪問 AccountInfo 頁面:
輸入賬號密碼進行驗證:
驗證之後返回 /StudentAccount/AccountInfo 頁面:
四、創建登出網頁
簡單地調用 SignOutAsync 用以清除當前 Cookie 中的授權信息。
1 [Authorize]
2 public async Task<IActionResult> Logout()
3 {
4 await _signInManager.SignOutAsync();
5 return View("Login");
6 }
同時在 AccountInfo 添加登出按鈕:
1 @model Dictionary<string, object>
2 @{
3 ViewData["Title"] = "AccountInfo";
4 }
5 <h2>賬戶信息</h2>
6 <ul>
7 @foreach (var info in Model)
8 {
9 <li>@info.Key: @Model[info.Key]</li>
10 }
11 </ul>
12 <br />
13 <a class="btn btn-danger" asp-action="Logout">登出</a>
登出後返回 Login 頁面,同時 AccountInfo 頁面需要重新進行驗證。
附加使用郵箱以及手機號驗證的測試:
最後對 Login 動作方法進行修改以避免不必要的驗證:
1 public IActionResult Login(string returnUrl)
2 {
3 if (HttpContext.User.Identity.IsAuthenticated)
4 {
5 return RedirectToAction("AccountInfo");
6 }
7
8 LoginModel loginInfo = new LoginModel();
9 ViewBag.returnUrl = returnUrl;
10 return View(loginInfo);
11 }
已授權情況下再度訪問 Login 方法返回 AccountInfo :
登出後再次訪問 AccountInfo 方法:
登出後需要重新驗證:
五?、Admin,不可與 Identity 同時使用的基於 Cookie 的授權?
帶有自定義驗證邏輯項目地址:https://files-cdn.cnblogs.com/files/gokoururi/LibraryDemo-Failed.zip
本來打算使用 Cookie 進行對 Admin 的授權,但由於 Identity 使用的也是基於 Cookie 的授權並做了大量的工作,同時使用兩者在一些奇奇怪怪的地方會出現 bug,如果有什麽解決方案感謝不盡,因此這節只做使用 Cookie 授權的演示。
為使用 Cookie 授權,需要在 ConfigureServices 和 Configure 方法中進行配置:
ConfigureServices 中調用 services.AddAuthentication 啟用驗證,使用 CookieAuthenticationDefaults.AuthenticationScheme 作為默認該驗證的 scheme,使用默認 Cookie 沿驗證。
1 services.AddAuthentication(options =>
2 {
3 options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
4 })
5 .AddCookie();
為保證安全,密碼不能使用明文保存在數據庫中,因此在此使用 MD5 加密對密碼進行加密。在此創建一個類用以更方便地調用:
創建 Encrptor 類,設置私有默認構造函數防止該類被實例化,添加靜態方法 MD5Encrypt32 用以返回加密後的字符串:
1 public class Encryptor
2 {
3 private Encryptor()
4 {
5 }
6
7 public static string MD5Encrypt(string password)
8 {
9 MD5 md5 = MD5.Create();
10 byte[] hashBytes = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
11 StringBuilder hashPassword = new StringBuilder();
12 foreach (var b in hashBytes)
13 {
14 hashPassword.Append(b);
15 }
16
17 return hashPassword.ToString();
18 }
19 }
在此處要註意使用 context.SaveChanges 來保存對數據庫做出的增刪改的操作,否則數據庫將不會做出更改。對 AdminDbContext 進行初始化:
1 public class AdminInitiator
2 {
3 public static async Task InitialAdmins(IServiceProvider serviceProvider)
4 {
5 AdminDbContext adminDbContext = serviceProvider.GetRequiredService<AdminDbContext>();
6 if (adminDbContext.Admins.Any())
7 {
8 return;
9 }
10
11 IEnumerable<Admin> admins = new[]
12 {
13 new Admin()
14 {
15 UserName = "admin",
16 Email = "[email protected]",
17 PhoneNumber = "10000000000",
18 Password = "123456"
19 },
20 new Admin()
21 {
22 UserName = "admin1",
23 Email = "[email protected]",
24 PhoneNumber = "10000000001",
25 Password = "456789"
26 },
27 };
28
29 foreach (var admin in admins)
30 {
31 EncryptAdmin(admin);
32 await adminDbContext.AddAsync(admin);
33 await adminDbContext.SaveChangesAsync();
34 }
35 }
36
37 private static Admin EncryptAdmin(Admin admin)
38 {
39 admin.Password = Encryptor.MD5Encrypt(admin.Password);
40 return admin;
41 }
42 }
此處為 Authorize 特性指定授權的 Scheme,則可以通過不同的 Scheme 指定不同的授權。指定 [AllowAnoymous] 特性時,該方法可以在未授權的情況下被訪問。
1 [Authorize(AuthenticationSchemes=CookieAuthenticationDefaults.AuthenticationScheme)]
2 public class AdminAccountController : Controller
3 {
4 private AdminDbContext _context;
5
6 public AdminAccountController(AdminDbContext context)
7 {
8 _context = context;
9 }
10
11 [AllowAnonymous]
12 public IActionResult Login(string returnUrl)
13 {
14 if (HttpContext.User.IsInRole("admin"))
15 {
16 return RedirectToAction("Index");
17 }
18 LoginModel model = new LoginModel();
19 return View(model);
20 }
21
22 public IActionResult Index()
23 {
24 return View(CurrentAccountData());
25 }
26
27 [HttpPost]
28 [ValidateAntiForgeryToken]
29 [AllowAnonymous]
30 public async Task<IActionResult> Login(LoginModel loginInfo, string returnUrl)
31 {
32 if (ModelState.IsValid)
33 {
34 Admin admin = new Admin();
35 switch (loginInfo.LoginType)
36 {
37 case LoginType.UserName:
38 admin = await _context.Admins.FirstOrDefaultAsync(a => a.UserName == loginInfo.Account);
39 break;
40 case LoginType.Email:
41 admin = await _context.Admins.FirstOrDefaultAsync(a => a.Email == loginInfo.Account);
42 break;
43 case LoginType.Phone:
44 admin = await _context.Admins.FirstOrDefaultAsync(a => a.PhoneNumber == loginInfo.Account);
45 break;
46 default:
47 admin = null;
48 break;
49 }
50
51 if (admin != null)
52 {
53 string encryptedPassword = Encryptor.MD5Encrypt32(loginInfo.Password);
54 if (admin.Password == encryptedPassword)
55 {
56 ClaimsIdentity identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme);
57 identity.AddClaims(new[]
58 {
59 new Claim(ClaimTypes.Name, admin.UserName),
60 new Claim(ClaimTypes.Email,admin.Email),
61 new Claim(ClaimTypes.MobilePhone,admin.PhoneNumber),
62 new Claim(ClaimTypes.Role,"admin"),
63 });
64 var principal = new ClaimsPrincipal(identity);
65 await HttpContext.SignInAsync(principal,new AuthenticationProperties()
66 {
67 ExpiresUtc = DateTime.UtcNow.AddSeconds(8)
68 });
69
70 if (returnUrl != null)
71 {
72 return Redirect(returnUrl);
73 }
74
75 return RedirectToAction("Index");
76 }
77 }
78 ModelState.AddModelError("", "賬號或密碼錯誤");
79 return View(loginInfo);
80 }
81
82 return View(loginInfo);
83 }
84
85 [Authorize]
86 public async Task<IActionResult> Logout()
87 {
88 await HttpContext.SignOutAsync();
89 return View("Login");
90 }
91
92 Dictionary<string, object> CurrentAccountData()
93 {
94 var userName = HttpContext.User.Identity.Name;
95 var user = _context.Admins.FirstOrDefault(a => a.UserName == userName);
96
97 return new Dictionary<string, object>()
98 {
99 ["用戶名"] = user.UserName,
100 ["郵箱"] = user.Email,
101 ["手機號"] = user.PhoneNumber,
102 };
103 }
104 }
由於 Login 視圖和 StudentAccountController 的 Login 視圖大致一致,因此可以將重復的部分提取出來作為一個分部視圖,在 Views/Shared 文件夾中創建分部視圖:
1 @model LoginModel
2
3 <input type="hidden" name="returnUrl" value="@ViewBag.returnUrl"/>
4 <div class="form-group">
5 <label asp-for="Account"></label>
6 <input asp-for="Account" class="form-control" placeholder="請輸入你的賬號(學號) / 郵箱 / 手機號"/>
7 </div>
8 <div class="form-group">
9 <label asp-for="Password"></label>
10 <input asp-for="Password" class="form-control" placeholder="請輸入你的密碼"/>
11 </div>
12 <div class="form-group">
13 <label>登錄方式</label>
14 <select asp-for="LoginType">
15 <option disabled value="">登錄方式</option>
16 <LoginType login-type="@Enum.GetNames(typeof(LoginType))"></LoginType>
17 </select>
18 </div>
19 <input type="submit" class="btn btn-primary"/>
20 <input type="reset" class="btn btn-primary"/>
對 StudentAccountController 的 Login 視圖做出修改:
1 @model LoginModel
2
3 @{
4 ViewData["Title"] = "Login";
5 }
6
7 <h2>Login</h2>
8 <br/>
9 <div class="text-danger" asp-validation-summary="All"></div>
10 <br/>
11 <form asp-action="Login" method="post">
12 @await Html.PartialAsync("_LoginPartialView",Model)
13 </form>
設置 AdminAccount 的 Login 視圖:
1 @model LoginModel
2 @{
3 ViewData["Title"] = "AdminIndex";
4 }
5
6 <h2>Login</h2>
7 <br />
8 <div class="text-danger" asp-validation-summary="All"></div>
9 <br />
10 <form asp-action="Login" method="post">
11 @await Html.PartialAsync("_LoginPartialView", Model)
12 </form>
AdminAccount 的 Index 視圖:
1 @model Dictionary<string,object>
2 @{
3 ViewData["Title"] = "AccountInfo";
4 }
5
6 <h2>AccountInfo</h2>
7
8 <ul>
9 @foreach (var info in Model)
10 {
11 <li>@info.Key: @Model[info.Key]</li>
12 }
13
14 </ul>
五、基於 Role 的 Identity 授權
在此把之前所有與 Admin 有關的內容全部註釋掉或刪除,初始化身份為 admin 的用戶。
修改 StudentInitial 類,添加名為 admin 的學生數組並使用 AddToRoleAsync 為用戶添加身份。在添加 Role 之前需要在 RoleManager 對象中使用 Create 方法為 Role 數據庫添加特定的 Role 字段:
1 public class StudentInitiator
2 {
3 public static async Task InitialStudents(IServiceProvider serviceProvider)
4 {
5 UserManager<Student> userManager = serviceProvider.GetRequiredService<UserManager<Student>>();
6 RoleManager<IdentityRole> roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>();
7 if (userManager.Users.Any())
8 {
9 return;
10 }
11
12 if (await roleManager.FindByNameAsync("Admin")==null)
13 {
14 await roleManager.CreateAsync(new IdentityRole("Admin"));
15 }
16
17 if (await roleManager.FindByNameAsync("Student")==null)
18 {
19 await roleManager.CreateAsync(new IdentityRole("Student"));
20 }
21
22 IEnumerable<Student> initialStudents = new[]
23 {
24 new Student()
25 {
26 UserName = "U201600001",
27 Name = "Nanase",
28 Email = "[email protected]",
29 PhoneNumber = "12345678910",
30 Degree = Degrees.CollegeStudent,
31 MaxBooksNumber = 10,
32 },
33 new Student()
34 {
35 UserName = "U201600002",
36 Name = "Ruri",
37 Email = "[email protected]",
38 PhoneNumber = "12345678911",
39 Degree = Degrees.DoctorateDegree,
40 MaxBooksNumber = 15
41 }
42 };
43
44 IEnumerable<Student> initialAdmins = new[]
45 {
46 new Student()
47 {
48 UserName = "A000000000",
49 Name="Admin0000",
50 Email = "[email protected]",
51 PhoneNumber = "12345678912",
52 Degree = Degrees.CollegeStudent,
53 MaxBooksNumber = 20
54 }
55 };
56 foreach (var student in initialStudents)
57 {
58 await userManager.CreateAsync(student, student.UserName.Substring(student.UserName.Length - 6, 6));
59 }
60 foreach (var admin in initialAdmins)
61 {
62 await userManager.CreateAsync(admin, "zxcZXC!123");
63 await userManager.AddToRoleAsync(admin, "Admin");
64 }
65 }
66 }
然後新建一個 Admin 控制器,設置 [Authorize] 特性並指定 Role 屬性,使帶有特定 Role 的身份才可以訪問該控制器。
1 [Authorize(Roles = "Admin")]
2 public class AdminAccountController : Controller
3 {
4 private UserManager<Student> _userManager;
5 private SignInManager<Student> _signInManager;
6
7 public AdminAccountController(UserManager<Student> studentManager, SignInManager<Student> signInManager)
8 {
9 _userManager = studentManager;
10 _signInManager = signInManager;
11 }
12
13 public IActionResult Index()
14 {
15 return View(CurrentAccountData());
16 }
17
18
19
20 Dictionary<string, object> CurrentAccountData()
21 {
22 var userName = HttpContext.User.Identity.Name;
23 var user = _userManager.FindByNameAsync(userName).Result;
24
25 return new Dictionary<string, object>()
26 {
27 ["學號"] = userName,
28 ["姓名"] = user.Name,
29 ["郵箱"] = user.Email,
30 ["手機號"] = user.PhoneNumber,
31 };
32 }
33 }
使用 Role 不是 Admin 的賬戶登錄:
使用 Role 為 Admin 的賬戶登錄:
對 ConfigureServices 作進一步配置,添加 Cookie 的過期時間和不滿足 Authorize 條件時返回的 Url:
services.ConfigureApplicationCookie(opts =>
{
opts.Cookie.HttpOnly = true;
opts.LoginPath = "/StudentAccount/Login";
opts.AccessDeniedPath = "/StudentAccount/Login";
opts.ExpireTimeSpan=TimeSpan.FromMinutes(5);
});
則當 Role 不為 Admin 時將返回 /StudentAccount/Login 而非默認的 /Account/AccessDeny。
ASP.NET Core 打造一個簡單的圖書館管理系統(三)基本登錄頁面以及授權邏輯的建立