【ASP.NET Core】使用最熟悉的Session驗證方案

東邪獨孤 發表於 2022-01-26
.Net

如果大夥伴們以前寫過 ASP 或 PHP 之類的,相信各位對基於 Session 的身份驗證很熟悉(其實在瀏覽器端是結合 Cookie 來處理的)。這種驗證方式是比較早期的,操作起來也不復雜。

a、使用者開啟(或自動跳轉到)登入頁,輸入你的大名和密碼,登入。

b、提交到伺服器,比較一下使用者名稱和密碼是否正確。

c、若驗證成功,往 Session 裡寫入一個標識。實際上往Session裡面寫啥都行,能作為使用者登入標識就行。畢竟嘛,對於每個連線來說,Session是唯一的,所以,在頁面“頭部”驗證時,許多時候壓根不用關心Session裡存了啥,只要有登入標識就OK。

當然,你會說,我 K,ao,這樣驗證是不是問題多多?確實,跨域驗證就出問題,而且單點登入也不好控制。所以現在才會衍生出許多驗證方式。甚至弄得很複雜,於是我們們就知道只要涉及到驗證和授權的內容就看得人頭暈。很真實,是TM挺複雜的。

不過,你同時也會發現,現在很多 Web 應用還是會使用 Session 來驗證的。為啥呢?因為我的專案很小,小到可能就只有五六個人登入,我用得著搞那麼複雜嗎?

老周不才,沒做過什麼大專案,小專案倒是坑了不少人。用小專案來忽悠客戶一向是老周的核心競爭力,一直被模仿卻從未被超越過。你不妨想想,你開了個小店,平時只賣幾張不知道正不正版的有顏色的DVD,店裡的員工可能就幾個,做個管理系統就那麼幾個操作員。你說這身份驗證你會選那些複雜到跳樓的方案嗎。

------------------------------- 銀河分界線 ------------------------------------

以前,我們在ASP中使用 Session 還是很簡單的。ASP 檔案中有一種類似C標頭檔案的東西(inc檔案),可以在其他ASP檔案中包含。那麼,這個 inc 檔案裡寫幾行程式碼——檢查一下 Session 裡是否包含登入標識。若沒有,跳轉到登入頁。然後,需要作驗證的頁面就 include 這個 inc 檔案。這樣就以很簡單但很混亂的方式實現了驗證功能。

在 ASP.NET Core 裡其實你也可以這樣用,在服務容器中啟用 Session 功能,然後寫個中介軟體,插入到 HTTP 管道的頭部,檢查 Session 中的登入標識,如果沒有那就 Redirect 到登入 URL。

這樣做確實可行的,但又出新問題了——所有進來的請求都會進行驗證了,這會導致客戶端訪問啥都要驗證了。當然,你會想到,Map When 就行了唄,讓中介軟體有了條件限制。

------------------------------ M77星雲分界線 ----------------------------------

以上做法並不符合 ASP.NET Core 設計模型。ASP.NET Core 中為驗證和授權提供了獨立的功能實現的。好了,前文扯了幾噸的廢話,正片現在開始。

驗證與授權是兩個不同的過程,但它們又經常一起使用。所以很多大夥伴經常分不清,關鍵是這兩貨的單詞也長得很像,不信你看:

1、驗證——authentication

2、授權——authorization

怎麼樣?像吧,也不知道那些洋鬼子們怎麼想的,把它倆弄得那麼像。

老周試著用一個故事來區別這兩個過程——假如你去你朋友家裡玩。首先,你朋友家裡得有人,而且你按門鈴後他會開門讓你進去(驗證);之後,你進去了,但是朋友家裡有很多個房間,一般大客廳你肯定可以站在那裡的,但是,朋友的臥室就不見得會允許你進去(授權),除非你們特別熟。

驗證是你能不能進別人家的門,授權是進了門後你被允許做什麼

------------------------- 小龍蝦星人分界線 ------------------------

下面分別說說這兩個過程的一些要素。

A、驗證

