ASP.NET Core 6框架揭祕範例演示[39]:使用最簡潔的程式碼實現登入、認證和登出

2023-06-19 12:00:50

認證是一個確定請求存取者真實身份的過程,與認證相關的還有其他兩個基本操作——登入和登出。ASP.NET Core利用AuthenticationMiddleware中介軟體完成針對請求的認證,並提供了用於登入、登出以及「質詢」的API,本篇文章利用它們使用最簡單的程式碼實現這些功能。(本文提供的範例演示已經同步到《ASP.NET Core 6框架揭祕-範例演示版》)

一、 認證票據
二、基於Cookie的認證
三、 強制認證
四、登入與登出

一、 認證票據

要真正理解認證、登入和登出這三個核心操作的本質,就需要對ASP.NET採用的基於「票據」的認證機制有基本的瞭解。ASP.NET Core應用的認證實現在AuthenticationMiddleware的中介軟體中,該中介軟體在處理分發給它的請求時會按照指定的認證方案(Authentication Scheme)從請求中提取能夠驗證使用者真實身份的資訊,我們一般將此資訊稱為安全令牌(Security Token)。ASP.NET Core應用下的安全令牌被稱為認證票據(Authentication Ticket),它採用基於票據的認證方式。該中介軟體實現的整個認證流程涉及圖1所示的三種針對認證票據的操作,即認證票據的「頒發」、「檢驗」和「復原」。我們將這三個操作所涉及的三種角色稱為票據頒發者(Ticket Issuer)、驗證者(Authenticator)和復原者(Ticket Revoker),在大部分場景下這三種角色由同一個主體來扮演。

image

圖1 基於票據的認證

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

一旦擁有了由認證方頒發的認證票據,使用者端就可以按照雙方協商的方式(比如通過Cookie或者報頭)在請求中攜帶該認證票據,並以此票據宣告的身份執行目標操作或者存取目標資源。認證票據一般都具有時效性,一旦過期將變得無效。如果希望在過期之前就讓認證票據無效,這就是登出(Sign Out)操作。

ASP.NET的認證系統旨在構建一個標準的模型,用來完成針對請求的認證以及與之相關的登入和登出操作。按照慣例,在介紹認證模型的架構設計之前,需要通過一個簡單的範例來演示如何在一個ASP.NET應用中實現認證、登入和登出的功能。

二、基於Cookie的認證

我們會採用ASP.NET提供的基於Cookie的認證方案。該認證方案採用Cookie來攜帶認證票據。為了使讀者對基於認證的程式設計模式有深刻的理解,我們演示的這個應用將從一個空白的ASP.NET應用開始搭建。這個應該會呈現兩個頁面,認證使用者存取主頁會呈現一個「歡迎」頁面,匿名請求則會重定向到登入頁面,我們將這兩個頁面的呈現實現在如下這個IPageRenderer服務中,PageRenderer型別為該介面的預設實現。

public interface IPageRenderer
{
    IResult RenderLoginPage(string? userName = null, string? password = null, string? errorMessage = null);
    IResult RenderHomePage(string userName);
}

public class PageRenderer : IPageRenderer
{
    public IResult RenderHomePage(string userName)
    {
        var html = @$"
<html>
  <head><title>Index</title></head>
  <body>
    <h3>Welcome {userName}</h3>
    <a href='Account/Logout'>Sign Out</a>
  </body>
</html>";
        return Results.Content(html, "text/html");
    }

    public IResult RenderLoginPage(string? userName, string? password, string? errorMessage)
    {
        var html = @$"
<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>";
        return Results.Content(html, "text/html");
    }
}

我們採用「使用者名稱+密碼」的認證方式,金鑰驗證實現的如下這個IAccountService介面的Validate方法中。在實現的AccountService型別中,我們預建立了三個密碼為「password」的賬號(「foo」、「bar」和「baz」)。

public interface IAccountService
{
    bool Validate(string userName, string password);
}

public class AccountService: IAccountService
{
    private readonly Dictionary<string, string> _accounts  = new(StringComparer.OrdinalIgnoreCase)
        {
            { "Foo", "password"},
            { "Bar", "password"},
            { "Baz", "password"}
        };

    public bool Validate(string userName, string password) =>_accounts.TryGetValue(userName, out var pwd) && pwd == password;
}

我們即將建立的這個ASP.NET應用主要處理四種型別的請求。主頁需要在登入之後才能存取,所以針對主頁的匿名請求會被重定向到登入頁面。在登入頁面輸入正確的使用者名稱和密碼之後,應用會自動重定向到主頁,該頁面會顯示當前認證使用者名稱並提供登出的連結。我們按照如下所示的方式註冊了四個對應的終結點,其中登入和登出採用的是約定的路徑「Account/Login」與「Account/Logout」。

