1. 程式人生 > >用最簡單的方式在ASP.NET Core應用中實現認證、登入和登出

用最簡單的方式在ASP.NET Core應用中實現認證、登入和登出

在安全領域,認證和授權是兩個重要的主題。認證是安全體系的第一道屏障,是守護整個應用或者服務的第一道大門。當訪問者請求進入的時候,認證體系通過驗證對方的提供憑證確定其真實身份。認證體系只有在證實了訪問者的真實身份的情況下才會允許其進入。ASP.NET Core提供了多種認證方式,它們的實現都基於相同的認證模型。本篇文章提供了一個極簡的例項讓讀者體驗如何在ASP.NET Core應用中實現認證、登入和登出。

本篇文章節選自《ASP.NET Core 3框架揭祕》(下冊),針對本書的限時5折優惠截至到今天24時,有興趣的朋友可以通過加入讀者群進行購買。入群方式:掃描右方二維碼新增“博文小丸子(broadview002)”,並將本書書號“38462”作為驗證資訊。原始碼從這裡下載。

一、認證票據

認證是一個旨在確定請求訪問者真實身份的過程,與認證相關的還有其他兩個基本操作——登入與登出。要真正理解認證、登入與登出這3個核心操作的本質,就需要對ASP.NET Core採用的基於“票據”的認證機制有基本的瞭解。ASP.NET Core應用的認證實現在一個名為AuthenticationMiddleware的中介軟體中,該中介軟體在處理分發給它的請求時會按照指定的認證方案(Authentication Scheme)從請求中提取能夠驗證使用者真實身份的資料,我們一般將該資料稱為安全令牌(Security Token)。ASP.NET Core應用下的安全令牌被稱為認證票據(Authentication Ticket),所以ASP.NET Core應用採用基於票據的認證方式。

AuthenticationMiddleware中介軟體實現的整個認證流程涉及下圖所示的3種針對認證票據的操作,即認證票據的頒發、檢驗和撤銷。我們將這3個操作所涉及的3種角色稱為票據頒發者(Ticket Issuer)、驗證者(Authenticator)和撤銷者(Ticket Revoker),在大部分場景下這3種角色由同一個主體來扮演。

頒發認證票據的過程就是登入(Sign In)操作。一般來說,使用者試圖通過登入應用以獲取認證票據的時候需要提供可用來證明自身身份的使用者憑證(User Credential),最常見的使用者憑證型別是“使用者名稱 + 密碼”。認證方在確定對方真實身份之後,會頒發一個認證票據,該票據攜帶著與該使用者相關的身份、許可權及其他相關的資訊。

一旦擁有了由認證方頒發的認證票據,我們就可以按照雙方協商的方式(如通過Cookie或者報頭)在請求中攜帶該認證票據,並以此票據宣告的身份執行目標操作或者訪問目標資源。認證票據一般都具有時效性,一旦過期將變得無效。我們有的時候甚至希望在過期之前就讓認證票據無效,以免別人使用它冒用自己的身份與應用進行互動,這就是登出(Sign Out)操作。

ASP.NET Core應用的認證系統旨在構建一個標準的模型來完成針對請求的認證以及與之相關的登入和登出操作。接下來我們就通過一個簡單的例項來演示如何在一個ASP.NET Core應用中實現認證、登入和登出的功能。

二、基於Cookie的認證

我們會採用ASP.NET Core提供的基於Cookie的認證方案。顧名思義,該認證方案採用Cookie來攜帶認證票據。為了使讀者對基於認證的程式設計模式有深刻的理解,我們演示的這個應用將從一個空白的ASP.NET Core應用開始搭建。

我們即將建立的這個ASP.NET Core應用主要處理3種類型的請求。應用的主頁需要登入之後才能訪問,所以針對主頁的匿名請求會被重定向到登入頁面。在登入頁面輸入正確的使用者名稱和密碼之後,應用會自動重定向到應用主頁,該頁面會顯示當前認證使用者名稱並提供登出的連結。我們按照如下所示的方式利用路由來處理這3種類型的請求,其中登入和登出採用的是預設路徑“Account/Login”與“Account/Logout”。

public class Program
{
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting()) 
                .Configure(app => app
                    .UseRouting()
                    .UseEndpoints(endpoints =>{
                        endpoints.Map(pattern: "/", RenderHomePageAsync);
                        endpoints.Map("Account/Login", SignInAsync);
                        endpoints.Map("Account/Logout", SignOutAsync);
                        })))                   
            .Build()
            .Run();
    }

    public static async Task RenderHomePageAsync(HttpContext context)
    {
        throw new NotImplementedException();
    }

    public static async Task SignInAsync(HttpContext context)
    {
        throw new NotImplementedException();
    }

    public static async Task SignOutAsync(HttpContext context)
    {
        throw new NotImplementedException();
    }
}

三、應用主頁

如下面的程式碼片段所示,我們呼叫IApplicationBuilder介面的UseAuthentication擴充套件方法就是為了註冊用來實現認證的AuthenticationMiddleware中介軟體。該中介軟體的依賴服務是通過呼叫IServiceCollection介面的AddAuthentication擴充套件方法註冊的。在註冊這些基礎服務時,我們還設定了預設採用的認證方案,靜態型別CookieAuthenticationDefaults的AuthenticationScheme屬性返回的就是Cookie認證方案的預設方案名稱。

public class Program
{
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs
                    .AddRouting()
                    .AddAuthentication(options => options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme)
                        .AddCookie())
                .Configure(app => app
                    .UseAuthentication()
                    .UseRouting()
                    .UseEndpoints(endpoints =>{
                        endpoints.Map(pattern: "/", RenderHomePageAsync);
                        endpoints.Map("Account/Login", SignInAsync);
                        endpoints.Map("Account/Logout", SignOutAsync);
                        })))                   
            .Build()
            .Run();
    }
}

