理解 ASP.NET Core: 驗證

ASP.NET Core 驗證

通常在應用程式中,安全分為前後兩個步驟:驗證和授權。驗證負責檢查當前請求者的身份,而授權則根據上一步得到的身份決定當前請求者是否能夠訪問期望的資源。

既然安全從驗證開始,我們也就從驗證開始介紹安全。

驗證的核心概念

我們先從比較簡單的場景開始考慮,例如在 Web API 開發中,需要驗證請求方是否提供了安全令牌,安全令牌是否有效。如果無效,那麼 API 端應該拒絕提供服務。在命名空間 Microsoft.AspNetCore.Authentication 下,定義關於驗證的核心介面。對應的程式集是 Microsoft.AspNetCore.Authentication.Abstractions.dll。

驗證介面 IAuthenticationHandler

在 ASP.NET 下,驗證中包含 3 個基本操作:

Authenticate 驗證

驗證操作負責基於當前請求的上下文,使用來自請求中的資訊,例如請求頭、Cookie 等等來構造用戶標識。構建的結果是一個 AuthenticateResult 對象,它指示了驗證是否成功,如果成功的話,用戶標識將可以在驗證票據中找到。

常見的驗證包括:

  • 基於 Cookie 的驗證,從請求的 Cookie 中驗證用戶
  • 基於 JWT Bearer 的驗證,從請求頭中提取 JWT 令牌進行驗證

Challenge 質詢

在授權管理階段,如果用戶沒有得到驗證,但所期望訪問的資源要求必須得到驗證的時候,授權服務會發出質詢。例如,當匿名用戶訪問受限資源的時候,或者當用戶點擊登錄鏈接的時候。授權服務會通過質詢來相應用戶。

例如

  • 基於 Cookie 的驗證會將用戶重定向到登錄頁面
  • 基於 JWT 的驗證會返回一個帶有 www-authenticate: bearer 響應頭的 401 響應來提醒客戶端需要提供訪問憑據

質詢操作應該讓用戶知道應該使用何種驗證機制來訪問請求的資源。

Forbid 拒絕

在授權管理階段,如果用戶已經通過了驗證,但是對於其訪問的資源並沒有得到許可,此時會使用拒絕操作。

例如:

  • Cookie 驗證模式下,已經登錄但是沒有訪問許可權的用戶,被重定向到一個提示無權訪問的頁面
  • JWT 驗證模式下,返回 403
  • 在自定義驗證模式下,將沒有許可權的用戶重定向到申請資源的頁面

拒絕訪問處理應該讓用戶知道:

  • 它已經通過了驗證
  • 但是沒有許可權訪問請求的資源

在這個場景下,可以看到,驗證需要提供的基本功能就包括了驗證和驗證失敗後的拒絕服務兩個操作。在 ASP.NET Core 中,驗證被稱為 Authenticate,拒絕被稱為 Forbid。 在供消費者訪問的網站上,如果我們希望在驗證失敗後,不是像 API 一樣直接返回一個錯誤頁面,而是將用戶導航到登錄頁面,那麼,就還需要增加一個操作,這個操作的本質是希望用戶再次提供安全憑據,在 ASP.NET Core 中,這個操作被稱為 Challenge。這 3 個操作結合在一起,就是驗證最基本的要求,以介面形式表示,就是 IAuthenticationHandler 介面,如下所示:

public interface IAuthenticationHandler
{
    Task InitializeAsync(AuthenticationScheme scheme, HttpContext context);
    Task<AuthenticateResult> AuthenticateAsync();
    Task ChallengeAsync(AuthenticationProperties? properties);
    Task ForbidAsync(AuthenticationProperties? properties);
}

驗證的結果是一個 AuthenticateResult 對象。值得注意的是,它還提供了一個靜態方法 NoResult() 用來返回沒有得到結果,靜態方法 Fail() 生成一個表示驗證異常的結果,而 Success() 成功則需要提供驗證票據。

通過驗證之後,會返回一個包含了請求者票據的驗證結果。

