[ASP.net Core] 實作 Cookie based 登入機制(CookieAuthentication) 取代ASP.Net Framework的表單驗證(FormsAuthentication)登入

ASP.net Core login using Cookie Authentication 

前言

登入是每個網站幾乎必備功能,剛開始接觸ASP.net Core,此項必學

不過ASP.net Framework時期的FormsAuthentication幾項功能來到ASP.net Core有幾點變化↓

1.Web.config定義:登入成功要導向的網址、尚未登入(Unauthorized)時要導向的網址、登入逾期時間

2.登入帳號寫入Cookie方式的改變

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

※話說,去年我在恆逸上課的ASP.net Core 2 進階班,登入機制學習的是 ASP.Net Identy,真是嚇死寶寶了~ 簡直跟2005年 Asp.net 2 的 Membership Provider一樣,會產生一堆有的沒的資料庫欄位,維護困難直接棄坑

實作

本文 ASP.net Core 2.1、3.0版~之後版本都適用,大部份差異只在Startup.cs不一樣,其餘Code都一模一樣

以前 FormsAuthentication在 Web.config 檔裡的設定,現在必須在Startup.cs處理

以下是 ASP.net Core 3.0 的 Startup.cs

using System;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.AspNetCore.Builder;
/*引用以下命名空間*/
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace AspCoreLoginTest
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        
        public void ConfigureServices(IServiceCollection services)
        { 
            //從組態讀取登入逾時設定
            double LoginExpireMinute = this.Configuration.GetValue<double>("LoginExpireMinute");
            //註冊 CookieAuthentication,Scheme必填
            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(option =>
            {
                //或許要從組態檔讀取,自己斟酌決定
                option.LoginPath = new PathString("/Home/Login");//登入頁
                option.LogoutPath = new PathString("/Home/Logout");//登出Action
                //用戶頁面停留太久,登入逾期,或Controller的Action裡用戶登入時,也可以設定↓
                option.ExpireTimeSpan = TimeSpan.FromMinutes(LoginExpireMinute);//沒給預設14天
                //↓資安建議false,白箱弱掃軟體會要求cookie不能延展效期,這時設false變成絕對逾期時間
                //↓如果你的客戶反應明明一直在使用系統卻容易被自動登出的話,你再設為true(然後弱掃policy請客戶略過此項檢查) 
                option.SlidingExpiration = false;  
            });
            
            services.AddControllersWithViews(options=> {
                //↓和CSRF資安有關,這裡就加入全域驗證範圍Filter的話,待會Controller就不必再加上[AutoValidateAntiforgeryToken]屬性
                options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
            });
        }
         
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error"); 
                app.UseHsts();
            }
            app.UseHttpsRedirection();//這樣的話,Controller、Action不必再加上[RequireHttps]屬性
            app.UseStaticFiles();

            app.UseRouting();

            //留意寫Code順序,先執行驗證...
            app.UseAuthentication();
            app.UseAuthorization();//Controller、Action才能加上 [Authorize] 屬性

            //微軟建議 ASP.net Core 3.0 開始改用Endpoint
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
}

以下是 ASP.net Core 2.1 的Startup.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using Microsoft.AspNetCore.Mvc;

namespace AspCoreLoginTest
{
    public class Startup
    {
        private readonly IConfiguration config;
        public Startup(IConfiguration config)
        {
            this.config = config;
        }

        public void ConfigureServices(IServiceCollection services)
        { 
            //從組態讀取登入逾時設定
            double loginExpireMinute = this.config.GetValue<double>("LoginExpireMinute");
            //註冊 CookieAuthentication,Scheme必填
            services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(option=> 
            {
                //或許要從組態檔讀取,自己斟酌決定
                option.LoginPath = new PathString("/Home/Login");//登入頁
                option.LogoutPath = new PathString("/Home/Logout");//登出Action
               //用戶頁面停留太久,登入逾期,或Controller中用戶登入時也可設定
               option.ExpireTimeSpan = TimeSpan.FromMinutes(loginExpireMinute );//沒給預設14天
               //↓資安建議false,白箱弱掃軟體會要求cookie不能延展效期,這時設false變成絕對逾期時間
               //↓如果你的客戶反應明明一直在使用系統卻容易被自動登出的話,你再設為true(然後弱掃policy請客戶略過此項檢查) 
                option.SlidingExpiration = false;  
            });
            
            services.AddMvc(options => {
                //↓和CSRF資安有關,這裡就加入全域驗證範圍Filter的話,待會Controller不必再加上[AutoValidateAntiforgeryToken]屬性
                options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); 
            });
        }

         
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();  
            }
            
            app.UseHttpsRedirection();//這樣的話,Controller、Action不必再加上[RequireHttps]屬性
            app.UseStaticFiles();

            //留意先執行驗證...
             app.UseAuthentication(); 
            //再執行Route,如此順序程式邏輯才正確
            app.UseMvcWithDefaultRoute();
        }
    }
}

