【ASP.NET Core】按使用者等級授權

2023-01-04 15:01:32

驗證和授權是兩個獨立但又存在聯絡的過程。驗證是檢查存取者的合法性,授權是校驗存取者有沒有許可權檢視資源。它們之間的聯絡——先驗證再授權。

貫穿這兩過程的是叫 Claim 的東東,可以叫它「宣告」。沒什麼神祕的,就是由兩個字串組成的物件,一曰 type,一曰 value。type 和 value 有著對映關係,類似字典結構的 key 和 value。Claim 用來收集使用者相關資訊,比如

UserName = admin
Age = 105
Birth = 1990,4,12
Address = 火星街130號

ClaimTypes 靜態類定義了一些標準的 type 值。如使用者名稱 Name,國家 Country,手機號 MobilePhone,家庭電話 HomePhone 等等。你也可以自己定義一個,反正就是個字串。

另外,還有一個 ClaimValueTypes 輔助類,也是一組字串,用於描述 value 的型別。如 Integer、HexBinary、String、DnsName 等。其實所有 value 都是用字串表示的,ValueTypes 只是基於內容本身的含義而定義的分類,在查詢和分析 Claim 時有輔助作用。比如,值是 「00:15:30」,可以認為其 ValueType 是 Time,這樣在分析這些資料時可以方便一些。

一般,程式碼會在 Sign in 前收集這些使用者資訊。作用是為後面的授權做準備。授權時會對這些使用者資訊進行綜合評估,以決定該使用者是否有能力存取某些資源。

回到本文主題。本文的重點是說授權,老周的想法是根據使用者的等級來授權。比如,使用者A的等級是2,如果某個URL要求4級以上的使用者才能存取,那麼A就無權存取了。

為了簡單,老周就不建資料庫這麼複雜的東西了,直接寫個類就好了。

public class User
{
    public string? UserName { get; set; }
    public string? Password { get; set; }

    /// <summary>
    /// 使用者等級,1-5
    /// </summary>
    public int Level { get; set; } = 1;
}

上面類中,Level 屬性表示的是使用者等級。然後,用下面的程式碼來產生一些使用者資料。

public static class UserDatas
{
    internal static readonly IEnumerable<User> UserList = new User[]
    {
        new(){UserName="admin", Password="123456", Level=5},
        new(){UserName="kitty", Password="112211", Level=3},
        new(){UserName="bob",Password="215215", Level=2},
        new(){UserName="billy", Password="886600", Level=1}
    };

    // 獲取所有使用者
    public static IEnumerable<User> GetUsers() => UserList;

    // 根據使用者名稱和密碼校對後返回的使用者實體
    public static User? CheckUser(string username, string passwd)
    {
        return UserList.FirstOrDefault(u => u.UserName!.Equals(username, StringComparison.OrdinalIgnoreCase) && u.Password == passwd);
    }
}

這樣的功能,對於咱們今天要說的內容,已經夠用了。

關於驗證,這裡不是重點。所以老周用最簡單的方案——Cookie。

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
{
    opt.LoginPath = "/UserLog";
    opt.LogoutPath = "/Logout";
    opt.AccessDeniedPath = "/Denied";
    opt.Cookie.Name = "ck_auth_ent";
    opt.ReturnUrlParameter = "backUrl";
});

這個驗證方案是結合 Session 和 Cookie 來完成的,也是Web身份驗證的經典方案了。上述程式碼中我設定了一些選項:

LoginPath——當 SessionID 和 Cookie 驗證不成功時,自動轉到些路徑,要求使用者登入。

LogoutPath——退出登入(登出)時的路徑。

AccessDeniedPath——存取被拒絕後轉到的路徑。

ReturnUrlParameter——回撥URL,就是驗證失敗後會轉到登入URL,然後會在URL引數中加一個回撥URL。這個選項就是設定這個引數的名稱的。比如這裡我設定為backUrl。假如我要存取/home,但是,驗證失敗,跳轉到 /UserLog 登入,這時候會在URL後面加上 /UserLog?backUrl=/home。如果登入成功且驗證也成功了,就會跳轉回 backUrl指定的路徑(/home)。

這裡要注意的是,我們不能把要求輸入使用者名稱和密碼作為驗證過程。驗證由內建的 CookieAuthenticationHandler  類去處理,它只驗證 Session 和 Cookie 中的資料是否匹配,而不是檢查使用者名稱/密碼對不對。你想想,如果把檢查使用者名稱和密碼作為驗證過程,那豈不是每次都要讓使用者去輸入一次?說不定每存取一個URL都要驗證一次的,那使用者不累死?所以,輸入使用者名稱/密碼登入只在 LoginPath 選項中設定,只在必要時輸入一次,然後配合 session 和 cookie 把狀態記錄下來,下次再存取,只驗證此狀態即可,不用再輸入了。

