[ASP.net MVC] ASP.net MVC整合FormsAuthentication表單驗證登入 - 簡易範例程式碼

[ASP.net MVC] ASP.net MVC整合FormsAuthentication表單驗證登入 - 簡易範例程式碼

前言

以往寫網站的登入機制,最常使用傳統的Session寫法

不過最近手頭上某案子被要求使用.net內建的FormsAuthentication表單驗證機制

所以寫個簡單的範例程式碼(很夠用),供未來的自己方便Copy Paste

※使用FormsAuthentication表單驗證機制不一定要照官方教學用那個很複雜的Membership資料庫,用自己專案資料庫的使用者資料表&角色群組資料表也可以整合

 

實作

1.先啟用Client端驗證※此步驟跟登入沒關係可略過

確認Web.config裡appSettings區段中已有下面兩個設定


<add key="ClientValidationEnabled" value="true" />
<add key="UnobtrusiveJavaScriptEnabled" value="true" />

管理Nuget套件

image

加入以下幾個套件

image

登入頁稍後再引用以下.js檔案


<script src="~/Scripts/jquery-2.1.0.js"></script>
<script src="~/Scripts/jquery.validate.js"></script>
<script src="~/Scripts/jquery.validate.unobtrusive.js"></script>

頁面上再宣告@model使用ViewModel,即可啟用Client欄位驗證

※上面步驟跟登入沒關係可略過,以下開始才是重點...

2.在Web.config的<system.web>區段裡定義

※2018.3.12追加,不知道從什麼時候開始,一定要給name屬性,否則無法登入

至於slidingExpiration、cookieless、requireSSL="true"是為了資安白箱弱掃而加上去

 <authentication mode="Forms">
      <!--defaultUrl:登入後到哪一頁,loginUrl:使用者未登入的話,要導至哪一頁-->
  <forms name="Demo_Site" 
             requireSSL="true"
             defaultUrl="~/Home/Index/" loginUrl="~/Home/Login/" 
             slidingExpiration="true" cookieless="UseDeviceProfile" 
             timeout="120" />
    </authentication>
 

3. 定義Login登入頁面會用到的ViewModel


using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Text;
using System.Web;

namespace FormLoginSample.ViewModels
{
    public class LoginVM : IValidatableObject
    {
        
        [DisplayFormat(ConvertEmptyStringToNull = false)]
        public string ReturnUrl { get; set; }

        /// <summary>
        /// 帳號
        /// </summary>
        [DisplayFormat(ConvertEmptyStringToNull = false)]
        [Required]
        public string Account { get; set; }

        /// <summary>
        /// 密碼
        /// </summary>
        [DisplayFormat(ConvertEmptyStringToNull = false)]
        [Required]
        public string Password { get; set; }


        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            //將使用者輸入的字串轉成Base64String
            string base64Password = Convert.ToBase64String(Encoding.UTF8.GetBytes(Password));
             //todo到DB抓使用者資料
            //假如抓不到系統使用者資料
            //※為了Demo用這種寫法,實際請換成判斷DB的資料存不存在
            if (!(Account == "shadow" && base64Password == "c29ueQ=="))
            {
                yield return new ValidationResult("無此帳號或密碼", new string[] { "Account" });
            }
        }
    }
}

4. Controller一律加上[Authorize] Attribute 代表該整個Controller必須要先進行過表單驗證登入後才可以進入

5. 為Login登入的Action加上 [AllowAnonymous] Attribute 代表該Action就算沒進行過表單驗證登入也可以執行

6.Controller完整的的登入和登出程式碼


using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Security;
using FormLoginSample.ViewModels;

namespace FormLoginSample.Controllers
{
    /// <summary>
    /// 後台首頁、登入頁...
    /// </summary>
    [Authorize]
    public class HomeController : Controller
    {
   

        /// <summary>
        /// 呈現後台使用者登入頁
        /// </summary>
        /// <param name="ReturnUrl">使用者原本Request的Url</param>
        /// <returns></returns>
        [AllowAnonymous]
        public ActionResult Login(string ReturnUrl)
        {
            //ReturnUrl字串是使用者在未登入情況下要求的的Url
            LoginVM vm = new LoginVM() { ReturnUrl = ReturnUrl };
            return View(vm);
        }

