.NetFramework
Login
ASP.NET Identity
.Net Framework 會員登入有兩種,一種是 ASP.NET Identity ,另一種是Form Authentication。
建立MVC專案時,驗證選項可以選"個別使用者帳戶",建立時自動產生Identity會員登入相關程式碼,或是將 ASP.NET Identity相關程式碼新增至Empty專案。
首先新增Empty MVC專案(無驗證)。如果你有現有的專案也可以。
在HomeController加上[Authorize]
標籤
進到首頁會得到HTTP Error 401.0 – Unauthorized回應
接著在專案下 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
重新將網站run起來就會導至設定的登入頁面
當使用者成功登入時(此部分省略),要新增驗證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。
接下來主要的內容就是實作登入的功能以及相關流程。
因為需要用到資料庫,可以先參考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; }
}
先在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; }
}
新增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(){
}
}
簡單調整一下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。
先在專案下新增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應該會有三個未實作的方法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。我們一樣在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);
}