LogoutPath 和 AccessDeniedPath 我就不弄太複雜了,直接這樣就完事。

app.MapGet("/Denied", () => "存取被拒絕");
app.MapGet("/Logout", async (HttpContext context) =>
{
    await context.SignOutAsync();
});

對於 LoginPath,我用一個 Razor Pages 來處理。

@page
@using MyApp
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using System.Security.Claims
@addTagHelper *,Microsoft.AspNetCore.Mvc.TagHelpers

<form method="post">
    <style>
        label{
            display:inline-block;
            min-width:100px;
        }
    </style>
    <div>
        <label for="userName">使用者名稱:</label>
        <input type="text" name="userName" />
    </div>
    <div>
        <label for="passWord">密碼:</label>
        <input type="password" name="passWord" />
    </div>
    <div>
        <button type="submit">登入</button>
    </div>
</form>

@functions{
    //[IgnoreAntiforgeryToken]
    public async void OnPost(string userName, string passWord)
    {
        var u = UserDatas.CheckUser(userName, passWord);
        if(u != null)
        {
            Claim[] cs = new Claim[]
            {
                new Claim(ClaimTypes.Name, u.UserName!),
                new Claim("level", u.Level.ToString())  //注意這裡,收集重要情報
            };
            ClaimsIdentity id = new(cs, CookieAuthenticationDefaults.AuthenticationScheme);
            ClaimsPrincipal p = new(id);
            await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, p);
            //HttpContext.Response.Redirect("/");
        }
    }
}

其他的各位可以不關注,重點是 OnPost 方法,首先用剛才寫的 UserDatas.CheckUser 靜態方法來驗證使用者名稱和密碼(這個是要我們自己寫程式碼來完成的,CookieAuthenticationHandler 可不負責這個)。使用者名稱和密碼正確後,咱們就要收集資訊了。收集啥呢?這個要根據你稍後在授權時要用到什麼來決定的。就拿今天的主題來講,我們需要知道使用者等級,所以要收集 Level 屬性的值。這裡 ClaimType 我直接用「level」,Value 就是 Level 屬性的值。

收集完使用者資訊後,要彙總到 ClaimsPrincipal 物件中,隨後呼叫 HttpContext.SignInAsync 擴充套件方法,會觸發 CookieAuthenticationHandler  去儲存狀態,因為它實現了 IAuthenticationSignInHandler 介面,從而帶有 SignInAsync 方法。

   var ticket = new AuthenticationTicket(signInContext.Principal!, signInContext.Properties, signInContext.Scheme.Name);
    // 儲存 Session
   if (Options.SessionStore != null)
   {
       if (_sessionKey != null)
       {
           // Renew the ticket in cases of multiple requests see: https://github.com/dotnet/aspnetcore/issues/22135
           await Options.SessionStore.RenewAsync(_sessionKey, ticket, Context, Context.RequestAborted);
       }
       else
       {
           _sessionKey = await Options.SessionStore.StoreAsync(ticket, Context, Context.RequestAborted);
       }

       var principal = new ClaimsPrincipal(
           new ClaimsIdentity(
               new[] { new Claim(SessionIdClaim, _sessionKey, ClaimValueTypes.String, Options.ClaimsIssuer) },
               Options.ClaimsIssuer));
       ticket = new AuthenticationTicket(principal, null, Scheme.Name);
   }
  // 生成加密後的 Cookie 值
   var cookieValue = Options.TicketDataFormat.Protect(ticket, GetTlsTokenBinding());

    // 追加 Cookie 到響應訊息中
   Options.CookieManager.AppendResponseCookie(
       Context,
       Options.Cookie.Name!,
       cookieValue,
       signInContext.CookieOptions);
 ……

----------------------------------------------------------------------------------------

好了,上面的都是周邊工作,下面我們來幹正事。

授權大體上分為兩種模式:

1、基於角色授權。即「你是誰就給你相應的許可權」。你是狼人嗎?你是預言家嗎?你是女巫嗎?你是好人嗎?是狼人就賦予你殺人的許可權。

2、基於策略。老周覺得這個靈活性高一點(純個人看法)。一個策略需要一定數量的約束條件,是否賦予使用者許可權就看他能否滿足這些約束條件了。約束實現 IAuthorizationRequirement 介面。這個介面未包含任何成員,因此你可以自由發揮了。