現在的網站我們們都知道,身份驗證方式很多。你可以使用者名稱/密碼登入,你可以用QQ、微博、微信等帳號登入,你可以用簡訊驗證碼登入。像QQ、微信這些是第三方授權的,為了省去每去訪問都要授權的麻煩,提供驗證的伺服器會發給你一個 Token,下次訪問你用這個 Token 就行了。當然,這個 Token 也是有時間限制的,過期了就不能用。

這種方法不會暴露使用者資訊,但也不是真的很安全的,別人可以不知道你是誰,他只要盜走你的 Token 也能用來登入。好比一些平臺會開放給開發者 API,比如微博開放平臺,會分配給你一個 App Key 和一個金鑰,然後你呼叫 API 時要傳遞這些東西。如果我知道你的 App Key 和金鑰,那我照樣可以以你的身份去呼叫 API。

正因為驗證的方式那麼多,所以,應用程式必須要有個東東來標識它們,這就跟我們在學校有學號一樣道理。於是就出了個名詞叫 Authentication Scheme。驗證架構,但翻譯為驗證方案更好聽。說白了,就是你給你這種驗證方式取個名字罷了。比如,郵件驗證碼登入的叫“Email-Auth”。像我們們常聽說的什麼 OAuth 2.0,也是一種驗證方案。

光有了驗證方案名稱可不行,你得讓程式知道咋去驗證,這就需要為每個方案配套一個 Handler 了,這個 Handler 是一個類,但它要求你實現 IAuthenticationHandler 介面。這樣便有了統一的呼叫標準,當你選擇某方案完成驗證時,就會呼叫與這個方案對應的 Handler 來處理。例如:

方案 Handler 說明
Email-Auth EmailAuthenHandler 郵件驗證
Pwd-Auth UserPasswordHandler 使用者名稱/密碼驗證

大概微軟也知道在 .NET 庫中整合太多驗證方案太笨重,所以現在新版本的 ASP.NET Core 的預設庫中只保留一些基本的驗證方案——如 Cookie,這個方案是內建的,我們不需要自己寫程式碼(在 Microsoft.AspNetCore.Authentication.Cookies 名稱空間中)。

在 Microsoft.AspNetCore.Authentication 名稱空間下有個抽象類 AuthenticationHandler<TOptions>,它實現了一點基本功能,我們如果想自己寫驗證方案,可以從這個類派生。但,老周這次要用的方案只是對 Session 的簡單檢查,所以,就不需要從這個抽象類派生,而是直接實現 IAuthenticationHandler 介面。

在實現驗證邏輯前,我們們寫個類,作為一些可設定引數的選項。

    public class TestAuthenticationOptions
    {
        /// <summary>
        /// 登入入口路徑
        /// </summary>
        public string LoginPath { get; set; } = "/Home/Login";

        /// <summary>
        /// 存入Session的鍵名
        /// </summary>
        public string SessionKeyName { get; set; } = "uid";

        /// <summary>
        /// 返回URL引數名
        /// </summary>
        public string ReturnUrlKey { set; get; } = "return";
    }

這裡老周只按照專案需求設定了三個選項,想新增選項的話得看你的實際需求了。

LoginPath:登入入口,這個屬性指定一個URL(一般是相對URL),表示使用者輸入名稱和密碼登入的頁面(可以是MVC,可以是 RazorPages,這個無所謂,由URL路由和你的程式碼決定)。

SessionKeyName:這個屬性設定 Session 裡面存放登入標識時的 Key 名。其實 Session 和字典物件類似,裡面每個項都有唯一的 Key。

ReturnUrlKey:指定一個欄位名,這個欄位名一般附加在URL的引數中,表示要跳轉回去的路徑。比如,設定為“return”,那麼,假如我們要訪問 https://localhost/admin/op,但這個路徑(或頁面)必須要驗證,否則不能訪問(其實包含授權過程),於是會自動跳轉到 https://localhost/Home/login,讓使用者登入。但使用者登入成功後要返回 /admin/op,所以,在 Login 後加個引數:

https://localhost/Home/Login?return=/admin/op

