快速理解ASP.NET Core的認證與授權

語言: CN / TW / HK

ASP.NET Core的認證與授權已經不是什麼新鮮事了,微軟官方的文件對於如何在ASP.NET Core中實現認證與授權有著非常詳細深入的介紹。但有時候在開發過程中,我們也往往會感覺無從下手,或者由於一開始沒有進行認證授權機制的設計與規劃,使得後期出現一些混亂的情況。這裡我就嘗試結合一個實際的例子,從0到1來介紹ASP.NET Core中如何實現自己的認證與授權機制。

當我們使用Visual Studio自帶的ASP.NET Core Web API專案模板新建一個專案的時候,Visual Studio會問我們是否需要啟用認證機制,如果你選擇了啟用,那麼Visual Studio會在專案建立的時候,加入一些輔助依賴和一些輔助類,比如加入對Entity Framework以及ASP.NET Identity的依賴,以幫助你實現基於Entity Framework和ASP.NET Identity的身份認證。如果你還沒有了解過ASP.NET Core的認證與授權的一些基礎內容,那麼當你開啟這個由Visual Studio自動建立的專案的時候,肯定會一頭霧水,不知從何開始,你甚至會懷疑自動建立的專案中,真的是所有的類或者方法都是必須的嗎?所以,為了讓本文更加簡單易懂,我們還是選擇不啟用身份認證,直接建立一個最簡單的ASP.NET Core Web API應用程式,以便後續的介紹。

新建一個ASP.NET Core Web API應用程式,這裡我是在Linux下使用JetBrains Rider新建的專案,也可以使用標準的Visual Studio或者VSCode來建立專案。建立完成後,執行程式,然後使用瀏覽器訪問/WeatherForecast端點,就可以獲得一組隨機生成的天氣及溫度資料的陣列。你也可以使用下面的curl命令來訪問這個API:

curl -X GET "http://localhost:5000/WeatherForecast" -H  "accept: text/plain"

現在讓我們在WeatherForecastController的Get方法上設定一個斷點,重新啟動程式,仍然傳送上述請求以命中斷點,此時我們比較關心User物件的狀態,開啟監視器檢視User物件的屬性,發現它的IsAuthenticated屬性為false:

在很多情況下,我們可能並不需要在Controller的方法中獲取認證使用者的資訊,因此也從來不會關注User物件是否真的處於已被認證的狀態。但是當API需要根據使用者的某些資訊來執行一些特殊邏輯時,我們就需要在這裡讓User的認證資訊處於一種合理的狀態:它是已被認證的,並且包含API所需的資訊。這就是本文所要討論的ASP.NET Core的認證與授權。

應用程式對於使用者的身份認定包含兩部分: 認證授權 。認證是指當前使用者是否是系統的 合法使用者 ,而授權則是指定 合法使用者 對於哪些系統資源具有怎樣的訪問許可權。我們先來看如何實現認證。

在此,我們單說由ASP.NET Core應用程式本身實現的認證,不討論具有統一Identity Provider完成身份認證的情況(比如單點登入),這樣的話就能夠更加清晰地瞭解ASP.NET Core本身的認證機制。接下來,我們嘗試在ASP.NET Core應用程式上,實現Basic認證。

Basic認證需要將使用者的認證資訊附屬在HTTP請求的Authorization的頭(Header)上,認證資訊是一串由使用者名稱和密碼通過BASE64編碼後所產生的字串,例如,當你採用Basic認證,並使用daxnet和password作為訪問WeatherForecast API的使用者名稱和密碼時,你可能需要使用下面的命令列來呼叫WeatherForecast:

curl -X GET "http://localhost:5000/WeatherForecast" -H  "accept: text/plain" -H "Authorization: Basic ZGF4bmV0OnBhc3N3b3Jk"

在ASP.NET Core Web API中,當應用程式接收到上述請求後,就會從Request的Header裡讀取Authorization的資訊,然後BASE64解碼得到使用者名稱和密碼,然後訪問資料庫來確認所提供的使用者名稱和密碼是否合法,以判斷認證是否成功。這部分工作通常可以採用ASP.NET Core Identity框架來實現,不過在這裡,為了能夠更加清晰地瞭解認證的整個過程,我們選擇自己動手來實現。

