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

如果大夥伴們以前寫過 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」,那麼,假如我們要訪問 //localhost/admin/op,但這個路徑(或頁面)必須要驗證,否則不能訪問(其實包含授權過程),於是會自動跳轉到 //localhost/Home/login,讓用戶登錄。但用戶登錄成功後要返回 /admin/op,所以,在 Login 後加個參數:

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

當登錄並驗證成功後,根據這個 return 查詢字段跳轉回去。如果你把 ReturnUrlKey 屬性設置為「back」,那麼登錄的URL就是:

//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 視圖。

 

 點擊「管理頁入口」鏈接,進入 Admin/MainLoad,此時候因為沒有登錄,就會跳轉到 /Home/Login 。輸入一個正確的用戶名和密碼,登錄。

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

 

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

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

 

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

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

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