ASP.NET Core提供了一個極具擴充套件性的認證模型,我們可以利用它支援多種認證方案,針對認證方案的註冊是通過AddAuthentication方法返回的一個AuthenticationBuilder物件來實現的。在上面提供的程式碼片段中,我們呼叫AuthenticationBuilder物件的AddCookie擴充套件方法完成了針對Cookie認證方案的註冊。

演示例項的主頁是通過如下所示的RenderHomePageAsync方法來呈現的。由於我們要求瀏覽主頁必須是經過認證的使用者,所以該方法會利用HttpContext上下文的User屬性返回的ClaimsPrincipal物件判斷當前請求是否經過認證。對於經過認證的請求,我們會響應一個簡單的HTML文件,並在其中顯示使用者名稱和一個登出連結。

public class Program
{
    ...
    public static async Task RenderHomePageAsync(HttpContext context)
    {
        if (context?.User?.Identity?.IsAuthenticated == true)
        {
            await context.Response.WriteAsync(
                @"<html>
                    <head><title>Index</title></head>
                    <body>" +
                        $"<h3>Welcome {context.User.Identity.Name}</h3>" +
                        @"<a href='Account/Logout'>Sign Out</a>
                    </body>
                </html>");
        }
        else
        {
            await context.ChallengeAsync();
        }
    }
}

對於匿名請求,我們希望應用能夠自動重定向到登入路徑。從如上所示的程式碼片段可以看出,我們僅僅呼叫當前HttpContext上下文的ChallengeAsync擴充套件方法就完成了針對登入路徑的重定向。前面提及,註冊的登入和登出路徑是基於Cookie的認證方案採用的預設路徑,所以呼叫ChallengeAsync方法時根本不需要指定重定向路徑。下圖所示就是作為應用的主頁在瀏覽器上呈現的效果。

四、登入

登入與登出分別實現在SignInAsync方法和SignOutAsync方法中,我們採用的是針對“使用者名稱 + 密碼”的登入方式,所以可以利用靜態欄位_accounts來儲存應用註冊的賬號。在靜態建構函式中,我們新增密碼均為“password”的3個賬號(Foo、Bar和Baz)。

public class Program
{
    private static Dictionary<string, string> _accounts;
    static Program()
    {
        _accounts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
        _accounts.Add("Foo", "password");
        _accounts.Add("Bar", "password");
        _accounts.Add("Baz", "password");
    }
}

如下所示的程式碼片段是用於處理登入請求的SignInAsync方法的定義,而RenderLoginPageAsync方法用來呈現登入頁面。如下面的程式碼片段所示,對於GET請求,SignInAsync方法會直接呼叫RenderLoginPageAsync方法來呈現登入介面。對於POST請求,我們會從提交的表單中提取使用者名稱和密碼,並對其實施驗證。如果提供的使用者名稱與密碼一致,我們會根據使用者名稱建立一個代表身份的GenericIdentity物件,並利用它建立一個代表登入使用者的ClaimsPrincipal物件,RenderHomePageAsync方法正是利用該物件來檢驗當前使用者是否經過認證的。有了ClaimsPrincipal物件,我們只需要將它作為引數呼叫HttpContext上下文的SignInAsync擴充套件方法即可完成登入,該方法最終會自動重定向到初始方法的路徑,也就是我們的主頁。

public class Program
{
    public static async Task SignInAsync(HttpContext context)
    {
        if (string.Compare(context.Request.Method, "GET") == 0)
        {             
            await RenderLoginPageAsync(context, null, null, null);
        }
        else
        {
            var userName = context.Request.Form["username"];
            var password = context.Request.Form["password"];
            if (_accounts.TryGetValue(userName, out var pwd) && pwd == password)
            {
                var identity = new GenericIdentity(userName, "Passord");
                var principal = new ClaimsPrincipal(identity);
                await context.SignInAsync(principal);
            }
            else
            {
                await RenderLoginPageAsync(context, userName, password, "Invalid user name or password!");
            }
        }
    }

    private static Task RenderLoginPageAsync(HttpContext context, string userName,  string password, string errorMessage)
    {
        context.Response.ContentType = "text/html";
        return context.Response.WriteAsync(
            @"<html>
                <head><title>Login</title></head>
                <body>
                    <form method='post'>" +
                            $"<input type='text' name='username' placeholder='User name' value ='{userName}'/>" +
                            $"<input type='password' name='password' placeholder='Password'  value ='{password}'/> " +
                            @"<input type='submit' value='Sign In' /></form>" +
                    $"<p style='color:red'>{errorMessage}</p>" +
                @"</body>
            </html>");
    }
}

如果使用者提供的使用者名稱與密碼不匹配,我們還是會呼叫RenderLoginPageAsync方法來呈現登入頁面,該頁面會以下圖所示的形式保留使用者的輸入並顯示錯誤訊息。圖19-3還反映了一個細節,呼叫HttpContext上下文的ChallengeAsync方法會將當前路徑(主頁路徑“/”,經過編碼後為“%2F”)儲存在一個名為ReturnUrl的查詢字串中,SignInAsync方法正是利用它實現對初始路徑的重定向的。

五、登出

既然登入可以通過呼叫當前HttpContext上下文的SignInAsync擴充套件方法來完成,那麼登出操作對應的自然就是SignOutAsync擴充套件方法。如下面的程式碼片段所示,我們定義在Program中的SignOutAsync擴充套件方法正是呼叫這個方法來登出當前登入狀態的。我們在完成登出之後將應用重定向到主頁。

public class Program
{    
    ...
    public static async Task SignOutAsync(HttpContext context)
    {
        await context.SignOutAsync();
        context.Response.Redirect("/");   
    }
}