當登入並驗證成功後,根據這個 return 查詢欄位跳轉回去。如果你把 ReturnUrlKey 屬性設定為“back”,那麼登入的URL就是:

https://localhost/Home/Login?back=/admin/op

 

在實現 IAuthenticationHandler 介面時,可以同時實現 IAuthenticationSignInHandler 介面。而 IAuthenticationSignInHandler 介面是包含 IAuthenticationHandler 和 IAuthenticationSignOutHandler 介面的。這就等於,你只實現 IAuthenticationSignInHandler 介面就行,它包含三個介面的方法成員。

InitializeAsync 方法:初始化時用,一般可以從這裡獲取當前請求關聯的 HttpContext ,以及正在被使用的驗證方案資訊。

AuthenticateAsync 方法:驗證過程,此處老周的做法僅僅看看 Session 中有沒有需要的Key就行了。

ChallengeAsync 方法:一旦驗證失敗,就會呼叫這個方法,向客戶端索要驗證資訊。這裡需要的驗證資訊是輸入使用者名稱和密碼。所以,老周在些方法中 Redirect 到登入頁面。

ForbidAsync 方法:禁止訪問時用,可以直接呼叫 HttpContext 的 ForbidAsync 方法。

SignInAsync 方法:登入時呼叫,這裡老周只是把使用者名稱放入 Session 就完事了。

SignOutAsync 方法:登出時呼叫,這裡只是把 Session 中的使用者名稱刪除即可。

這些方法都可以由 ASP.NET Core 內部自動呼叫,也可以通過 HttpContext 的擴充套件方法手動觸發,如SignInAsync、AuthenticateAsync、ChallengeAsync等。

    public class TestAuthenticationHandler : IAuthenticationSignInHandler
    {
        /// <summary>
        /// 驗證方案的名稱,可以自行按需取名
        /// </summary>
        public const string TEST_SCHEM_NAME = "some_authen";

        /// <summary>
        /// 依賴注入獲取的選項
        /// </summary>
        public TestAuthenticationOptions Options { get; private set; }

        public TestAuthenticationHandler(IOptions<TestAuthenticationOptions> opt)
        {
            Options = opt.Value;
        }

        public HttpContext HttpContext { get; private set; }
        public AuthenticationScheme Scheme { get; private set; }

        public Task<AuthenticateResult> AuthenticateAsync()
        {
            // 先要看看驗證方案是否與當前方案匹配
            if(Scheme.Name != TEST_SCHEM_NAME)
            {
               return Task.FromResult(AuthenticateResult.Fail("驗證方案不匹配"));
            }
            // 再看Session
            if(!HttpContext.Session.Keys.Contains(Options.SessionKeyName))
            {
                return Task.FromResult(AuthenticateResult.Fail("會話無效"));
            }
            // 驗證通過
            string un = HttpContext.Session.GetString(Options.SessionKeyName)??string.Empty;
            ClaimsIdentity id = new(TEST_SCHEM_NAME);
            id.AddClaim(new(ClaimTypes.Name, un));
            ClaimsPrincipal prcp = new(id);
            AuthenticationTicket ticket = new(prcp, TEST_SCHEM_NAME);
            return Task.FromResult(AuthenticateResult.Success(ticket));
        }

        public Task ChallengeAsync(AuthenticationProperties? properties)
        {
            // 跳轉到登入入口
            HttpContext.Response.Redirect($"{Options.LoginPath}?{Options.ReturnUrlKey}={HttpContext.Request.Path}");
            return Task.CompletedTask;
        }

        public async Task ForbidAsync(AuthenticationProperties? properties)
        {
            await HttpContext.ForbidAsync(Scheme.Name);
        }

        public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
        {
            // 獲取一些必備物件的引用
            HttpContext = context;
            Scheme = scheme;
            return Task.CompletedTask;
        }

        public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties)
        {
            // 獲取使用者名稱
            string uname = user.Identity?.Name ?? string.Empty;
            if(!string.IsNullOrEmpty(uname))
            {
                HttpContext.Session.SetString(Options.SessionKeyName, uname);
            }
            return Task.CompletedTask;
        }

        public Task SignOutAsync(AuthenticationProperties? properties)
        {
            if(HttpContext.Session.Keys.Contains(Options.SessionKeyName))
            {
                HttpContext.Session.Remove(Options.SessionKeyName);
            }
            return Task.CompletedTask;
        }
    }