這裡咱們需要的約束條件是使用者等級,所以,咱們實現一個 LevelAuthorizationRequirement。

 public class LevelAuthorizationRequirement : IAuthorizationRequirement
 {
     public int Level { get; private set; }

     public LevelAuthorizationRequirement(int lv)
     {
         Level = lv;
     }
 }

授權處理有兩個介面:

1、IAuthorizationHandler:處理過程,一個授權請求可以執行多個 IAuthorizationHandler。一般用於授權過程中的某個階段(或針對某個約束條件)。一個授權請求可以由多 IAuthorizationHandler 參與處理。

2、IAuthorizationEvaluator:綜合評估是否決定授權。評估一般在各種 IAuthorizationHandler 之後進行收尾工作。所以只執行一次就可以了,用於總結整個授權過程的情況得出最終結論(放權還是不放權)。

ASP.NET Core 內建了 DefaultAuthorizationEvaluator,這是預設實現,如無特殊需求,我們不會重新實現。

public class DefaultAuthorizationEvaluator : IAuthorizationEvaluator
{
    public AuthorizationResult Evaluate(AuthorizationHandlerContext context)
        => context.HasSucceeded
            ? AuthorizationResult.Success()
            : AuthorizationResult.Failed(context.HasFailed
                ? AuthorizationFailure.Failed(context.FailureReasons)
                : AuthorizationFailure.Failed(context.PendingRequirements));
}

所以,咱們的程式碼可以選擇實現一個抽象類:AuthorizationHandler<TRequirement>,其中,TRequirement 需要實現 IAuthorizationRequirement 介面。這個抽象類已經滿足咱們的需求了。

public class LevelAuthorizationHandler : AuthorizationHandler<LevelAuthorizationRequirement>
{
    // 策略名稱,寫成常數方便使用
    public const string POLICY_NAME = "Level";

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LevelAuthorizationRequirement requirement)
    {
        // 查詢宣告
        Claim? clm = context.User.Claims.FirstOrDefault(c => c.Type == "level");
        if(clm != null)
        {
            // 讀出使用者等級
            int lv = int.Parse(clm.Value);
            // 看看使用者等級是否滿足條件
            if(lv >= requirement.Level)
            {
                // 滿足,標記此階段允許授權
                context.Succeed(requirement);
            }
        }
        return Task.CompletedTask;
    }
}

在授權請求啟動時,AuthorizationHandlerContext (上下文)物件會把所有 IAuthorizationRequirement 物件新增到一個雜湊表中(HashSet<T>),表示一大串正等著授權處理的約束條件。

當我們呼叫 Succeed 方法時,會把已滿足要求的 IAuthorizationRequirement  傳遞給方法引數。在 Success 方法內部會從雜湊表中刪除此 IAuthorizationRequirement,以表示該條件已滿足了,不必再證。

public virtual void Succeed(IAuthorizationRequirement requirement)
{
    _succeedCalled = true;
    _pendingRequirements.Remove(requirement);
}

 

記得要在服務容器中註冊,否則咱們寫的 Handler 是不起作用的。

 builder.Services.AddSingleton<IAuthorizationHandler, LevelAuthorizationHandler>();

builder.Services.AddAuthorizationBuilder().AddPolicy(LevelAuthorizationHandler.POLICY_NAME, pb =>
{
    pb.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme);
    pb.AddRequirements(new LevelAuthorizationRequirement(3));
});

策略的名稱我們前面以常數的方式定義了,記得否?

  public const string POLICY_NAME = "Level";

AddAuthenticationSchemes 是把此授權策略與一個驗證方案關聯,當進行鑑權時順便做一次驗證。上述程式碼我們關聯 Cookie 驗證即可,這個在文章前面已經設定了。AddRequirements 方法新增我們自定義的約束條件,這裡我設定的使用者等級是 3 —— 使用者等級要 >= 3 才允許存取。

下面寫個 MVC 控制器來檢驗一下是否能正確授權。

public class HomeController : Controller
{
    [HttpGet("/")]
    [Authorize(Policy = LevelAuthorizationHandler.POLICY_NAME)]
    public IActionResult Index()
    {
        return View();
    }
}

這裡咱們用基於策略的授權方式,所以[Authorize]特性要指定策略名稱。

好,執行。本來是存取根目錄 / 的,但由於驗證不通過,自動跳到登入頁了。

 

 注意URL上的 backUrl 引數:?backUrl=/。本來要存取 / 的,所以登入後再跳回 / 。我們選一個使用者等級為 5 的登入。

 

由於使用者等級為 5,是 >=3 的存在,所以授權通過。

 

現在,把名為 ck_auth_ent 的Cookie刪除。

 

 這個 ck_auth_ent 是在程式碼中設定的,還記得嗎?

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
{
    opt.LoginPath = "/UserLog";
    opt.LogoutPath = "/Logout";
    opt.AccessDeniedPath = "/Denied";
    opt.Cookie.Name = "ck_auth_ent";
    opt.ReturnUrlParameter = "backUrl";
});

現在咱們找個使用者等級低於 3 的登入。

 

登入後被拒絕存取。

 

到此為止,好像、貌似、似乎已大功告成了。但是,老周又發現問題了:如果我一個控制器內或不同控制器之間有的操作方法要讓使用者等級 3 以上的使用者存取,有些操作方法只要等級在 2 以上的使用者就可以存取。這該咋整呢?有大夥伴可以會說了,那就多弄幾個策略,每個策略代表一個等級。

builder.Services.AddAuthorizationBuilder()
    .AddPolicy("Level3", pb =>
    {
        pb.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme);
        pb.AddRequirements(new LevelAuthorizationRequirement(3));
    })
    .AddPolicy("Level5", pb =>
    {
        pb.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme);
        pb.AddRequirements(new LevelAuthorizationRequirement(5));
    });

是的,這樣確實是可行的。不過不夠動態,要是我弄個策略從 Level1 到 Level10 呢,豈不要寫十個?

官方有個用 Age 生成授權策略的範例讓老周獲得了靈感——是的,咱們就是要動態生成授權策略。需要用到一個介面:IAuthorizationPolicyProvider。這個介面可以根據策略名稱返回授權策略,所以,咱們可以拿它做文章。

public class LevelAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
    private readonly AuthorizationOptions _options;

    public LevelAuthorizationPolicyProvider(IOptions<AuthorizationOptions> opt)
    {
        _options = opt.Value;
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
    {
        return Task.FromResult(_options.DefaultPolicy);
    }

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
    {
        return Task.FromResult(_options.FallbackPolicy);
    }

    public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
    {
        if(policyName.StartsWith(LevelAuthorizationHandler.POLICY_NAME,StringComparison.OrdinalIgnoreCase))
        {
            // 比如,策略名 Level4,得到等級4
            // 提取名稱最後的數位
            int prefixLen = LevelAuthorizationHandler.POLICY_NAME.Length;
            if(int.TryParse(policyName.Substring(prefixLen), out int level))
            {
                // 動態生成策略
                AuthorizationPolicyBuilder plcyBd = new AuthorizationPolicyBuilder();
                plcyBd.AddAuthenticationSchemes(CookieAuthenticationDefaults.AuthenticationScheme);
                plcyBd.AddRequirements(new LevelAuthorizationRequirement(level));
                // Build 方法生成策略
                return Task.FromResult(plcyBd.Build())!;
            }
        }
        // 未處理,交由選項類去返回預設的策略
        return Task.FromResult(_options.GetPolicy(policyName));
    }
}

這樣可以根據給定的策略名稱,生成與使用者等級相關的設定。例如,名稱「Level3」,就是等級3;「Level5」就是等級5。

於是,在設定服務容器時,我們不再需要 AddAuthorizationBuilder 一大段程式碼了,直接把 LevelAuthorizationPolicyProvider 註冊一下就行了。

builder.Services.AddSingleton<IAuthorizationHandler, LevelAuthorizationHandler>();
builder.Services.AddTransient<IAuthorizationPolicyProvider, LevelAuthorizationPolicyProvider>();

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
……

然後,在MVC控制器上咱們就可以666地玩了。

 public class HomeController : Controller
 {
     [HttpGet("/")]
     [Authorize(Policy = $"{LevelAuthorizationHandler.POLICY_NAME}3")]
     public IActionResult Index()
     {
         return View();
     }

     [HttpGet("/music")]
     [Authorize(Policy = $"{LevelAuthorizationHandler.POLICY_NAME}2")]
     public IActionResult Foo()
         => Content("2星級使用者擾民音樂俱樂部");

     [HttpGet("/movie")]
     [Authorize(Policy = $"{LevelAuthorizationHandler.POLICY_NAME}5")]
     public IActionResult Movies()
         => Content("5星級鬼畜影院");
 }

這樣一來,設定不同等級的授權就方便多了。