首先,我們定義一個User物件,並且預先設計好幾個使用者,以便模擬儲存使用者資訊的資料庫,這個User物件的程式碼如下:

public class User
{
    public string UserName { get; set; }
    public string Password { get; set; }
    public IEnumerable<string> Roles { get; set; }
    public int Age { get; set; }

    public override string ToString() => UserName;

    public static readonly User[] AllUsers = {
        new User
        {
            UserName = "daxnet", Password = "password", Age = 16, Roles = new[] { "admin", "super_admin" }
        },
        new User
        {
            UserName = "admin", Password = "admin", Age = 29, Roles = new[] { "admin" }
        }
    };
}

該User物件包括使用者名稱、密碼以及它的角色名稱,不過暫時我們不需要關心角色資訊。User物件還包含一個靜態欄位,我們將它作為使用者資訊資料庫來使用。

接下來,在應用程式中新增一個AuthenticationHandler,用來獲取Request Header中的使用者資訊,並對使用者資訊進行驗證,程式碼如下:

public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationSchemeOptions>
{
    public BasicAuthenticationHandler(
        IOptionsMonitor<BasicAuthenticationSchemeOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.ContainsKey("Authorization"))
        {
            return Task.FromResult(AuthenticateResult.Fail("Authorization header is not specified."));
        }
        var authHeader = Request.Headers["Authorization"].ToString();
        if (!authHeader.StartsWith("Basic "))
        {
            return Task.FromResult(
                AuthenticateResult.Fail("Authorization header value is not in a correct format"));
        }

        var base64EncodedValue = authHeader["Basic ".Length..];
        var userNamePassword = Encoding.UTF8.GetString(Convert.FromBase64String(base64EncodedValue));
        var userName = userNamePassword.Split(':')[0];
        var password = userNamePassword.Split(':')[1];
        var user = User.AllUsers.FirstOrDefault(u => u.UserName == userName && u.Password == password);
        if (user == null)
        {
            return Task.FromResult(AuthenticateResult.Fail("Invalid username or password."));
        }

        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.UserName),
            new Claim(ClaimTypes.Role, string.Join(',', user.Roles)),
            new Claim(ClaimTypes.UserData, user.Age.ToString())
        };
        var claimsPrincipal =
            new ClaimsPrincipal(new ClaimsIdentity(
                claims, 
                "Basic", 
                ClaimTypes.NameIdentifier, ClaimTypes.Role));
        var ticket = new AuthenticationTicket(claimsPrincipal, new AuthenticationProperties
        {
            IsPersistent = false
        }, "Basic");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

在上面的HandleAuthenticateAsync程式碼中,首先對Request Header進行合法性校驗,比如是否包含Authorization的Header,以及Authorization Header的值是否合法,然後,將Authorization Header的值解析出來,通過Base64解碼後得到使用者名稱和密碼,與使用者資訊資料庫裡的記錄進行匹配,找到匹配的使用者。接下來,基於找到的使用者物件,建立ClaimsPrincipal,並基於ClaimsPrincipal建立AuthenticationTicket然後返回。

這段程式碼中有幾點值得關注:

  1. BasicAuthenticationSchemeOptions本身只是一個繼承於AuthenticationSchemeOptions的POCO類。AuthenticationSchemeOptions類通常是為了向AuthenticationHandler提供一些輸入引數。比如,在某個自定義的使用者認證邏輯中,可能需要通過環境變數讀入字串解密的金鑰資訊,此時就可以在這個自定義的AuthenticationSchemeOptions中增加一個Passphrase的屬性,然後在Startup.cs中,通過service.AddScheme呼叫將從環境變數中讀取的Passphrase的值傳入
  2. 除了將使用者名稱作為Identity Claim加入到ClaimsPrincipal中之外,我們還將使用者的角色(Role)用逗號串聯起來,作為Role Claim新增到ClaimsPrincipal中,目前我們暫時不需要涉及角色相關的內容,但是先將這部分程式碼放在這裡以備後用。另外,我們將使用者的年齡(Age)放在UserData claim中,在實際中應該是在使用者物件上有該使用者的出生日期,這樣比較合理,然後這個出生日期應該放在DateOfBirth claim中,這裡為了簡單起見,就先放在UserData中了
  3. ClaimsPrincipal的建構函式中,可以指定哪個Claim型別可被用作使用者名稱稱,而哪個Claim型別又可被用作使用者的角色。例如上面程式碼中,我們選擇NameIdentifier型別作為使用者名稱,而Role型別作為使用者角色,於是,在接下來的Controller程式碼中,由NameIdentifier這種Claim所指向的字串值,就會被看成使用者名稱而被繫結到Identity.Name屬性上