在 AuthenticateAsync 方法中,先要檢查一下,當前所使用用的驗證方案是否與 TEST_SCHEM_NAME 所表示的方案名稱相同。這是為了防止把 TestAuthenticationHandler 與錯誤的驗證方案進行註冊繫結。例如我這個是實現用Session來驗證的,要是把它與“Email-Auth”方案繫結,就會出現邏輯錯誤,畢竟此類不是用電子郵件來驗證的。

不管是實現驗證方法AuthenticateAsync 還是登入方法SignInAsync,都不要去檢查使用者名稱和密碼,而應該把使用者名稱和密碼驗證放到登入的頁面或 Controller 中處理。因為這個自定義的 TestAuthenticationHandler 在許多需要驗證的請求中都要呼叫,如果你在這裡去檢查使用者名稱和密碼,豈不是每次都要跳轉到登入頁讓使用者去輸入?

 

B、授權

一旦驗證完成,就到了授權過程。

驗證過程通過驗證方案名稱來標識,同樣,授權過程也可包含多個策略。

比如,可以基於使用者的角色進行授權,管理員的許可權多一些,非管理員的少一些;

可以基於使用者的年齡進行授權,哪些遊戲 15 歲以下的不能玩;

或者,基於使用者的信用分來授權,信用差的不能貸款;信用好的允許你貸款

……

授權過程處理是通過收集一系列的宣告(Claim)來評估一下使用者具有哪些許可權。比如

你是管理員嗎?

你幾歲了?

你過去三年的信用值是多少?

你是不是VIP使用者?

你的購物積分多少?

你過去一年在我店買過幾次東西?

……

這些宣告來源很多,可以在過去使用者購買東西時存入資料庫並彙總出來,也可能使用者在登入驗證時從資料庫中查詢到。處理程式碼要根據這些宣告來綜合評定一下,你是否達到授權的【要求】。

這些【要求】就可以用 IAuthorizationRequirement 介面來表示。好玩的是,這個介面沒有規定任何方法成員,你只需要有個類來實現這個介面就行。比如使用者積分,寫個類叫 UserPoints,實現這個介面,再加個屬性叫 PointValue,表示積分數。

然後,你把這個 UserPoints 類新增到某授權策略的 Requirements  集合中,在處理授權評估時,再通過程式碼檢查一下里面的各種實現了 IAuthorizationRequirement 介面的物件,看看符不符合條件。

而自定義的授權策略處理是實現 IAuthorizationHandler 介面。你看看,是不是原理差不多,剛才驗證的時候會實現自定義的 Handler,現在授權時又可以實現 Handler。

在 Session 驗證這個方案中,我們不需要寫自定義的授權 Handler,只需要呼叫現有API開啟授權功能,並註冊一個有效的策略名稱即可。而 IAuthorizationRequirement 我們也不用實現,直接用擴充套件方法 RequireAuthenticatedUser 就行。意思是說只要有已登入的使用者名稱就行,畢竟我們們前面在驗證時,已經提供了一個有效的使用者登入名,還記得 AuthenticateAsync 方法中的這幾行嗎?

            // 驗證通過
            string un = HttpContext.Session.GetString(Options.SessionKeyName)??string.Empty;
            ClaimsIdentity id = new(TEST_SCHEM_NAME);
            id.AddClaim(new(ClaimTypes.Name, un));
            ClaimsPrincipal prcp = new(id);
            AuthenticationTicket ticket = new(prcp, TEST_SCHEM_NAME);
            return Task.FromResult(AuthenticateResult.Success(ticket));

其實我們已經新增了一個宣告——Name,以使用者名稱為標識,在授權策略中,程式要查詢的就是這個宣告。只要找到,就能授權;否則拒絕訪問。

 

----------------------------------- 第三宇宙分界線 -----------------------------------