namespace Microsoft.AspNetCore.Authentication
{
    public class AuthenticateResult
    {
        // ......
        public static AuthenticateResult NoResult()
        {
            return new AuthenticateResult() { None = true };
        }
        public static AuthenticateResult Fail(Exception failure)
        {
            return new AuthenticateResult() { Failure = failure };
        }
        public static AuthenticateResult Success(AuthenticationTicket ticket)
        {
            if (ticket == null)
            {
                throw new ArgumentNullException(nameof(ticket));
            }
            return new AuthenticateResult() { Ticket = ticket, Properties = ticket.Properties };
        }
        public static AuthenticateResult Success(AuthenticationTicket ticket)
        {
            if (ticket == null)
            {
                throw new ArgumentNullException(nameof(ticket));
            }
            return new AuthenticateResult() { Ticket = ticket, Properties = ticket.Properties };
        }
        // ......
    }
}

在 GitHub 中查看 AuthenticateResult 源碼

那麼驗證的資訊來自哪裡呢?除了前面介紹的 3 個操作之外,還要求一個初始化的操作 Initialize,通過這個方法來提供當前請求的上下文資訊。

在 GitHub 中查看 IAuthenticationHandler 定義

支援登錄和登出操作的驗證介面

有的時候,我們還希望提供登出操作,增加登出操作的介面被稱為 IAuthenticationSignOutHandler。

public interface IAuthenticationSignOutHandler : IAuthenticationHandler
{
    Task SignOutAsync(AuthenticationProperties? properties);
}

在 GitHub 中查看 IAuthenticationSignOutHandler 源碼

在登出的基礎上,如果還希望提供登錄操作,那麼就是 IAuthenticationSignInHandler 介面。

public interface IAuthenticationSignInHandler : IAuthenticationSignOutHandler
{
    Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties);
}

在 GitHub 中查看 IAuthenticationSignInHandler 源碼

實現驗證支援的抽象基類 AuthenticationHandler

直接實現介面還是比較麻煩的,在命名空間 Microsoft.AspNetCore.Authentication 下,微軟提供了抽象基類 AuthenticationHandler 以方便驗證控制器的開發,其它控制器可以從該控制器派生,以取得其提供的服務。

namespace Microsoft.AspNetCore.Authentication
{
    public abstract class AuthenticationHandler<TOptions> : IAuthenticationHandler where TOptions : AuthenticationSchemeOptions, new()
    {
         protected AuthenticationHandler(IOptionsMonitor<TOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
        {
            Logger = logger.CreateLogger(this.GetType().FullName);
            UrlEncoder = encoder;
            Clock = clock;
            OptionsMonitor = options;
        }
    }
    // ......
}

通過類的定義可以看到,它使用了泛型。每個控制器應該有一個對應該控制器的配置選項,通過泛型來指定驗證處理器所使用的配置類型,在構造函數中,可以看到它被用於獲取對應的配置選項對象。

在 GitHub 中查看 AuthenticationHandler 源碼

通過 InitializeAsync(),驗證處理器可以獲得當前請求的上下文對象 HttpContext。

public async Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)

最終,作為抽象類的 ,希望派生類來完成這個驗證任務,抽象方法 HandleAuthenticateAsync() 提供了擴展點。

/// <summary>
/// Allows derived types to handle authentication.
/// </summary>
/// <returns>The <see cref="AuthenticateResult"/>.</returns>
protected abstract Task<AuthenticateResult> HandleAuthenticateAsync();

驗證的結果是一個 AuthenticateResult。

而拒絕服務則簡單的多,直接在這個抽象基類中提供了默認實現。直接返回 HTTP 403。

protected virtual Task HandleForbiddenAsync(AuthenticationProperties properties)
{
    Response.StatusCode = 403;
    return Task.CompletedTask;
}

剩下的一個也一樣,提供了默認實現。直接返回 HTTP 401 響應。

protected virtual Task HandleChallengeAsync(AuthenticationProperties properties)
{
    Response.StatusCode = 401;
    return Task.CompletedTask;
}

Jwt 驗證處理器是如何實現的?

對於 JWT 來說,並不涉及到登入和登出,所以它需要從實現 IAuthenticationHandler 介面的抽象基類 AuthenticationHandler 派生出來即可。從 AuthenticationHandler 派生出來的 JwtBearerHandler 實現基於自己的配置選項 JwtBearerOptions。所以該類定義就變得如下所示,而構造函數顯然配合了抽象基類的要求。