        /// <summary>
        /// 後台使用者進行登入
        /// </summary>
        /// <param name="vm"></param>
        /// <param name="u">使用者原本Request的Url</param>
        /// <returns></returns>
        [AllowAnonymous]
        [HttpPost]
        public ActionResult Login(LoginVM vm)
        {

            //沒通過Model驗證(必填欄位沒填,DB無此帳密)
            if (!ModelState.IsValid)
            {
                return View(vm);
            }



            //都成功...
            //進行表單登入 ※之後使用User.Identity.Name的值就是vm.Account帳號的值
            FormsAuthentication.SetAuthCookie(vm.Account, false);
            //保哥文章使用 FormsAuthentication.RedirectFromLoginPage(帳號, false); 來登入也是可以
            //但要留意↑會做Redirect,如果後續有使用到Request.UrlReferrer.AbsoluteUri的話,值會改變


            //導向預設Url(Web.config裡的defaultUrl值)或 
            //使用者原先Request的Url(登入頁網址的QueryString:ReturnUrl,此QueryString在表單驗證機制下對於未登入的使用者會自動產生)
            return Redirect(FormsAuthentication.GetRedirectUrl(vm.Account, false));

 
        }
        /// <summary>
        /// 後台使用者登出動作
        /// </summary>
        /// <returns></returns>
        [AllowAnonymous]
        public ActionResult Logout()
        {
            //清除Session中的資料
            Session.Abandon();
            //登出表單驗證
            FormsAuthentication.SignOut();
            //導至登入頁
            return RedirectToAction("Login", "Home");
        }


        /// <summary>
        /// 後台首頁 
        /// </summary>
        /// <returns></returns>
        public ActionResult Index()
        {
            //取得目前登入者的帳號
            string Account = User.Identity.Name;
            //可以依帳號到DB抓登入者的資料...


           


            return View();
        }
        /// <summary>
        /// 測試用的
        /// </summary>
        /// <returns></returns>
        public ActionResult Index2()
        {
            //如果要判斷此登入者有沒有登入的寫法
            //if (User.Identity.IsAuthenticated)
            //{//目前已登入 
            //}
            return View();
        }
        

    }
}

7. Login登入頁的View


@using FormLoginSample.ViewModels
@model LoginVM
@{
    Layout = null;
}

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <script src="~/Scripts/jquery-2.1.0.js"></script>
    <script src="~/Scripts/jquery.validate.js"></script>
    <script src="~/Scripts/jquery.validate.unobtrusive.js"></script>
    <title>Login</title>
</head>
<body>
    <div>
        @*如果↓表單的ReturnUrl沒給的話,就沒辦法導向使用者原先要求的頁面*@
         @using (Html.BeginForm("Login", "Home", new { ReturnUrl  = Model.ReturnUrl}, FormMethod.Post, new {   autocomplete = "off" }))
         {
            
                    @Html.TextBoxFor(m => m.Account ) @Html.ValidationMessageFor(m=>m.Account)
                              <br />
                    @Html.PasswordFor(m => m.Password ) @Html.ValidationMessageFor(m=>m.Password)
                              <br />
          
                   <input type="submit"   value="登入" /> 
               
         }
    </div>
</body>
</html>

其他不是本文重點的另兩個頁面View(Index和Index2)

 


<!DOCTYPE html>
<html>
<head>
    <title>Index</title>
</head>
<body>
    <div>
        <h1>Index</h1>
        <div><a href="@Url.Action("Logout","Home")">登出</a></div>
    </div>
</body>
</html>

 

 


<!DOCTYPE html>
<html>
<head>
    <title>Index2</title>
</head>
<body>
    <div>
        <h2>Index2</h2>
        <div><a href="@Url.Action("Logout","Home")">登出</a></div>
    </div>
</body>
</html>

 

執行結果

1.假設一開始使用者沒登入狀態下進到/Home/Index2

image

使用者會被自動導至登入頁(瀏覽器後面會帶ReturnUrl)

image

若這時候使用者輸入正確的帳密再按一次登入

image

↑程式就會自動把使用者導至原先要求的Url(/Home/Index2)

當然若使用者一開始老老實實地在/Home/Login/輸入帳密登入成功,就直接進到/Home/Index頁囉

 

 

結語

使用傳統的Session寫法或採用.net內建的FormsAuthentication表單驗證機制各有好壞

Session不易維護(Session的Key值定義哪些,開發者通常要到各Action程式碼查找)、開發時期,網站一經過編譯,Session就會遺失等缺點

FormsAuthentication表單驗證機制的話,則無上述困擾(因為把資訊存到Cookie),但缺點是一個Web專案只能使用一個FormsAuthentication表單驗證機制,Session寫法則無此限制

而且以本範例程式碼而言,採用FormsAuthentication表單驗證,每次要抓登入者資訊的話,就只能用User.Identity.Name(帳號)從DB重新再撈資料,會有拖累效能問題

