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、Identity 找回密码、c# SMTP 的使用、配置文件的使用

 

 

 一到四为对 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 = "Nanase@cnblog.com",15 PhoneNumber = "12345678910",16 Degree = Degrees.CollegeStudent,17 MaxBooksNumber = 10,18  },19 new Student()20  {21 UserName = "U201600002",22 Name = "Ruri",23 Email = "NanaseRuri@cnblog.com",24 PhoneNumber = "12345678911",25 Degree = Degrees.DoctorateDegree,26 MaxBooksNumber = 1527  },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 = "admin@cnblog.com",17 PhoneNumber = "10000000000",18 Password = "123456"19  },20 new Admin()21  {22 UserName = "admin1",23 Email = "admin1@cnblog.com",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 = "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 = 1541  }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 = 2054  }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。

 

相关文章