namespace Microsoft.AspNetCore.Authentication.JwtBearer
{
    public class JwtBearerHandler : AuthenticationHandler<JwtBearerOptions>
    {
        public JwtBearerHandler(
            IOptionsMonitor<JwtBearerOptions> options, 
            ILoggerFactory logger, 
            UrlEncoder encoder, 
            ISystemClock clock)
            : base(options, logger, encoder, clock)
        { }
        // ......
    }
}

在 GitHub 中查看 JwtBearerHandler 源碼

真正的驗證則在 HandleAuthenticateAsync() 中實現。下面的程式碼是不是就很熟悉了,從請求頭中獲取附帶的 JWT 訪問令牌,然後驗證該令牌的有效性,核心程式碼如下所示。

string authorization = Request.Headers[HeaderNames.Authorization];

// If no authorization header found, nothing to process further
if (string.IsNullOrEmpty(authorization))
{
    return AuthenticateResult.NoResult();
}

if (authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
    token = authorization.Substring("Bearer ".Length).Trim();
}

// If no token found, no further work possible
if (string.IsNullOrEmpty(token))
{
    return AuthenticateResult.NoResult();
}

// ......
principal = validator.ValidateToken(token, validationParameters, out validatedToken);

在 GitHub 中查看 JwtBearerHandler 源碼

註冊 Jwt 驗證處理器

在 ASP.NET Core 中,你可以使用各種驗證處理器,並不僅僅只能使用一個,驗證控制器需要一個名稱,它被看作該驗證模式 Schema 的名稱。Jwt 驗證模式的默認名稱就是 “Bearer”,通過字元串常量 JwtBearerDefaults.AuthenticationScheme 定義。

namespace Microsoft.AspNetCore.Authentication.JwtBearer
{
    /// <summary>
    /// Default values used by bearer authentication.
    /// </summary>
    public static class JwtBearerDefaults
    {
        /// <summary>
        /// Default value for AuthenticationScheme property in the JwtBearerAuthenticationOptions
        /// </summary>
        public const string AuthenticationScheme = "Bearer";
    }
}

在 GitHub 中查看 JwtBearerDefaults 源碼

最終通過 AuthenticationBuilder 的擴展方法 AddJwtBearer() 將 Jwt 驗證控制器註冊到依賴注入的容器中。

public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder)
            => builder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, _ => { });

public static AuthenticationBuilder AddJwtBearer(
    this AuthenticationBuilder builder, 
    string authenticationScheme, 
    string displayName, 
    Action<JwtBearerOptions> configureOptions)
{
            builder.Services.TryAddEnumerable(
                ServiceDescriptor.Singleton<IPostConfigureOptions<JwtBearerOptions>, 
                JwtBearerPostConfigureOptions>());
            return builder.AddScheme<JwtBearerOptions, JwtBearerHandler>(
                authenticationScheme, displayName, configureOptions);
}

在 GitHub 中查看 JwtBearerExtensions 擴展方法源碼

驗證架構 Schema

一種驗證處理器,加上對應的驗證配置選項,我們再為它起一個名字,組合起來就成為一種驗證架構 Schema。在 ASP.NET Core 中,可以註冊多種驗證架構。例如,授權策略可以使用架構的名稱來指定所使用的驗證架構來使用特定的驗證方式。在配置驗證的時候,通常設置默認的驗證架構。當沒有指定驗證架構的時候,就會使用默認架構進行處理。

還可以

  • 對於 authenticate, challenge, 以及 forbid 操作使用不同的驗證架構
  • 使用策略來組合多種驗證架構

註冊的驗證模式,最終變成 AuthenticationScheme,註冊到依賴注入服務中。

public class AuthenticationScheme
{
    public string Name { get; }
    public string? DisplayName { get; }
    