組態檔 appsettings.json 的設定,由於登入逾期時間,客戶時常改來改去,所以放在組態檔

{
  
  "LoginExpireMinute": 60
  
}

登入頁、及處理登入

※微軟官網在呼叫SignInAsync()、SignOutAsync()時,有填寫CookieAuthenticationDefaults.AuthenticationScheme,但我自己實測可以省略

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace AspCoreLoginTest.Controllers
{
    //[AutoValidateAntiforgeryToken]//此項跟資安有關,只要是http method post,都要驗證token。Startup.cs有設定過全域驗證的話,這行可省略
    public class HomeController : Controller
    {
        /// <summary>
        /// 讀取組態用
        /// </summary>
        private readonly IConfiguration config;
        public HomeController(IConfiguration config)
        {
            this.config = config;
        }
        /// <summary>
        /// 登入頁
        /// </summary>
        /// <returns></returns>
        public IActionResult Login()
        {
            return View();
        }

        /// <summary>
        /// 表單post提交,準備登入
        /// </summary>
        /// <param name="form"></param>
        /// <returns></returns>
        [HttpPost] 
        public async Task<IActionResult> Login(string Account, string pd,string ReturnUrl)
        {//未登入者想進入必須登入的頁面,他會被自動導頁至/Home/Login,網址後面也會自動帶上名為ReturnUrl(原始要求網址)的QueryString
             
            //pd是密碼,記得加密pd變數或雜湊過後再和DB資料比對
            //從自己DB檢查帳&密,輸入是否正確
            if ((Account == "shadow" && pd=="shadow")==false)
            {
                //帳&密不正確
                ViewBag.errMsg = "帳號或密碼輸入錯誤";
                return View();//流程不往下執行
            }

            //帳密都輸入正確,ASP.net Core要多寫三行程式碼 
            Claim[] claims = new[] { new Claim( "Account",Account) }; //Key取名"Account",在登入後的頁面,讀取登入者的帳號會用得到,自己先記在大腦
            ClaimsIdentity claimsIdentity = new ClaimsIdentity(claims , CookieAuthenticationDefaults.AuthenticationScheme);//Scheme必填
            ClaimsPrincipal principal = new ClaimsPrincipal(claimsIdentity);
            
            
            //從組態讀取登入逾時設定
            double loginExpireMinute = this.config.GetValue<double>("LoginExpireMinute");
            //執行登入,相當於以前的FormsAuthentication.SetAuthCookie()
            await HttpContext.SignInAsync(principal,
                new AuthenticationProperties() {  
                IsPersistent = false, //IsPersistent = false:瀏覽器關閉立馬登出;IsPersistent = true 就變成常見的Remember Me功能
                //用戶頁面停留太久,逾期時間,在此設定的話會覆蓋Startup.cs裡的逾期設定
                /* ExpiresUtc = DateTime.UtcNow.AddMinutes(loginExpireMinute) */  });
             //加上 Url.IsLocalUrl 防止Open Redirect漏洞
            if (!string.IsNullOrEmpty(ReturnUrl) && Url.IsLocalUrl(ReturnUrl) )
            {
                return Redirect(ReturnUrl);//導到原始要求網址
            }
            else
            {
                return RedirectToAction("ListData", "AfterLogin");//到登入後的第一頁,自行決定
            }
            
        }

        /// <summary>
        /// 登出
        /// </summary>
        /// <returns></returns>
        //登出 Action 記得別加上[Authorize],不管用戶是否登入,都可以執行Logout
        public async Task<IActionResult> Logout()
        {
            await HttpContext.SignOutAsync();

            return RedirectToAction("Login", "Home");//導至登入頁
        }
    }
}

登入後的頁面