回過頭來看看BasicAuthenticationSchemeOptions類,它的實現非常簡單:

public class BasicAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
        
}

接下來,在Startup.cs檔案裡,修改ConfigureServices和Configure方法,加入Authentication的支援:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
    });
    services.AddAuthentication("Basic")
        .AddScheme<BasicAuthenticationSchemeOptions, BasicAuthenticationHandler>(
            "Basic", options => { });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebAPIAuthSample v1"));
    }

    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthentication();
    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

現在,執行應用程式,在WeatherForecastController的Get方法上設定斷點,然後執行上面的curl命令,當斷點被命中時,觀察this.User物件可以發現,IsAuthenticated屬性變為了true,Name屬性也被設定為使用者名稱:

大多數身份認證框架會提供一些輔助方法來幫助開發人員將AuthenticationHandler註冊到應用程式中,例如,基於JWT持有者身份認證的框架會提供一個AddJwtBearer的方法,將JWT身份認證機制加入到應用程式中,它本質上也是呼叫AddScheme方法來完成AuthenticationHandler的註冊。在這裡,我們也可以自定義一個AddBasicAuthentication的擴充套件方法:

public static class Extensions
{
    public static AuthenticationBuilder AddBasicAuthentication(this AuthenticationBuilder builder)
        => builder.AddScheme<BasicAuthenticationSchemeOptions, BasicAuthenticationHandler>(
            "Basic", 
            options => { });
}

然後修改Starup.cs檔案,將ConfigureServices方法改為下面這個樣子:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
    });
    services.AddAuthentication("Basic").AddBasicAuthentication();
}

這樣做的好處是,你可以為開發人員提供更多比較有針對性的配置認證機制的程式設計介面,這對於一個認證模組/框架的開發是一個很好的設計。

在curl命令中,如果我們沒有指定Authorization Header,或者Authorization Header的值不正確,那麼WeatherForecast API仍然可以被呼叫,只不過IsAuthenticated屬性為false,也無法從this.User物件得到使用者資訊。其實,阻止未認證使用者訪問API並不是認證的事情,API被未認證(或者說未登入)使用者訪問也是合理的事情,因此,要實現對於未認證使用者的訪問限制,就需要進一步實現ASP.NET Core Web API的另一個安全控制組件: 授權

認證 相比, 授權 的邏輯會比較複雜:認證更多是技術層面的事情,而授權則更多地與業務相關。市面上常見的認證機制頂多也就是那麼幾種或者十幾種,而授權的方式則是多樣化的,因為不同app不同業務,對於app資源訪問的 授權需求 是不同的。最為常見的一種授權方式就是RBAC(Role Based Access Control,基於角色的訪問控制),它定義了什麼樣的 角色 對於什麼 資源 具有怎樣的 訪問許可權 。在RBAC中,不同的使用者都被賦予了不同的角色,而為了管理方便,又為具有相同資源訪問許可權的使用者設計了 使用者組 ,而將訪問控制設定在使用者組上,更進一步,組和組之間還可以有父子關係。

請注意上面的黑體字,每一個黑體標註的詞語都是授權相關的概念,在ASP.NET Core中,每一個 授權需求 (Authorization Requirement)對應一個實現IAuthorizationRequirement的類,並由AuthorizationHandler負責處理相應的授權邏輯。簡單地理解,授權需求表示什麼樣的使用者才能夠滿足被授權的要求,或者說什麼樣的使用者才能夠通過授權去訪問資源。一個授權需求往往僅定義並處理一種特定的授權邏輯,ASP.NET Core允許將多個授權需求組合成 授權策略 (Authorization Policy)然後應用到被訪問的資源上,這樣的設計可以保證授權需求的設計與實現都是小粒度的,從而分離不同授權需求的關注點。在授權策略的層面,通過組合不同授權需求從而達到靈活實現授權業務的目的。

比如:假設app中有的API只允許管理員訪問,而有的API只允許滿18週歲的使用者訪問,而另外的一些API需要使用者既是超級管理員又滿18歲。那麼就可以定義兩種Authorization Requirement:GreaterThan18Requirement和SuperAdminRequirement,然後設計三種Policy:第一種只包含GreaterThan18Requirement,第二種只包含SuperAdminRequirement,第三種則同時包含這兩種Requirement,最後將這些不同的Policy應用到不同的API上就可以了。

回到我們的案例程式碼,首先定義兩個Requirement:SuperAdminRequirement和GreaterThan18Requirement:

public class SuperAdminRequirement : IAuthorizationRequirement
{
}
public class GreaterThan18Requirement : IAuthorizationRequirement
{
}

然後分別實現SuperAdminAuthorizationHandler和GreaterThan18AuthorizationHandler:

public class GreaterThan18AuthorizationHandler : AuthorizationHandler<GreaterThan18Requirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, GreaterThan18Requirement requirement)
    {
        if (context.User.HasClaim(c => c.Type == ClaimTypes.UserData) &&
            Convert.ToInt32(context.User.FindFirstValue(ClaimTypes.UserData)) > 18)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

public class SuperAdminAuthorizationHandler : AuthorizationHandler<SuperAdminRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SuperAdminRequirement requirement)
    {
        if (context.User.HasClaim(c => c.Type == ClaimTypes.Role) &&
            (context.User.FindFirstValue(ClaimTypes.Role)
                ?.Split(',')
                ?.Contains("super_admin") ?? false))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

實現邏輯也非常清晰:在GreaterThan18AuthorizationHandler中,通過UserData claim獲得年齡資訊,如果年齡大於18,則授權成功;在SuperAdminAuthorizationHandler中,通過Role claim獲得使用者所處的角色,如果角色中包含super_admin,則授權成功。接下來就需要將這兩個Requirement加到所需的Policy中,然後註冊到應用程式裡:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
    });
    services.AddAuthentication("Basic").AddBasicAuthentication();
    services.AddAuthorization(options =>
    {
        options.AddPolicy("AgeMustBeGreaterThan18", builder =>
        {
            builder.Requirements.Add(new GreaterThan18Requirement());
        });
        options.AddPolicy("UserMustBeSuperAdmin", builder =>
        {
            builder.Requirements.Add(new SuperAdminRequirement());
        });
    });
    services.AddSingleton<IAuthorizationHandler, GreaterThan18AuthorizationHandler>();
    services.AddSingleton<IAuthorizationHandler, SuperAdminAuthorizationHandler>();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebAPIAuthSample v1"));
    }

    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

在ConfigureServices方法中,我們定義了兩種Policy:AgeMustBeGreaterThan18和UserMustBeSuperAdmin,最後,在API Controller或者Action上,應用AuthorizeAttribute,從而指定所需的Policy即可。比如,如果希望WeatherForecase API只有年齡大於18歲的使用者才能訪問,那麼就可以這樣做:

[HttpGet] 
[Authorize(Policy = "AgeMustBeGreaterThan18")]
public IEnumerable<WeatherForecast> Get()
{
    var rng = new Random();
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)]
        })
        .ToArray();
}

執行程式,假設有三個使用者:daxnet、admin和foo,它們的BASE64認證資訊分別為:

  • daxnet:ZGF4bmV0OnBhc3N3b3Jk
  • admin:YWRtaW46YWRtaW4=
  • foo:Zm9vOmJhcg==

那麼,相同的curl命令,指定不同的使用者認證資訊時,得到的結果是不一樣的:

daxnet使用者年齡小於18歲,所以訪問API不成功,服務端返回403:

admin使用者滿足年齡大於18歲的條件,所以可以成功訪問API:

而foo使用者本身沒有在系統中註冊,所以服務端返回401,表示使用者沒有認證成功:

本文簡要介紹了ASP.NET Core中使用者身份認證與授權的基本實現方法,幫助初學者或者需要使用這些功能的開發人員快速理解這部分內容。ASP.NET Core的認證與授權體系非常靈活,能夠整合各種不同的認證機制與授權方式,文章也無法進行全面詳細的介紹。不過無論何種框架哪種實現,它的實現基礎也就是本文所介紹的這些內容,如果打算自己開發一套認證和授權的框架,也可以參考本文。

(總訪問量:1;當日訪問量:1)