在 Program.cs 檔案中,我們要註冊這些服務類。

var builder = WebApplication.CreateBuilder(args);
// 啟用Session功能
builder.Services.AddSession(o =>
{
    // 把時間縮短一些,好測試
    o.IdleTimeout = TimeSpan.FromSeconds(5);
});
// 這個用來檢查使用者名稱和密碼是否正確
builder.Services.AddSingleton<UserChecker>();
// 使用MVC功能
builder.Services.AddControllersWithViews();
// 註冊剛剛定義的選項類,可以依賴注入
// 不要忘了,不然出大事
builder.Services.AddOptions<TestAuthenticationOptions>();
// 新增驗證功能
builder.Services.AddAuthentication(opt =>
{
    // 新增我們自定義的驗證方案名
    opt.AddScheme<TestAuthenticationHandler>(TestAuthenticationHandler.TEST_SCHEM_NAME, null);
});
// 新增授權功能
builder.Services.AddAuthorization(opt =>
{
    // 註冊授權策略,名為“demo2”
    opt.AddPolicy("demo2", c =>
    {
        // 與我們前面定義的驗證方案繫結
        // 授權過程跟隨該驗證後發生
        c.AddAuthenticationSchemes(TestAuthenticationHandler.TEST_SCHEM_NAME);
        // 要求存在已登入使用者的標識
        c.RequireAuthenticatedUser();
    });
});
var app = builder.Build();

把Session中的過期進間設為5秒,是為了好測試。

上面程式碼還註冊了一個單例項模式的 UserChecker,這只是個測試,老周不使用資料庫了,就用一個寫“死”了的類來檢查使用者名稱和密碼是否正確。

    public class UserChecker
    {
        private class UserInfo
        {
            public string Name { get; init; }
            public string Password { get; init; }
        }

        // 簡單粗暴的使用者資訊,只為測試而生
        static readonly IEnumerable<UserInfo> _Users = new UserInfo[]
        {
            new(){Name = "lucy", Password="123456"},
            new(){Name= "tom", Password="abcd"},
            new() {Name="jim", Password="xyz321"}
        };

        /// <summary>
        /// 驗證使用者名稱和密碼是否有效
        /// </summary>
        /// <param name="name">使用者名稱</param>
        /// <param name="pwd">使用者密碼</param>
        /// <returns></returns>
        public bool CheckLogin(string name, string pwd) => _Users.Any(u => u.Name == name.ToLower() && u.Password == pwd);
    }

 

在 App 物件 build 了之後,記得插入這些中介軟體到HTTP管道。

app.UseSession();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllerRoute("main", "{controller=Home}/{action=Index}");

注意順序,授權在驗證之後,驗證和授權要在 Map MVC的處理之前。

 

測試專案中我用到了兩個 Controller。第一個是 Home,可以隨便訪問,故不需要考慮驗證和授權的問題;第二個是 Admin,只有已正確登入的使用者才可以訪問。

Admin 控制器很簡單,只返回對應的檢視。

    [Authorize("demo2")]
    public class AdminController : Controller
    {
        public IActionResult MainLoad()
        {
            return View();
        }
    }

注意在此控制器上應用了 Authorize 特性,並且指定了使用的授權策略是“demo2”。表明這個控制器裡面的所有 Action 都不能匿名訪問,要訪問得先登入。

MainLoad 檢視如下:

<h2>
    這是管理後臺
</h2>

--------------------------- L78分界線 ----------------------------

Home 控制器允許匿名訪問,其中包含了使用者登入入口 Login。

    public class HomeController : Controller
    {
        TestAuthenticationOptions _options;

        public HomeController(IOptions<TestAuthenticationOptions> o)
        {
            _options = o.Value;
        }

        public IActionResult Index() => View();

        public IActionResult Login()
        {
            // 獲取返回的URL
            if (!HttpContext.Request.Query.TryGetValue(_options.ReturnUrlKey, out var url))
            {
                url = string.Empty;
            }
            // 用模型來傳遞URL
            return View((object)url.ToString());
        }

        public async Task<IActionResult> PostLogin( 
                 string name,    //使用者名稱
                 string pwd,     //密碼
                 string _url,    //要跳回的URL
                 [FromServices]UserChecker usrchecker   //用來驗證使用者名稱和密碼
            )
        {
            if(string.IsNullOrEmpty(name)
                || string.IsNullOrEmpty(pwd))
            {
                return View("Login", _url);
            }
            // 如果密碼不正確
            if (!usrchecker.CheckLogin(name, pwd))
                return View("Login", _url);
            // 準備登入用的材料
            // 1、宣告
            Claim cname = new(ClaimTypes.Name, name);
            // 2、標識
            ClaimsIdentity id = new(TestAuthenticationHandler.TEST_SCHEM_NAME);
            id.AddClaim(cname);
            // 3、主體
            ClaimsPrincipal principal = new(id);
            // 登入
            await HttpContext.SignInAsync(TestAuthenticationHandler.TEST_SCHEM_NAME, principal);

            if(!string.IsNullOrEmpty(_url))
            {
                // 重定向回到之前的URL
                return Redirect(_url);
            }

            return View("Login", _url);
        }
    }

Home 控制器中只用到兩個檢視,一個是Index,預設主頁;另一個是 Login,用於顯示登入UI。

Login 檢視如下:

@inject Microsoft.Extensions.Options.IOptions<DemoApp5.TestAuthenticationOptions> _opt
@model string

<form method="post" asp-controller="Home" asp-action="PostLogin">
    <p>
        使用者名稱:
        <input name="name" type="text"/>
    </p>
    <p>
        密  碼:
        <input name="pwd" type="password"/>
    </p>
    <button type="submit">確  定</button>
    <input type="hidden" name="_url" value="@Model" />
</form>

這個檢視中繫結的 Model 型別為string,實際上就是 Challenge 方法重定向到此URL時傳遞的回撥URL引數(/Home/Login?return=/Admin/XXX)。在Login方法中,通過View方法把這個URL傳給檢視中的 Model 屬性。

之所以要使用模型繫結,是因為HTTP兩次請求間是無狀態的:

第一次,GET 方式訪問 /Home/Login,並用 return 引數傳遞了回撥URL;

第二次,輸入完使用者名稱和密碼,POST 方式提交時呼叫的是 PostLogin 方法,這時候,Login?return=xxxxx 傳遞的URL已經丟失了,無法再獲取。只能繫結到 Model 上,再從 Model 中取值繫結到 hidden 元素上。

<input type="hidden" name="_url" value="@Model" />

POST的時候就會連同這個 hidden 一起發回給伺服器,這樣在 PostLogin 方法中還能夠獲取到這個回撥URL。

----------------------------------------------------------------------------------------------------

執行示例後,先是開啟預設的 Index 檢視。

【ASP.NET Core】使用最熟悉的Session驗證方案

 

 點選“管理頁入口”連結,進入 Admin/MainLoad,此時候因為沒有登入,就會跳轉到 /Home/Login 。輸入一個正確的使用者名稱和密碼,登入。

【ASP.NET Core】使用最熟悉的Session驗證方案

 成功後就跳回到管理後臺。

【ASP.NET Core】使用最熟悉的Session驗證方案

 

 5 秒鐘後就會過期,要訪問就得重新登入。當然這個主要為了測試方便。實際運用可以設定 15 -20 分鐘。

儲存 Session 標識的 Cookie 由執行庫自動完成,通過瀏覽器的開發人員工具能夠看到生成的 Cookie。

【ASP.NET Core】使用最熟悉的Session驗證方案

 

 預設的 Cookie 使用了名稱 AspNetCore.Session,如果你覺得這個名字不夠高大上,可以自己改。在 AddSession 時設定。

builder.Services.AddSession(o =>
{
    // 把時間縮短一些,好測試
    o.IdleTimeout = TimeSpan.FromSeconds(5);
    o.Cookie.Name = "dyn_ssi";
});

然後,生成的用來儲存Session標識的 Cookie 就會變成:

【ASP.NET Core】使用最熟悉的Session驗證方案