using App;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using System.Security.Claims;
using System.Security.Principal;

var builder = WebApplication.CreateBuilder();
builder.Services
    .AddSingleton<IPageRenderer, PageRenderer>()
    .AddSingleton<IAccountService, AccountService>()
    .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
var app = builder.Build();
app.UseAuthentication();

app.Map("/", WelcomeAsync);
app.MapGet("Account/Login", Login);
app.MapPost("Account/Login", SignInAsync);
app.Map("Account/Logout", SignOutAsync);
app.Run();

Task WelcomeAsync () => throw new NotImplementedException();
IResult Login(IPageRenderer renderer) => throw new NotImplementedException();
Task SignInAsync()=> throw new NotImplementedException();
Task SignOutAsync() => throw new NotImplementedException();

上面的演示程式呼叫UseAuthentication擴充套件方法註冊了AuthenticationMiddleware中介軟體,它所依賴服務是通過呼叫AddAuthentication擴充套件方法進行註冊。在呼叫該方法時,我們還設定了預設採用的認證方案,靜態型別CookieAuthenticationDefaults的AuthenticationScheme屬性返回的就是Cookie認證方案的預設方案名稱。我們在上面定義的兩個服務也在這裡進行了註冊。圖2所示就是作為應用的主頁在瀏覽器上呈現的效果。

clip_image004

圖2 應用主頁

三、 強制認證

演示範例的主頁是通過如下所示的WelcomeAsync方法來呈現的,該方法注入了當前HttpContext上下文、代表當前使用者的ClaimsPrincipal物件和IPageRenderer物件。我們利用ClaimsPrincipal物件確定使用者是否經過人證,認證使用者請求將呈現正常的歡迎頁面,匿名請求直接呼叫HttpContext上下文的ChallengeAsync方法進行處理。基於Cookie的認證方案會自動將匿名請求重定向到登入頁面,由於我們指定的登入和登出路徑是Cookie的認證方案約定的路徑,所以呼叫ChallengeAsync方法時根本不需要指定重定向路徑。

Task WelcomeAsync(HttpContext context, ClaimsPrincipal user, IPageRenderer renderer)
{
    if (user?.Identity?.IsAuthenticated ?? false)
    {
        return renderer.RenderHomePage(user.Identity.Name!).ExecuteAsync(context);
    }

    return  context.ChallengeAsync();
}

四、登入與登出

針對登入頁面所在地址的請求由兩種型別,針對GET請求的Login方法會登入頁面呈現出來,針對POST請求的SignInAsync方法檢驗輸入的使用者名稱和密碼,並在驗證成功後實施「登入」。如下面的程式碼片段所示,SignInAsync方法中注入了當前HttpContext上下文、代表請求的HttpRequest物件和額外兩個服務。從請求表單將使用者和密碼提取出來後,我們利用IAccountService物件進行驗證。在驗證通過的情況下,我們會根據使用者名稱建立代表當前使用者的ClaimsPrincipal物件,並將它作為引數呼叫HttpContext上下文的SignInAsync擴充套件方法實施登入, 該方法最終會自動重定向到初始方法的路徑,也就是我們的主頁。

IResult Login(IPageRenderer renderer) => renderer.RenderLoginPage();

Task SignInAsync(HttpContext context, HttpRequest request, IPageRenderer renderer,IAccountService accountService)
{
    var username = request.Form["username"];
    if (string.IsNullOrEmpty(username))
    {
        return renderer.RenderLoginPage(null, null, "Please enter user name.").ExecuteAsync(context);
    }

    var password = request.Form["password"];
    if (string.IsNullOrEmpty(password))
    {
        return renderer.RenderLoginPage(username, null, "Please enter user password.").ExecuteAsync(context);
    }

    if (!accountService.Validate(username, password))
    {
        return renderer.RenderLoginPage(username, null, "Invalid user name or password.").ExecuteAsync(context);
    }

    var identity = new GenericIdentity(name: username, type: "PASSWORD");
    var user = new ClaimsPrincipal(identity);
    return context.SignInAsync(user);
}

如果使用者名稱或者密碼沒有提供或者不匹配,登入頁面會以圖3所示的形式再次呈現出來,並保留輸入的使用者名稱和錯誤訊息。ChallengeAsync方法會將當前路徑(主頁路徑「/」,經過編碼後為「%2F」)儲存在一個名為ReturnUrl的查詢字串中,SignInAsync方法正是利用它實現對初始路徑的重定向的。

image

圖3 登入頁面

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

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