基本登录页面以及授权逻辑的建立
来源: 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 的账户登录:


Mikel
