基本登录页面以及授权逻辑的建立
来源: ASP.NET Core 打造一个简单的图书馆管理系统 (修正版)(二)用户数据库初始化、基本登录页面以及授权逻辑的建立 – NanaseRuri – 博客园
前言:
本系列文章主要为我之前所学知识的一次微小的实践,以我学校图书馆管理系统为雏形所作。
本系列文章主要参考资料:
微软文档:https://docs.microsoft.com/zh-cn/aspnet/core/getting-started/?view=aspnetcore-2.1&tabs=windows
《Pro ASP.NET MVC 5》、《锋利的 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 4 opts.User.RequireUniqueEmail = true; 5 opts.User.AllowedUserNameCharacters = "qwertyuiopasdfghjklzxcvbnmQWERTYUIOPASDFGHJKLZXCVBNM0123456789"; 6 opts.Password.RequiredLength = 6; 7 opts.Password.RequireNonAlphanumeric = false; 8 opts.Password.RequireLowercase = false; 9 opts.Password.RequireUppercase = false; 10 opts.Password.RequireDigit = false; 11 }).AddEntityFrameworkStores<StudentIdentityDbContext>() 12 .AddDefaultTokenProviders();
RequireUniqueEmail 限制每个邮箱只能用于一个账号。
此处 AllowedUserNameCharacters 方法限制用户名能够使用的字符,需要单独输入每个字符。
剩下的设置分别为限制密码必须有符号 / 包含小写字母 / 包含大写字母 / 包含数字。
二、对数据库进行初始化
在此创建一个 StudentInitiator 用以对数据库进行初始化:
1 public class StudentInitiator 2 { 3 public static async Task Initial(IServiceProvider serviceProvider) 4 { 5 UserManager<Student> userManager = serviceProvider.GetRequiredService<UserManager<Student>>(); 6 if (userManager.Users.Any()) 7 { 8 return; 9 } 10 IEnumerable<Student> initialStudents = new[] 11 { 12 new Student() 13 { 14 UserName = "U201600001", 15 Name = "Nanase", 16 Email = "Nanase@cnblog.com", 17 PhoneNumber = "12345678910", 18 Degree = Degrees.CollegeStudent, 19 MaxBooksNumber = 10, 20 }, 21 new Student() 22 { 23 UserName = "U201600002", 24 Name = "Ruri", 25 Email = "NanaseRuri@cnblog.com", 26 PhoneNumber = "12345678911", 27 Degree = Degrees.DoctorateDegree, 28 MaxBooksNumber = 15 29 }, 30 }; 31 32 foreach (var student in initialStudents) 33 { 34 await userManager.CreateAsync(student, student.UserName.Substring(student.UserName.Length - 6,6)); 35 } 36 } 37 }
为确保能够进行初始化,在 Startup.cs 的 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] 特性构造函数传入一个字符串用来告知对应属性在使用 Html.EditorFor() 时用什么模板来展示数据。
1 public enum LoginType 2 { 3 UserName, 4 Email, 5 Phone 6 } 7 8 public class LoginModel 9 { 10 [Required(ErrorMessage = "请输入您的学号 / 邮箱 / 手机号码")] 11 [Display(Name = "学号 / 邮箱 / 手机号码")] 12 public string Account { get; set; } 13 14 [Required(ErrorMessage = "请输入您的密码")] 15 [UIHint("password")] 16 [Display(Name = "密码")] 17 public string Password { get; set; } 18 19 [Required] 20 public LoginType LoginType { get; set; } 21 }
使用支架特性创建一个 StudentAccountController
StudentAccount 控制器:
第 5 行判断是否授权以避免多余的授权:
1 public class StudentAccountController : Controller 2 { 3 public IActionResult Login(string returnUrl) 4 { 5 if (HttpContext.User.Identity.IsAuthenticated) 6 { 7 return RedirectToAction("AccountInfo"); 8 } 9 10 LoginModel loginInfo = new LoginModel(); 11 ViewBag.returnUrl = returnUrl; 12 return View(loginInfo); 13 } 14 }
在在 Login 视图中添加多种登录方式,并使视图更加清晰,创建了一个 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 }
Login 视图:
25 行中使用了刚建立的 LoginTypeTagHelper:
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>
然后创建一个用于对信息进行验证的动作方法。
为了获取数据库的数据以及对数据进行验证授权,需要通过 DI(依赖注入) 获取对应的 UserManager 和 SignInManager 对象,在此针对 StudentAccountController 的构造函数进行更新。
StudentAccountController 整体:
1 [Authorize] 2 public class StudentAccountController : Controller 3 { 4 private UserManager<Student> _userManager; 5 private SignInManager<Student> _signInManager; 6 7 public StudentAccountController(UserManager<Student> studentManager, SignInManager<Student> signInManager) 8 { 9 _userManager = studentManager; 10 _signInManager = signInManager; 11 } 12 13 [AllowAnonymous] 14 public IActionResult Login(string returnUrl) 15 { 16 if (HttpContext.User.Identity.IsAuthenticated) 17 { 18 return RedirectToAction("AccountInfo"); 19 } 20 21 LoginModel loginInfo = new LoginModel(); 22 ViewBag.returnUrl = returnUrl; 23 return View(loginInfo); 24 } 25 26 [HttpPost] 27 [ValidateAntiForgeryToken] 28 [AllowAnonymous] 29 public async Task<IActionResult> Login(LoginModel loginInfo, string returnUrl) 30 { 31 if (ModelState.IsValid) 32 { 33 Student student =await GetStudentByLoginModel(loginInfo); 34 35 if (student == null) 36 { 37 return View(loginInfo); 38 } 39 SignInResult signInResult = await _signInManager.PasswordSignInAsync(student, loginInfo.Password, false, false); 40 41 if (signInResult.Succeeded) 42 { 43 return Redirect(returnUrl ?? "/StudentAccount/"+nameof(AccountInfo)); 44 } 45 46 ModelState.AddModelError("", "账号或密码错误"); 47 48 } 49 50 return View(loginInfo); 51 } 52 53 public IActionResult AccountInfo() 54 { 55 return View(CurrentAccountData()); 56 } 57 58 Dictionary<string, object> CurrentAccountData() 59 { 60 var userName = HttpContext.User.Identity.Name; 61 var user = _userManager.FindByNameAsync(userName).Result; 62 63 return new Dictionary<string, object>() 64 { 65 ["学号"]=userName, 66 ["姓名"]=user.Name, 67 ["邮箱"]=user.Email, 68 ["手机号"]=user.PhoneNumber, 69 }; 70 } 71 }
_userManager 以及 _signInManager 将通过 DI 获得实例;[ValidateAntiForgeryToken] 特性用于防止 XSRF 攻击;returnUrl 参数用于接收或返回之前正在访问的页面,在此处若 returnUrl 为空则返回 AccountInfo 页面;[Authorize] 特性用于确保只有已授权的用户才能访问对应动作方法;CurrentAccountData 方法用于获取当前用户的信息以在 AccountInfo 视图中呈现。
由于未进行授权,在此直接访问 AccountInfo 方法默认会返回 /Account/Login 页面请求验证,可通过在 Startup.cs 的 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();
直接访问 AccountInfo 页面:
输入账号密码进行验证:
验证之后返回 /StudentAccount/AccountInfo 页面:
四、创建登出网页
简单地调用 SignOutAsync 用以清除当前 Cookie 中的授权信息。
1 public async Task<IActionResult> Logout(string returnUrl) 2 { 3 await _signInManager.SignOutAsync(); 4 if (returnUrl == null) 5 { 6 return View("Login"); 7 } 8 9 return Redirect(returnUrl); 10 }
同时在 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 页面需要重新进行验证。
附加使用邮箱以及手机号验证的测试:
五、基于 Role 的 Identity 授权
修改 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 = "Nanase@cnblog.com", 29 PhoneNumber = "12345678910", 30 Degree = Degrees.CollegeStudent, 31 MaxBooksNumber = 10, 32 }, 33 new Student() 34 { 35 UserName = "U201600002", 36 Name = "Ruri", 37 Email = "NanaseRuri@cnblog.com", 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 = "Admin@cnblog.com", 51 PhoneNumber = "12345678912", 52 Degree = Degrees.CollegeStudent, 53 MaxBooksNumber = 20 54 }, 55 new Student() 56 { 57 UserName = "A000000001", 58 Name = "Admin0001", 59 Email = "123456789@qq.com", 60 PhoneNumber = "12345678910", 61 Degree = Degrees.CollegeStudent, 62 MaxBooksNumber = 20 63 }, 64 }; 65 foreach (var student in initialStudents) 66 { 67 await userManager.CreateAsync(student, student.UserName.Substring(student.UserName.Length - 6, 6)); 68 } 69 foreach (var admin in initialAdmins) 70 { 71 await userManager.CreateAsync(admin, "zxcZXC!123"); 72 await userManager.AddToRoleAsync(admin, "Admin"); 73 } 74 } 75 }
对 ConfigureServices 作进一步配置,添加 Cookie 的过期时间和不满足 Authorize 条件时返回的 Url:
1 services.ConfigureApplicationCookie(opts => 2 { 3 opts.Cookie.HttpOnly = true; 4 opts.LoginPath = "/StudentAccount/Login"; 5 opts.AccessDeniedPath = "/StudentAccount/Login"; 6 opts.ExpireTimeSpan=TimeSpan.FromMinutes(5); 7 });
则当 Role 不为 Admin 时将返回 /StudentAccount/Login 而非默认的 /Account/AccessDeny。
然后新建一个用以管理学生信息的 AdminAccount 控制器,设置 [Authorize] 特性并指定 Role 属性,使带有特定 Role 的身份才可以访问该控制器。
1 [Authorize(Roles = "Admin")] 2 public class AdminAccountController : Controller 3 { 4 private UserManager<Student> _userManager; 5 6 public AdminAccountController(UserManager<Student> userManager) 7 { 8 _userManager = userManager; 9 } 10 11 public IActionResult Index() 12 { 13 ICollection<Student> students = _userManager.Users.ToList(); 14 return View(students); 15 } 16 }
Index 视图:
1 @using LibraryDemo.Models.DomainModels 2 @model IEnumerable<LibraryDemo.Models.DomainModels.Student> 3 @{ 4 ViewData["Title"] = "AccountInfo"; 5 Student stu = new Student(); 6 } 7 <link rel="stylesheet" href="~/css/BookInfo.css" /> 8 9 <script> 10 function confirmDelete() { 11 var userNames = document.getElementsByName("userNames"); 12 var message = "确认删除"; 13 var values = []; 14 for (i in userNames) { 15 if (userNames[i].checked) { 16 message = message + userNames[i].value+","; 17 values.push(userNames[i].value); 18 } 19 } 20 message = message + "?"; 21 if (confirm(message)) { 22 $.ajax({ 23 url: "@Url.Action("RemoveStudent")", 24 contentType: "application/json", 25 method: "POST", 26 data: JSON.stringify(values), 27 success: function(students) { 28 updateTable(students); 29 } 30 }); 31 } 32 } 33 34 function updateTable(data) { 35 var body = $("#studentList"); 36 body.empty(); 37 for (var i = 0; i < data.length; i++) { 38 var person = data[i]; 39 body.append(`<tr><td><input type="checkbox" name="userNames" value="${person.userName}" /></td> 40 <td>${person.userName}</td><td>${person.name}</td><td>${person.degree}</td> 41 <td>${person.phoneNumber}</td><td>${person.email}</td><td>${person.maxBooksNumber}</td></tr>`); 42 } 43 }; 44 45 function addStudent() { 46 var studentList = $("#studentList"); 47 if (!document.getElementById("studentInfo")) { 48 studentList.append('<tr id="studentInfo">' + 49 '<td></td>' + 50 '<td><input type="text" name="UserName" id="UserName" /></td>' + 51 '<td><input type="text" name="Name" id="Name" /></td>' + 52 '<td><input type="text" name="Degree" id="Degree" /></td>' + 53 '<td><input type="text" name="PhoneNumber" id="PhoneNumber" /></td>' + 54 '<td><input type="text" name="Email" id="Email" /></td>' + 55 '<td><input type="text" name="MaxBooksNumber" id="MaxBooksNumber" /></td>' + 56 '<td><button type="submit" onclick="return postAddStudent()">添加</button></td>' + 57 '</tr>'); 58 } 59 } 60 61 function postAddStudent() { 62 $.ajax({ 63 url: "@Url.Action("AddStudent")", 64 contentType: "application/json", 65 method: "POST", 66 data: JSON.stringify({ 67 UserName: $("#UserName").val(), 68 Name: $("#Name").val(), 69 Degree:$("#Degree").val(), 70 PhoneNumber: $("#PhoneNumber").val(), 71 Email: $("#Email").val(), 72 MaxBooksNumber: $("#MaxBooksNumber").val() 73 }), 74 success: function (student) { 75 addStudentToTable(student); 76 } 77 }); 78 } 79 80 function addStudentToTable(student) { 81 var studentList = document.getElementById("studentList"); 82 var studentInfo = document.getElementById("studentInfo"); 83 studentList.removeChild(studentInfo); 84 85 $("#studentList").append(`<tr>` + 86 `<td><input type="checkbox" name="userNames" value="${student.userName}" /></td>` + 87 `<td>${student.userName}</td>` + 88 `<td>${student.name}</td>`+ 89 `<td>${student.degree}</td>` + 90 `<td>${student.phoneNumber}</td>` + 91 `<td>${student.email}</td>` + 92 `<td>${student.maxBooksNumber}</td >` + 93 `</tr>`); 94 } 95 </script> 96 97 <h2>学生信息</h2> 98 99 <div id="buttonGroup"> 100 <button class="btn btn-primary" onclick="return addStudent()">添加学生</button> 101 <button class="btn btn-danger" onclick="return confirmDelete()">删除学生</button> 102 </div> 103 104 105 <br /> 106 <table> 107 <thead> 108 <tr> 109 <th></th> 110 <th>@Html.LabelFor(m => stu.UserName)</th> 111 <th>@Html.LabelFor(m => stu.Name)</th> 112 <th>@Html.LabelFor(m => stu.Degree)</th> 113 <th>@Html.LabelFor(m => stu.PhoneNumber)</th> 114 <th>@Html.LabelFor(m => stu.Email)</th> 115 <th>@Html.LabelFor(m => stu.MaxBooksNumber)</th> 116 </tr> 117 </thead> 118 <tbody id="studentList"> 119 120 @if (!@Model.Any()) 121 { 122 <tr><td colspan="6">未有学生信息</td></tr> 123 } 124 else 125 { 126 foreach (var student in Model) 127 { 128 <tr> 129 <td><input type="checkbox" name="userNames" value="@student.UserName" /></td> 130 <td>@student.UserName</td> 131 <td>@student.Name</td> 132 <td>@Html.DisplayFor(m => student.Degree)</td> 133 <td>@student.PhoneNumber</td> 134 <td>@student.Email</td> 135 <td>@student.MaxBooksNumber</td> 136 </tr> 137 } 138 } 139 </tbody> 140 </table>
使用 Role 不是 Admin 的账户登录:
使用 Role 为 Admin 的账户登录: