.NET Core Identity Policy Customize

在上一篇的文章 中學習了 ClaimPolicy 的基本知識,會員在新增完 Claim 之後可以搭配我們註冊好的 Policy 就能夠避免不符合規定的使用者呼叫機密的 API

但是在進階的場景,例如:年齡限制或者檢查使用者的性別等需要另外對 Claim 內容進行分辨的邏輯,就需要建立客製化的 Policy Provider

要客製化 Policy 我們先看看官方的範例 Github
Authorization 有建立了四個類別

  • MinimumAgeAuthorizationHandler.cs
  • MinimumAgeAuthorizeAttribute.cs
  • MinimumAgePolicyProvider.cs
  • MinimumAgeRequirement.cs

首先定義一個新類別實做 IAuthorizationRequirement 介面,之後在使用上只允許傳入 int 代表年齡

internal class MinimumAgeRequirement : IAuthorizationRequirement
{
    public int Age { get; private set; }

    public MinimumAgeRequirement(int age) { Age = age; }
}

根據下方註釋的內容可以得知 .Net 只允許註冊一個 provider MinimumAgePolicyProvider 所以我們在客製化 provider 的時候要額外提供 Fallback 的 provider,也就是說現在新的 MinimumAgePolicyProvider 已經取代掉 DefaultAuthorizationPolicyProvider 了, 所以未來需要特定 Policy 檢查的流程都會跑 MinimumAgePolicyProviderGetPolicyAsync方法。

目前只有 policyName 為 MinimumAge 時才會進行年齡檢查

internal class MinimumAgePolicyProvider : IAuthorizationPolicyProvider
{
    const string POLICY_PREFIX = "MinimumAge";
    public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; }

    public MinimumAgePolicyProvider(IOptions<AuthorizationOptions> options)
    {
        // ASP.NET Core only uses one authorization policy provider, so if the custom implementation
        // doesn't handle all policies (including default policies, etc.) it should fall back to an
        // alternate provider.
        //
        // In this sample, a default authorization policy provider (constructed with options from the 
        // dependency injection container) is used if this custom provider isn't able to handle a given
        // policy name.
        //
        // If a custom policy provider is able to handle all expected policy names then, of course, this
        // fallback pattern is unnecessary.
        FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => FallbackPolicyProvider.GetDefaultPolicyAsync();

    public Task<AuthorizationPolicy> GetFallbackPolicyAsync() => FallbackPolicyProvider.GetFallbackPolicyAsync();

    // Policies are looked up by string name, so expect 'parameters' (like age)
    // to be embedded in the policy names. This is abstracted away from developers
    // by the more strongly-typed attributes derived from AuthorizeAttribute
    // (like [MinimumAgeAuthorize] in this sample)
    public Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
    {
        if (policyName.StartsWith(POLICY_PREFIX, StringComparison.OrdinalIgnoreCase) &&
            int.TryParse(policyName.Substring(POLICY_PREFIX.Length), out var age))
        {
            var policy = new AuthorizationPolicyBuilder();
            policy.AddRequirements(new MinimumAgeRequirement(age));
            return Task.FromResult(policy.Build());
        }

        // If the policy name doesn't match the format expected by this policy provider,
        // try the fallback provider. If no fallback provider is used, this would return 
        // Task.FromResult<AuthorizationPolicy>(null) instead.
        return FallbackPolicyProvider.GetPolicyAsync(policyName);
    }
}

根據目前掌握的資訊可以得知 Policy 是靠名稱識別的,所以當我們要帶入參數的時候通常會把參數放在 Policy 名稱裡,例如 MinimumAge18MinimumAge21用來表示不同的年齡限制,這時候就可以透過 Attribute 來避免這個問題, 加入 MinimumAgeAuthorizeAttribute 之後就可以改成 MinimumAgeAuthorize(21) 能讓開發者使用上更方便

internal class MinimumAgeAuthorizeAttribute : AuthorizeAttribute
{
    const string POLICY_PREFIX = "MinimumAge";

    public MinimumAgeAuthorizeAttribute(int age) => Age = age;

    // Get or set the Age property by manipulating the underlying Policy property
    public int Age
    {
        get
        {
            if (int.TryParse(Policy.AsSpan(POLICY_PREFIX.Length), out var age))
            {
                return age;
            }
            return default(int);
        }
        set
        {
            Policy = $"{POLICY_PREFIX}{value}";
        }
    }
}

最後是關鍵的 Handler,在上面的 MinimumAgePolicyProvider 運行中會將 MinimumAgeRequirement 添加到 AuthorizationPolicyBuilder 中, MinimumAgeAuthorizationHandler 繼承了 AuthorizationHandler<MinimumAgeRequirement> 所以它只會專門處理 MinimumAgeRequirement

接下來讀取會員的 ClaimTypes.DateOfBirth 取得目前會員的生日,並且只有 age >= requirement.Age 才會回傳成功

internal class MinimumAgeAuthorizationHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    private readonly ILogger<MinimumAgeAuthorizationHandler> _logger;

    public MinimumAgeAuthorizationHandler(ILogger<MinimumAgeAuthorizationHandler> logger)
    {
        _logger = logger;
    }

    // Check whether a given MinimumAgeRequirement is satisfied or not for a particular context
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, MinimumAgeRequirement requirement)
    {
        // Log as a warning so that it's very clear in sample output which authorization policies
        // (and requirements/handlers) are in use
        _logger.LogWarning("Evaluating authorization requirement for age >= {age}", requirement.Age);

        // Check the user's age
        var dateOfBirthClaim = context.User.FindFirst(c => c.Type == ClaimTypes.DateOfBirth);
        if (dateOfBirthClaim != null)
        {
            // If the user has a date of birth claim, check their age
            var dateOfBirth = Convert.ToDateTime(dateOfBirthClaim.Value, CultureInfo.InvariantCulture);
            var age = DateTime.Now.Year - dateOfBirth.Year;
            if (dateOfBirth > DateTime.Now.AddYears(-age))
            {
                // Adjust age if the user hasn't had a birthday yet this year
                age--;
            }

            // If the user meets the age criterion, mark the authorization requirement succeeded
            if (age >= requirement.Age)
            {
                _logger.LogInformation("Minimum age authorization requirement {age} satisfied", requirement.Age);
                context.Succeed(requirement);
            }
            else
            {
                _logger.LogInformation("Current user's DateOfBirth claim ({dateOfBirth}) does not satisfy the minimum age authorization requirement {age}",
                    dateOfBirthClaim.Value,
                    requirement.Age);
            }
        }
        else
        {
            _logger.LogInformation("No DateOfBirth claim present");
        }

        return Task.CompletedTask;
    }
}

接下來我們可以試著建立自己的 Policy, 首先手動進行註冊不要額外建立 MinimumAgePolicyProvider,也就是直接註冊一個 Policy 名稱為 MinimumAge18
這邊呼叫 AddRequirements 方法新增一個 MinimumAgeRequirement 傳入一個 Int 數值, 我們之後要建立的 MinimumAgeAuthorizationHandler 會接收這個 Requirements 之後進行處理

//Program.cs
option.AddPolicy("MinimumAge18", builder =>
{
    builder.AddRequirements(new MinimumAgeRequirement(18));
});

這裡我們繼承 AuthorizationHandler<MinimumAgeRequirement> 並且透過 Context 讀出目前登入 UserClaim
Claim 轉換為使用這目前的年紀,最後在拿使用者的年紀與 MinimumAgeRequirementInt 進行比較

