Try   HackMD

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專案。

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

將 ASP.NET Identity 加至 ASP.NET MVC Empty 專案中

Form Authentication

首先新增Empty MVC專案(無驗證)。如果你有現有的專案也可以。

在HomeController加上[Authorize]標籤

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

進到首頁會得到HTTP Error 401.0 – Unauthorized回應

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

接著在專案下 Web.config 新增authentication設定

<configuration> ... <system.web> ... <authentication mode="Forms"> <forms loginUrl="/Account/Login" timeout="2880" defaultUrl="/Home/Index" /> </authentication> </system.web> ... </configuration>

由於登入路徑設為/Account/Login,所以我們要建立相對應的Controller, Action與View

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

重新將網站run起來就會導至設定的登入頁面

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

當使用者成功登入時(此部分省略),要新增驗證Cookie

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

ASP.NET MVC 使用Forms Authentication 表單驗證登入

Login and Register steps

接下來主要的內容就是實作登入的功能以及相關流程。

Table

因為需要用到資料庫,可以先參考code first方法建立資料庫。

需要使用到的User資料表

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檔案給登入和註冊頁面使用,

public class LoginViewModel { [Required] public string Email { get; set; } [Required] public string Password { get; set; } }
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,先有個大致上的樣子稍後會再修改。

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

body { background: linear-gradient(135deg, #8e13e3, #4e3cc1); min-height: 100vh; }

_Layout.cshtml連結部分修改一下加上登入與登出連結,原本navbar-nav後加上mr-auto,並再加上新的ul.navbar-nav,並加上User.Identity.IsAuthenticated判斷是否登入。

<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僅示意不實做。

<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"僅示意不實作。

<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。

先在專案下新增Services資料夾和AccountService.cs檔案,再來直接使用IntelliSense幫你產生對應方法。

先從註冊開始,在Register Post方法加上註冊流程,成功註冊新增使用者後將頁面導至登入頁,不然停留在原註冊頁面;新增使用者詳細邏輯放在service內實作。

[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內實作。

[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); }

登出比較簡單就這樣而已。

public ActionResult Logout() { FormsAuthentication.SignOut(); Session.RemoveAll(); return RedirectToAction("Index","Home"); }

AccountService

這時候AccountService應該會有三個未實作的方法CreateUser, ValidateUser, GenCookie;在實作方法前先新增UserToken.cs類別,稍後會用到。

public class UserToken { public string PasswordHash { get; set; } public string PasswordSalt { get; set; } public int PasswordWorkFactor { get; set; } }

AccountService.cs新增兩個private方法,稍後CreateUser, ValidateUser使用。

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稍後新增)

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登入時驗證該使用者

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。

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。

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)

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

private byte[] GenerateSalt(int length) { var bytes = new byte[length]; using (var rng = new RNGCryptoServiceProvider()) { rng.GetBytes(bytes); } return bytes; }
private byte[] GenerateHash(byte[] password, byte[] salt, int iterations, int length) { using (var deriveBytes = new Rfc2898DeriveBytes(password, salt, iterations)) { return deriveBytes.GetBytes(length); } }

另外還需要轉Base64編碼方法,因使用者輸入密碼可能含有非Base64編碼內容,無法直接使用Convert.FromBase64String()轉換。

private static string Base64Encode(string plainText) { var plainTextBytes = Encoding.UTF8.GetBytes(plainText); return Convert.ToBase64String(plainTextBytes); }

剩下兩個AccountService用到的方法:GenerateUserToken,IsValidPassword

GenerateUserToken為產生隨機salt再結合user輸入的密碼雜湊,將所需內容放入UserToken回傳。

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值是否與雜湊過的輸入密碼相等。

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
Salted Password Hashing