# Login with ASP.NET MVC ###### tags: `.NetFramework` `Login` `ASP.NET Identity` .Net Framework 會員登入有兩種,一種是 ASP.NET Identity ,另一種是Form Authentication。 ## ASP.NET Identity 建立MVC專案時,驗證選項可以選"個別使用者帳戶",建立時自動產生Identity會員登入相關程式碼,或是將 ASP.NET Identity相關程式碼新增至Empty專案。 ![](https://i.imgur.com/QX5kI6p.png) >[將 ASP.NET Identity 加至 ASP.NET MVC Empty 專案中](https://blog.yowko.com/add-aspnet-identity-empty-project/) ## Form Authentication 首先新增Empty MVC專案(無驗證)。如果你有現有的專案也可以。 在HomeController加上`[Authorize]`標籤 ![](https://i.imgur.com/81isVN0.png) 進到首頁會得到HTTP Error 401.0 – Unauthorized回應 ![](https://i.imgur.com/8g5ltq2.png) 接著在專案下 Web.config 新增authentication設定 ```xml= <configuration> ... <system.web> ... <authentication mode="Forms"> <forms loginUrl="/Account/Login" timeout="2880" defaultUrl="/Home/Index" /> </authentication> </system.web> ... </configuration> ``` 由於登入路徑設為/Account/Login,所以我們要建立相對應的Controller, Action與View ![](https://i.imgur.com/oHi8FP7.png) 重新將網站run起來就會導至設定的登入頁面 ![](https://i.imgur.com/QJPDa5X.png) 當使用者成功登入時(此部分省略),要新增驗證Cookie ```csharp= var ticket = new FormsAuthenticationTicket( version: 1, name: account, //可以放使用者Id issueDate: DateTime.UtcNow,//現在UTC時間 expiration: DateTime.UtcNow.AddMinutes(30),//Cookie有效時間=現在時間往後+30分鐘 isPersistent: true,// 是否要記住我 true or false userData: string.Join(",", roles), //可以放使用者角色名稱 cookiePath: FormsAuthentication.FormsCookiePath ); var encryptedTicket = FormsAuthentication.Encrypt(ticket); //把驗證的表單加密 var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket); Response.Cookies.Add(cookie); ``` 以上就是From Authentication主要的內容,不含帳號註冊驗證等步驟,詳細步驟見下方[Login and Register steps](#Login-and-Register-steps)。 >[ASP.NET MVC 使用Forms Authentication 表單驗證登入](http://ikevin.tw/2018/07/28/asp-net-mvc-%E4%BD%BF%E7%94%A8forms-authentication-%E8%A1%A8%E5%96%AE%E9%A9%97%E8%AD%89%E7%99%BB%E5%85%A5/) ### Login and Register steps 接下來主要的內容就是實作登入的功能以及相關流程。 ### Table :::info 因為需要用到資料庫,可以先參考code first方法建立資料庫。 ::: 需要使用到的User資料表 ```csharp= public class User { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public string PasswordHash { get; set; } public string PasswordSalt { get; set; } public int PasswordWorkFactor { get; set; } } ``` ### ViewModels 先在Models/ViewModes/資料夾下新增LoginViewModel.cs與RegisterViewModel.cs檔案給登入和註冊頁面使用, ```csharp= public class LoginViewModel { [Required] public string Email { get; set; } [Required] public string Password { get; set; } } ``` ```csharp= public class RegisterViewModel { [Required] public string Name { get; set; } [Required] public string Email { get; set; } [Required] public string Password { get; set; } [Required] public string PasswordConfirmed { get; set; } } ``` ### Controller 新增AccountController.cs,先有個大致上的樣子稍後會再修改。 ```csharp= public class AccountController : Controller { private MyAppContext context = new MyAppContext(); [HttpGet] public ActionResult Login() { return View(); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Login(LoginViewModel viewModel) { if (!ModelState.IsValid) { return View(viewModel); } return View(); } [HttpGet] public ActionResult Register() { return View(); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Register(RegisterViewModel viewModel) { if (!ModelState.IsValid) { return View(viewModel); } return View(); } public ActionResult Logout(){ } } ``` ### Views 簡單調整一下Site.css ```css= body { background: linear-gradient(135deg, #8e13e3, #4e3cc1); min-height: 100vh; } ``` _Layout.cshtml連結部分修改一下加上登入與登出連結,原本`navbar-nav`後加上`mr-auto`,並再加上新的`ul.navbar-nav`,並加上`User.Identity.IsAuthenticated`判斷是否登入。 ```htmlmixed= <ul class="navbar-nav mr-auto"> <li class="nav-item active">@Html.ActionLink("首頁", "Index", "Home", null, new { @class = "nav-link" })</li> <li class="nav-item">@Html.ActionLink("關於", "About", "Home", null, new { @class = "nav-link" })</li> <li class="nav-item">@Html.ActionLink("連絡人", "Contact", "Home", null, new { @class = "nav-link" })</li> </ul> <ul class="navbar-nav"> @if (User.Identity.IsAuthenticated) { <li class="nav-item">@Html.ActionLink("登出", "Logout", "Account", null, new { @class = "nav-link" })</li> } else { <li class="nav-item">@Html.ActionLink("登入", "Login", "Account", null, new { @class = "nav-link" })</li> } </ul> ``` 加入Login.cshtml頁面,這邊Forgot password僅示意不實做。 ![](https://i.imgur.com/C9UJ81I.png) ```htmlmixed= <div class="card align-items-center shadow mx-auto my-5 px-4 py-5" style="width: 350px;"> <h2>Sign in</h2> <form action="/Account/Login" method="post" class="w-100"> @Html.AntiForgeryToken() <div class="form-group"> <label for="Email">Email address</label> <input type="email" class="form-control" id="Email" name="Email" placeholder="example@example.com" data-val="true" data-val-required="required" data-val-email="email only"> <span class="field-validation-valid text-danger" data-valmsg-for="Email" data-valmsg-replace="true"></span> <small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small> </div> <div class="form-group"> <div class="d-flex justify-content-between"> <label for="Password">Password</label><small><a href="#" class="text-muted">Forgot Password?</a></small> </div> <input type="password" class="form-control" id="Password" name="Password" data-val="true" data-val-required="required"> <span class="field-validation-valid text-danger" data-valmsg-for="Password" data-valmsg-replace="true"></span> </div> <div class="form-group form-check"> <input type="checkbox" class="form-check-input" id="exampleCheck1"> <label class="form-check-label text-muted" for="exampleCheck1">Remember me</label> </div> <button type="submit" class="btn btn-primary w-100">Sign in</button> <small><a href="/Account/Register">Sign up here</a></small> </form> </div> @section Scripts { @Scripts.Render("~/bundles/jqueryval") } ``` 加入Register.cshtml頁面,"I accept the Terms and Conditions"僅示意不實作。 ![](https://i.imgur.com/jDMHZgo.png) ```htmlmixed= <div class="card align-items-center shadow mx-auto my-5 px-4 py-5" style="width: 400px;"> <h2>Create your account</h2> <form action="/Account/Register" method="post" class="w-100"> @Html.AntiForgeryToken() <div class="form-group"> <label for="Name">Your name</label> <input type="text" class="form-control" id="Name" name="Name" data-val="true" data-val-required="required"> <span class="field-validation-valid text-danger" data-valmsg-for="Name" data-valmsg-replace="true"></span> </div> <div class="form-group"> <label for="Email">Email address</label> <input type="email" class="form-control" id="Email" name="Email" placeholder="example@example.com" data-val="true" data-val-required="required" data-val-email="email only"> <span class="field-validation-valid text-danger" data-valmsg-for="Email" data-valmsg-replace="true"></span> <small id="emailHelp" class="form-text text-muted">We'll never share your email with anyone else.</small> </div> <div class="form-group"> <label for="Password">Password</label> <input type="password" class="form-control" id="Password" name="Password" data-val="true" data-val-required="required"> <span class="field-validation-valid text-danger" data-valmsg-for="Password" data-valmsg-replace="true"></span> </div> <div class="form-group"> <label for="PasswordConfirmed">Password Confirmed <span class="field-validation-valid text-danger" data-valmsg-for="PasswordConfirmed" data-valmsg-replace="true"></span></label> <input type="password" class="form-control" id="PasswordConfirmed" name="PasswordConfirmed" data-val="true" data-val-required="required" data-val-equalto="not match" data-val-equalto-other="Password"> </div> <div class="form-group form-check"> <input type="checkbox" class="form-check-input" id="exampleCheck1"> <label class="form-check-label text-muted" for="exampleCheck1"> I accept the <a href="#">Terms and Conditions</a> </label> </div> <button type="submit" class="btn btn-primary w-100">Create an account</button> </form> </div> @section Scripts { @Scripts.Render("~/bundles/jqueryval") } ``` Login, Register view 都有加上 jquery.validate.unobtrusive驗證項目 ### Controller (Edit) 接著要來修改一下Controller。 :::warning 先在專案下新增Services資料夾和AccountService.cs檔案,再來直接使用IntelliSense幫你產生對應方法。 ::: 先從註冊開始,在Register Post方法加上註冊流程,成功註冊新增使用者後將頁面導至登入頁,不然停留在原註冊頁面;新增使用者詳細邏輯放在service內實作。 ```csharp= [HttpPost] [ValidateAntiForgeryToken] public ActionResult Register(RegisterViewModel viewModel) { if (!ModelState.IsValid) { return View(viewModel); } AccountService service = new AccountService(); bool isCreated = service.CreateUser(viewModel); if (isCreated) { return RedirectToAction("Login"); } return View(viewModel); } ``` 再來是登入,在Login Post方法加上註冊流程,成功驗證使用者後產生並加入Cookie,導回首頁;詳細邏輯同樣放在service內實作。 ```csharp= [HttpPost] [ValidateAntiForgeryToken] public ActionResult Login(LoginViewModel viewModel) { if (!ModelState.IsValid) { return View(viewModel); } AccountService service = new AccountService(); bool isSuccess = service.ValidateUser(viewModel); if (isSuccess) { HttpCookie cookie = service.GenCookie(viewModel.Email); Response.Cookies.Add(cookie); return RedirectToAction("Index", "Home"); } return View(viewModel); } ``` 登出比較簡單就這樣而已。 ```csharp= public ActionResult Logout() { FormsAuthentication.SignOut(); Session.RemoveAll(); return RedirectToAction("Index","Home"); } ``` ### AccountService 這時候AccountService應該會有三個未實作的方法`CreateUser`, `ValidateUser`, `GenCookie`;在實作方法前先新增UserToken.cs類別,稍後會用到。 ```csharp= public class UserToken { public string PasswordHash { get; set; } public string PasswordSalt { get; set; } public int PasswordWorkFactor { get; set; } } ``` AccountService.cs新增兩個private方法,稍後`CreateUser`, `ValidateUser`使用。 ```csharp= private bool HasUser(string email) { return context.Users.Any(x => x.Email == email); } private User GetUser(string email) { return context.Users.First(x => x.Email == email); } ``` 先前由Controller註冊使用者時呼叫AccountService.CreateUser()方法,這裡主要產生驗證的東西與將使用者新增至資料庫。(EncryptService.cs稍後新增) ```csharp= public bool CreateUser(RegisterViewModel viewModel) { if (!HasUser(viewModel.Email)) { UserToken token = new EncryptService().GenerateUserToken(viewModel.Password); User user = new User { Name = viewModel.Name, Email = viewModel.Email, PasswordHash = token.PasswordHash, PasswordSalt = token.PasswordSalt, PasswordWorkFactor = token.PasswordWorkFactor, }; context.Users.Add(user); context.SaveChanges(); return true; } return false; } ``` 由Controller登入時驗證該使用者 ```csharp= public bool ValidateUser(LoginViewModel viewModel) { if (HasUser(viewModel.Email)) { User user = GetUser(viewModel.Email); bool isValid = new EncryptService().IsValidPassword(user.PasswordHash, user.PasswordSalt, viewModel.Password); return isValid; } return false; } ``` 成功驗證使用者後產生Cookie。 ```csharp= public HttpCookie GenCookie(string email) { var ticket = new FormsAuthenticationTicket( version: 1, name: email, //可以放使用者Id issueDate: DateTime.UtcNow,//現在UTC時間 expiration: DateTime.UtcNow.AddMinutes(30),//Cookie有效時間=現在時間往後+30分鐘 isPersistent: true,// 是否要記住我 true or false userData: "", //可以放使用者角色名稱 cookiePath: FormsAuthentication.FormsCookiePath ); var encryptedTicket = FormsAuthentication.Encrypt(ticket); //把驗證的表單加密 var cookie = new HttpCookie(FormsAuthentication.FormsCookieName, encryptedTicket) { // 重點!!避免 Cookie遭受攻擊、盜用或不當存取。請查詢關鍵字「」。 HttpOnly = true // 必須上網透過http才可以存取Cookie。不允許用戶端(寫前端程式)存取 //Secure = true; // 需要搭配https(SSL)才行。 }; return cookie; } ``` ### EncryptService 接著就是做還沒做的EncryptService。我們一樣在Services資料夾內新增EncryptService.cs檔案,需要三個property。 ```csharp= public int SaltLength { get; private set; } public int HashLength { get; private set; } public int Iteration { get; private set; } ``` 新增建構子多載,可供呼叫端自訂Salt, Hash長度與Work factor (可參考下方Salted Password Hashing內文章:Making Password Cracking Harder: Slow Hash Functions) ```csharp= public EncryptService() : this(256, 256, 5) { } public EncryptService(int saltLength, int hashLength, int iteration) { SaltLength = saltLength; HashLength = hashLength; Iteration = iteration; } ``` 我們主要需要的方法有:`GenerateSalt`, `GenerateHash`, 以及AccountService用到的`GenerateUserToken`,`IsValidPassword` `GenerateSalt`, `GenerateHash`只需要private就好,此部分參考[Password Hash Example](https://www.mking.net/blog/password-security-best-practices-with-examples-in-csharp) ```csharp= private byte[] GenerateSalt(int length) { var bytes = new byte[length]; using (var rng = new RNGCryptoServiceProvider()) { rng.GetBytes(bytes); } return bytes; } ``` ```csharp= private byte[] GenerateHash(byte[] password, byte[] salt, int iterations, int length) { using (var deriveBytes = new Rfc2898DeriveBytes(password, salt, iterations)) { return deriveBytes.GetBytes(length); } } ``` 另外還需要轉[Base64編碼](https://zh.wikipedia.org/zh-tw/Base64)方法,因使用者輸入密碼可能含有非Base64編碼內容,無法直接使用`Convert.FromBase64String()`轉換。 ```csharp= private static string Base64Encode(string plainText) { var plainTextBytes = Encoding.UTF8.GetBytes(plainText); return Convert.ToBase64String(plainTextBytes); } ``` 剩下兩個AccountService用到的方法:`GenerateUserToken`,`IsValidPassword` GenerateUserToken為產生隨機salt再結合user輸入的密碼雜湊,將所需內容放入UserToken回傳。 ```csharp= public UserToken GenerateUserToken(string password) { var encodePass = Base64Encode(password); var salt = GenerateSalt(SaltLength); var hash = GenerateHash(Convert.FromBase64String(encodePass), salt, Iteration, HashLength); UserToken token = new UserToken { PasswordSalt = Convert.ToBase64String(salt), PasswordHash = Convert.ToBase64String(hash), PasswordWorkFactor = Iteration }; return token; } ``` 這邊驗證只驗證輸入的hash值是否與雜湊過的輸入密碼相等。 ```csharp= public bool IsValidPassword(string inputHash, string inputSalt, string password) { var encodePass = Base64Encode(password); var hash = GenerateHash(Convert.FromBase64String(encodePass), Convert.FromBase64String(inputSalt), Iteration, HashLength); var hasString = Convert.ToBase64String(hash); return inputHash.Equals(hasString); } ``` >[Password Hash Example](https://www.mking.net/blog/password-security-best-practices-with-examples-in-csharp) >[Salted Password Hashing](https://crackstation.net/hashing-security.htm)