※如果你在Controller級別加上[Authorize] Filter ,但底下某些Action又想允許使用者未登入存取的話,未登入可存取的Action,一樣掛上 [AllowAnonymous] 這個Attribute

using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
 

namespace AspCoreLoginTest.Controllers
{
    //[AutoValidateAntiforgeryToken]//此項跟資安有關,只要是Http Method Post,都要驗證token。Startup.cs有設定過全域驗證的話,這行可省略
    //↓避免Cross Site History Manipulation漏洞(用戶登出後,瀏覽器回上一頁仍可看到網頁的資安漏洞)
    [ResponseCache(NoStore =true)]
    public class AfterLoginController : Controller
    {
        //[Authorize]要加在Controller或Acton自行決定,有加上[Authorize]表示用戶必須事先登入才能瀏覽,用戶未登入就進來的話會被自動導頁
        [Authorize]
        public IActionResult ListData()
        {
            //可以使用 HttpContext.User.Identity.IsAuthenticated 來判斷用戶是否登入
            //但由於此Action已經加上[Authorize],表示會執行此Action內容的一定都是登入者,所以不必再脫褲子放屁多寫判斷XD

            StringBuilder sb = new StringBuilder();
            sb.AppendLine("<ul>");
         
            foreach (Claim claim in  HttpContext.User.Claims)
            {
                sb.AppendLine($@"<li> claim.Type:{claim.Type} , claim.Value:{ claim.Value}</li>");
            }
            sb.AppendLine("</ul>");

            ViewBag.msg = sb.ToString();//顯示用戶儲存在Cookie的資訊,本範例只有帳號,因為通常帳號不能更動(有的系統還拿來當DB Primary Key)
            //若想取得其他用戶資料的話,請再自行使用此帳號去DB撈用戶資料
            return View();
        }
    }
}

Login.cshtml

 <!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Login</title>
</head>
<body>
    <!-- autocomplete="off" ,和 密碼欄位故意取名pd跟資安有關,那又是另一個議題-->
    @using (Html.BeginForm("Login", "Home",new { ReturnUrl =Context.Request.Query["ReturnUrl"] }, FormMethod.Post, true, new { name = "myForm", autocomplete = "off" }))
    {
        <div>
            <label>帳號:</label>@Html.TextBox("Account")
        </div>
        <div>
            <label>密碼:</label>@Html.Password("pd")
        </div>
        <div>
            <button type="submit">提交</button>
        </div>
        <div style="color:red;">
            <!--顯示登入失敗訊息 -->
            @ViewBag.errMsg
        </div>
    }
</body>
</html>

ListData.cshtml

 

<!DOCTYPE html> 
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>ListData(登入後才能看)</title>
</head>
<body>
    <div>
        您的登入資訊↓
    </div>
    <div>
        @Html.Raw(ViewBag.msg)
    </div>

    <div>
        <a href="@Url.Action("Logout","Home")">登出</a>
    </div>
</body>
</html>

執行結果 ↓

一開始如果用戶未登入想直接進入 /AfterLogin/ListData

會被導到 /Home/Login,注意QueryString 系統會自帶ReturnUrl,這點和以前的FormsAuthentication一樣

輸入帳密(shadow / shadow) ,登入成功畫面 ↓

如此看來,想取得用戶登入帳號的話 ↓

Controller.cs

HttpContext.User.Claims.FirstOrDefault(m=>m.Type=="Account").Value;

View (*.cshtml)

Context.User.Claims.FirstOrDefault(m=>m.Type=="Account").Value;

※ 2019-02-12追記:上述寫死字串 "Account" 似乎不是個好方法,微軟其實有內建類似列舉的字串可以使用,如下

登入時↓

//改使用ClaimTypes.Name就不必寫死字串, Account變數為用戶輸入的帳號
Claim[] claims = new[] { new Claim(ClaimTypes.Name, Account) };  

取出帳號值↓

string Account = HttpContext.User.Claims.FirstOrDefault(m => m.Type == ClaimTypes.Name).Value;

※ 2019-01-29追記:原本我以為 jQuery Ajax遇上登入逾時,會像.Net MVC一樣後端執行導頁,前端Ajax callback function 會取得不正確結果 (請見:Handling session timeout in ajax calls)

一經嘗試,到了 ASP.net Core 變成用戶登入逾時(或未登入)執行Ajax,後端就只回傳Http Status Code 401、不執行導頁(ASP.net Core變聰明內建判斷是Ajax的話,後端就不執行導頁的樣子?)