    [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
    public Type HandlerType { get; }
}

在 GitHub 中查看 AuthenticationScheme 源碼

使用驗證處理器

IAuthenticationSchemeProvider

各種驗證架構被保存到一個 IAuthenticationSchemeProvider 中。

public interface IAuthenticationSchemeProvider
{
    Task<IEnumerable<AuthenticationScheme>> GetAllSchemesAsync();
    Task<AuthenticationScheme?> GetSchemeAsync(string name);
    void AddScheme(AuthenticationScheme scheme);
    void RemoveScheme(string name);
}

在 GitHub 中查看 IAuthenticationSchemeProvider 源碼

IAuthenticationHandlerProvider

最終的使用是通過 IAuthenticationHandlerProvider 來實現的,通過一個驗證模式的字元串名稱,可以取得所對應的驗證控制器。

public interface IAuthenticationHandlerProvider
{
    Task<IAuthenticationHandler?> GetHandlerAsync(HttpContext context, string authenticationScheme);
}

在 GitHub 中查看 IAuthenticationHandlerProvider 源碼

它的默認實現是 AuthenticationHandlerProvider,源碼並不複雜。

public class AuthenticationHandlerProvider : IAuthenticationHandlerProvider
{
    public IAuthenticationSchemeProvider Schemes { get; }
    private readonly Dictionary<string, IAuthenticationHandler> _handlerMap 
        = new Dictionary<string, IAuthenticationHandler>(StringComparer.Ordinal);
    
    public AuthenticationHandlerProvider(IAuthenticationSchemeProvider schemes)
    {
        Schemes = schemes;
    }
    
    public async Task<IAuthenticationHandler?> GetHandlerAsync(HttpContext context, string authenticationScheme)
    {
        if (_handlerMap.TryGetValue(authenticationScheme, out var value))
        {
            return value;
        }

        var scheme = await Schemes.GetSchemeAsync(authenticationScheme);
        if (scheme == null)
        {
            return null;
        }
        var handler = (context.RequestServices.GetService(scheme.HandlerType) ??
           ActivatorUtilities.CreateInstance(context.RequestServices, scheme.HandlerType))
            as IAuthenticationHandler;
        if (handler != null)
        {
            await handler.InitializeAsync(scheme, context);
            _handlerMap[authenticationScheme] = handler;
        }
        return handler;
    }
}

在 GitHub 中查看 AuthenticationHandlerProvider 源碼

Authentication 中間件 AuthenticationMiddleware

驗證中間件的處理就沒有那麼複雜了。

找到默認的驗證模式,使用默認驗證模式的名稱取得對應的驗證處理器,如果驗證成功的話,把當前請求用戶的主體放到當前請求上下文的 User 上。

裡面還有一段特別的程式碼,用來找出哪些驗證處理器實現了 IAuthenticationHandlerProvider,並依次調用它們,看看是否需要提取終止請求處理過程。

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Authentication
{
    public class AuthenticationMiddleware
    {
        private readonly RequestDelegate _next;

        public AuthenticationMiddleware(RequestDelegate next, IAuthenticationSchemeProvider schemes)
        {
            if (next == null)
            {
                throw new ArgumentNullException(nameof(next));
            }
            if (schemes == null)
            {
                throw new ArgumentNullException(nameof(schemes));
            }

            _next = next;
            Schemes = schemes;
        }

        public IAuthenticationSchemeProvider Schemes { get; set; }

        public async Task Invoke(HttpContext context)
        {
            context.Features.Set<IAuthenticationFeature>(new AuthenticationFeature
            {
                OriginalPath = context.Request.Path,
                OriginalPathBase = context.Request.PathBase
            });

            // Give any IAuthenticationRequestHandler schemes a chance to handle the request
            var handlers = context.RequestServices.GetRequiredService<IAuthenticationHandlerProvider>();
            foreach (var scheme in await Schemes.GetRequestHandlerSchemesAsync())
            {
                var handler = await handlers.GetHandlerAsync(context, scheme.Name) as IAuthenticationRequestHandler;
                if (handler != null && await handler.HandleRequestAsync())
                {
                    return;
                }
            }

            var defaultAuthenticate = await Schemes.GetDefaultAuthenticateSchemeAsync();
            if (defaultAuthenticate != null)
            {
                var result = await context.AuthenticateAsync(defaultAuthenticate.Name);
                if (result?.Principal != null)
                {
                    context.User = result.Principal;
                }
            }

            await _next(context);
        }
    }
}

在 GitHub 中查看 AuthenticationMiddle 源碼

參考資料