//MinimumAgeAuthorizationHandler.cs
public class MinimumAgeAuthorizationHandler : AuthorizationHandler<MinimumAgeRequirement>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        MinimumAgeRequirement requirement)
    {
        if (!context.User.HasClaim(c => c.Type == ClaimTypes.DateOfBirth))
        {
            return Task.FromResult(0);
        }

        DateTime dateOfBirth =Convert.ToDateTime(context.User?.FindFirst(c => c.Type == ClaimTypes.DateOfBirth)?.Value);

        int age = DateTime.Today.Year - dateOfBirth.Year;
        if (dateOfBirth > DateTime.Today.AddYears(-age)) age--;

        if (age > requirement.Age)
        {
            context.Succeed(requirement);
        }

        return Task.FromResult(0);
    }
}

最後註冊 MinimumAgeAuthorizationHandler 到系統中

//Program.cs
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeAuthorizationHandler>();

這種寫法就是最簡易的,下一步到 AccountController 進行測試

[Authorize(policy: "MinimumAge18")]
[HttpGet("GetUser")]
public async Task<IdentityUser?> GetUser(string userName)
{
    return await _userManager.FindByNameAsync(userName);
}

這裡明確指定說 Policy 要使用 MinimumAge18,使用者需要大於 18 歲 API 才會回傳資料


不過這樣使用起來體驗不是很好,我們另外建立 MinimumAgePolicyProviderMinimumAgeAuthorizeAttribute 使用起來會比較方便

這裡建立了一個 MinimumAgeAuthorizeAttribute 並改成傳入一個 Int, 原本是[Authorize(Policy = "MinimumAge18")] 現在可以改成 [MinimumAgeAuthorize(18)] 可讀性比較高

//MinimumAgeAuthorizeAttribute.cs
public class MinimumAgeAuthorizeAttribute : AuthorizeAttribute
{
    private const string POLICY_PREFIX = "MinimumAge";

    public MinimumAgeAuthorizeAttribute(int age)
    {
        Age = age;
    }

    public int Age
    {
        get
        {
            if (int.TryParse(Policy.Substring(POLICY_PREFIX.Length), out var age))
            {
                return age;
            }
    
            return default;
        }
        set
        {
            Policy = $"{POLICY_PREFIX}{value.ToString()}";
        }
    }
}

根據 MinimumAgeAuthorizeAttribute 的內容,我們會跟系統要求一個 MinimumAge18Policy
MinimumAgePolicyProvider 會將數字 18 分離出來,最後添加MinimumAgeRequirement(18)

//MinimumAgePolicyProvider.cs
public class MinimumAgePolicyProvider : IAuthorizationPolicyProvider
{
    const string POLICY_PREFIX = "MinimumAge";

    public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; }

    public MinimumAgePolicyProvider(IOptions<AuthorizationOptions> options)
    {
        FallbackPolicyProvider = new DefaultAuthorizationPolicyProvider(options);
    }

    public Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
    {
        if (policyName.StartsWith(POLICY_PREFIX, StringComparison.OrdinalIgnoreCase) &&
            int.TryParse(policyName.Substring(POLICY_PREFIX.Length), out var age))
        {
            var policy = new AuthorizationPolicyBuilder();
            policy.AddRequirements(new MinimumAgeRequirement(age));
            return Task.FromResult(policy.Build());
        }

        return Task.FromResult<AuthorizationPolicy>(null);
    }

    public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => FallbackPolicyProvider.GetDefaultPolicyAsync();

    public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => Task.FromResult<AuthorizationPolicy?>(null);
}
//Program.cs
builder.Services.AddSingleton<IAuthorizationPolicyProvider, MinimumAgePolicyProvider>();
builder.Services.AddSingleton<IAuthorizationHandler, MinimumAgeAuthorizationHandler>();

最後我們到 WeatherForecastController 改用我們剛剛建立的標籤,意思明確許多並且也能達到同樣的結果

[MinimumAgeAuthorize(18)]
[HttpGet("GetUser")]
public async Task<IdentityUser?> GetUser(string userName)
{
    return await _userManager.FindByNameAsync(userName);
}

Summary

今天學習了如何客製化自己的授權邏輯,在某些框架中的授權系統也是使用相同的邏輯來建立出一套複雜的授權系統