如此一來,事情變得簡單許多,不必像.Net MVC一樣改寫AuthorizeAttribute

Ajax Request遇上登入逾時,事情都在前端jQuery處理即可

Sample Code↓

HomeController.cs

 public IActionResult AjaxTest()
 {
    return View();
 }

AjaxTest.cshtml

<!DOCTYPE html> 
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>AjaxTest</title>
</head>
<body>
    <!--如果Ajax發出Http Post Method,有可能要加上這行,產生token(為了資安弱掃)-->
    @using (Html.BeginForm("","",null,FormMethod.Post,true,new { name="myForm"}))
    { 
        <button id="btnGo" type="button">點我發出Ajax</button>
    }
    <!--顯示Ajax呼叫成功結果-->
    <div id="result"> 
    </div> 
    <!--引用jQuery -->
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.3.1.js"></script>
    <script type="text/javascript">
        //寫在_Layout.cshtml 供其他頁面使用
        //為所有的$.ajax呼叫設定預設值,當遇到StatusCode為401時,頁面導至登入頁
            $.ajaxSetup({
                statusCode: {
                    401: function () {//未授權,Unauthorized 
                        //JS前端導頁 
                       window.location.href = "@Url.Action("Login","Home")?ReturnUrl=" + encodeURIComponent(window.location.href); 
                        
                    }
                }
            });
    </script>
    <script type="text/javascript">
        let AjaxUrl = "@Url.Action("PostData","AfterLogin")";
    </script>
    <script type="text/javascript">
        $("#btnGo").on("click", function () {
            $.ajax({
                url: AjaxUrl, 
                headers: { RequestVerificationToken: $("input[name='__RequestVerificationToken'").val()}, //如果是發出Http Post Method,可能要加這行
                method: "post",
                success: function (result) { 
                    $("#result").html(result);//畫面顯示結果
                } 
            }); 
        });
    </script>
</body>
</html>

  AfterLoginController.cs

[Authorize]//用戶登入才可存取
[HttpPost] //可能為了資安弱掃還要再加上 [AutoValidateAntiforgeryToken] 屬性 ,給看倌們自行決定XD
public IActionResult PostData()
{ 
  return Content("Hello Ajax");
}

執行結果 ↓

用戶已登入的話,Ajax會取得後端回傳的文字

用戶未登入執行Ajax的話,畫面導頁至/Home/Login (QueryString記得手動寫Code帶上ReturnUrl)

 

結語

如果還有資安相關設定漏寫,日後有空補上

參考資源

登入

Asp.Net Core - simplest possible forms authentication by stackoverflow

微軟官網:使用沒有 ASP.NET Core 身分識別的 cookie 驗證 (Use cookie authentication without ASP.NET Core Identity)

ASP.NET CORE 2.0 COOKIE AUTHENTICATIONASP.NET CORE中使用Cookie身份认证

ASP.net Core 1.x的同學請見:技術最前線 - ASP.NET Core 中的 Cookie、宣告與驗證

資安

[鐵人賽 Day28] ASP.NET Core 2 系列 - Response 快取

微軟官網:ASP.NET Core 中的回應快取

微軟官網:ASP.NET Core 中的防止跨網站要求偽造 (XSRF/CSRF) 攻擊

Automatically validating anti-forgery tokens in ASP.NET Core with the AutoValidateAntiforgeryTokenAttribute 
↑ 翻譯:AutoValidateAntiforgeryToken加在Controller級別,專門對底下所有HttpPost的Action驗證token,才不用一個一個Action加上 [ValidateAntiForgeryToken] 

Core 2 - Ajax submit with ValidateAntiForgeryToken not working  
↑ 翻譯:由於加上filter [AutoValidateAntiforgeryToken],jQuery Ajax該如何送出AntiforgeryToken

jquery ajax设置header的两种方式

在 ASP.net MVC 時期,從jQuery Ajax要發送AntiforgeryToken給後端要寫在 data 參數,如下↓

$.ajax({
       url: "",
       method: "post", 
       data: {
       __RequestVerificationToken: $("input[name='__RequestVerificationToken']").val() 
             },
             success: function (msg) { }
       });//end ajax

但到了ASP.net Core時期,發送AntiforgeryToken要寫在jQuery Ajax的 headers 參數