[ASP.net Core 2] 實作 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一樣,會產生一堆有的沒的資料庫欄位,維護困難Orz

實作

本文是ASP.net Core 2.1版本

以前 FormsAuthentication在Web.config檔裡的設定,現在必須在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)
        {
            services.AddMvc(options => {
                //此項和資安有關
                //options.Filters.Add(new AutoValidateAntiforgeryTokenAttribute()); //不知從何開始,這個可以省略,所以註解掉
            });

            //從組態讀取登入逾時設定
            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天
            });
        }

         
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();  
            }

            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
    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,網址後面會加上QueryString:ReturnUrl(原始要求網址)
             

            //從自己的DB檢查帳&密,輸入是否正確
            if ((Account == "shadow" && pd=="shadow")==false)
            {
                //帳&密不正確
                ViewBag.errMsg = "帳號或密碼輸入錯誤";
                return View();//流程不往下執行
            }

            //帳密都輸入正確,ASP.net Core要多寫三行程式碼 
            Claim[] claims = new[] { new Claim( "Account",Account) }; //取名Account,在登入後的頁面,讀取登入者的帳號會用得到,自己先記在大腦
            ClaimsIdentity claimsIdentity = new ClaimsIdentity(claims , CookieAuthenticationDefaults.AuthenticationScheme);//Scheme必填
            ClaimsPrincipal principal = new ClaimsPrincipal(claimsIdentity);
            //執行登入,相當於以前的FormsAuthentication.SetAuthCookie()
            //從組態讀取登入逾時設定
            double loginExpireMinute = this.config.GetValue<double>("LoginExpireMinute");
            await HttpContext.SignInAsync(principal,
                new AuthenticationProperties() {  IsPersistent = false, //IsPersistent = false,瀏覽器關閉即刻登出
                //用戶頁面停留太久,逾期時間,在此設定的話會覆蓋Startup.cs裡的逾期設定
                /* ExpiresUtc = DateTime.Now.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
    //避免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

事情都在前端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記得帶上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 參數