雖然解法可以在登入時把該登入者其他資訊存入Cookie(請見:簡介 ASP.NET 表單驗證 (FormsAuthentication) 的運作方式),不過各瀏覽器Cookie又有長度限制問題

而使用Session寫法的話,一般在登入後把我們會把登入者物件塞到Session裡,之後其他地方要讀取登入者資訊的話,就由Session中去取得就沒有上述表單驗證的缺點,但相對的

若登入者修改了自己的使用者資訊後,Session寫法通常要再更新Session物件,FormsAuthentication表單驗證機制則不用(以本範例程式碼寫法來說)

所以依自己的考量使用吧,混用也是可以XD

例如:同一個Web專案裡,前台用Session寫法寫登入機制,後台用FormsAuthentication表單驗證機制

 

2018.4.6追記,發現把登入驗證邏輯寫在ViewModel,萬一登入檢核很複雜,例如:登入失敗N次鎖帳號、登入失敗寄通知信、登入成功寫Log...什麼的

由於最近碰到登入邏輯很複雜的系統,所以以下再提供另一範例程式碼,不靠ViewModel版本

Controller完整代碼↓ ※提示:避免把密碼變數命名為Password或pwd這種敏感字眼,會被資安弱掃掃出問題,所以使用PD當做輸入密碼的變數

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web;
using System.Web.Mvc;
/*引用這個*/
using System.Web.Security;

namespace WebApplication1Login.Controllers
{
    public class HomeController : Controller
    {
         
        /// <summary>
        /// 登入畫面
        /// </summary>
        /// <returns></returns>
        [AllowAnonymous]
        public ActionResult Login()
        {
            return View();
        }
        [AllowAnonymous]
        [HttpPost]
        public ActionResult Login(string Account,string PD,string ReturnUrl)
        {
            //檢查必填欄位
            if (string.IsNullOrEmpty(Account) || string.IsNullOrEmpty(PD))
            {
                ModelState.AddModelError("Account", "請輸入必填欄位");
                ModelState.AddModelError("PD", "請輸入必填欄位");
                return View();
            }
            //把輸入的密碼加密
            string base64PD = Convert.ToBase64String(Encoding.UTF8.GetBytes(PD));

            //todo 和DB裡的資料做比對


            //登入成功 
            //進行表單登入  之後User.Identity.Name的值就是Account帳號的值
            FormsAuthentication.RedirectFromLoginPage(Account, false);

            //↓這行不會執行到,亂回傳XD
            return Content("Login success");
        }

        /// <summary>
        /// 登出
        /// </summary>
        /// <returns></returns>
        [AllowAnonymous]
        public ActionResult Logout()
        {
            FormsAuthentication.SignOut();//登出
            return RedirectToAction("Login","Home");  
 
        }

        /// <summary>
        /// 登入後預設進入的畫面
        /// </summary>
        /// <returns></returns>
        public ActionResult Index()
        {
            //登入帳號
            ViewData["Login_Account"] = User.Identity.Name;
            //是否登入(boolean值)
            ViewData["isLogin"] = User.Identity.IsAuthenticated;
            return View();
        }
        //有登入過才能進入此Action,請參考下述文章改寫成Ajax支援版
        [Authorize]
        public ActionResult LoginedPage()
        {
            return Content("is Login");
        }
    }
}

Login的View完整代碼↓


@using (Html.BeginForm("Login", "Home",new { ReturnUrl=Request.QueryString["ReturnUrl"] }, FormMethod.Post, new { name = "LoginForm" }))
{
    @Html.TextBox("Account", "", new { required = "required" }) @Html.ValidationMessage("Account")<br />

    @Html.Password("PD", "", new { required = "required", autocomplete = "new-password" }) @Html.ValidationMessage("PD")<br />

    <input type="submit" value="登入" />
}

  

2014.5.4追記

有使用到Ajax的請參考下一篇: [ASP.MVC] 當jQuery Ajax呼叫遇上Login Timeout的處理

2018.8.12

有些網站,例如個人網銀,為了避免使用者登出後,仍可以透過瀏覽器的「上一頁」查看已登入的畫面(其實看到的是快取畫面)

這種資安漏洞Cross Site History Manipulation,要防止的話

可以為Controller加上Attribute

[OutputCache(Duration =1)]//如果Controller底下裡的Action方法,其中有給View使用@Html.Action()呼叫的話,就用這個  
//這種寫法也可以
[OutputCache(Location = System.Web.UI.OutputCacheLocation.None, NoStore = true)]

 

參考文章:

簡介 ASP.NET 表單驗證 (FormsAuthentication) 的運作方式

How does FormsAuthentication.RedirectFromLoginPage() work?