ASP.NET Core 6 使用 Policy Authorization 保護端點

驗證替使用者建立身分識別,授權則是用來判斷使用者能不能使用某一個功能,ASP.NET Core 提供許多的授權 Role、Claims、Policy 等,老實講 Policy 授權使用上有一點門檻,分享一下我的實際用法,也給需要的人參考

 

開發環境

  • .NET 6
  • Rider 2022.1.2

標準步驟

在 DI Container 時就固定住有哪些原則,授權就會跟著原則走

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Permission", policy =>
        policy.Requirements.Add(new PermissionAuthorizationRequirement()));
});

 

使用 Authorization Middleware

app.UseAuthentication();
app.UseAuthorization();

 

加入 Policy Authorization 的 DI Container

設定 Policy Authorization 的 DI Container,

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Permission", policy =>
        policy.Requirements.Add(new PermissionAuthorizationRequirement()));
});
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, PermissionAuthorizationMiddlewareResultHandler>();

建立 「Permission」 原則, 裡面有一個必要 PermissionAuthorizationRequirement 參數

[Authorize(Policy = "Permission")]

在 Endpoint 設定 [Authorize],當 PolicyName 不符合 AddAuthorization 裡面的 Policy 會噴出例外,反之,會進入  PermissionAuthorizationHandler 跟 PermissionAuthorizationMiddlewareResultHandler

[Authorize(Policy = "Permission")]
[HttpGet]
public async Task<IActionResult> Get()
{
    return this.Ok("好");
}

 

實作 IAuthorizationRequirement

存放授權必要的參數

public class PermissionAuthorizationRequirement : IAuthorizationRequirement
{
}

 

實作 AuthorizationHandler<IAuthorizationRequirement>

主要的處理授權的流程放在這裡,只要控制 AuthorizationHandlerContext 的狀態即可,需要用到幾個方法、狀態:

  • 檢查失敗時調用 AuthorizationHandlerContext.Fail()
  • 所有的失敗原因會被集中在 AuthorizationHandlerContext.FailureReasons
  • 只要有一個失敗原因 AuthorizationHandlerContext.HasFailed == true,AuthorizationHandlerContext.HasSucceeded == false
  • 沒有任何的失敗原因 AuthorizationHandlerContext.HasSucceeded == true
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionAuthorizationRequirement>
{
    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
        PermissionAuthorizationRequirement requirement)
    {
        if (context.User.Identity.IsAuthenticated == false)
        {
            return;
        }
        // ....處理流程
        context.Fail(new AuthorizationFailureReason(this,
            $"Invalid Permission"));
        
        if (context.HasFailed == false)
        {
            context.Succeed(requirement);
        }
    }
}

 

實作 IAuthorizationMiddlewareResultHandler

授權失敗,預設會回傳 403,PermissionAuthorizationMiddlewareResultHandler 用來可以

  • 自訂回應
  • 強化 challenge or forbid 回應

我們會替 4 開頭的 HttpStatus,加上詳細的 Error Body

public class PermissionAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
    public async Task HandleAsync(RequestDelegate next, 
        HttpContext context,
        AuthorizationPolicy policy,
        PolicyAuthorizationResult authorizeResult)
    {
        await next(context);
    }
}

 

動態設定原則步驟

當我們的原則很多的時,就可以透過 IAuthorizationPolicyProvider 來處理,就不是寫死在 DI Container 裡面了,官方有給一個 範例,我也是從這裡去調整的

實作 IAuthorizationPolicyProvider 

主要就是在 GetPolicyAsync 決定要使用那些 Policy

internal class PermissionAuthorizationPolicyProvider : IAuthorizationPolicyProvider
{
    public DefaultAuthorizationPolicyProvider FallbackPolicyProvider { get; }

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

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

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


    public Task<AuthorizationPolicy> GetPolicyAsync(string policyName)
    {
        if (policyName.StartsWith(POLICY_PREFIX, StringComparison.OrdinalIgnoreCase))
        {
            var policy = new AuthorizationPolicyBuilder();
            policy.AddRequirements(new PermissionAuthorizationRequirement());
            return Task.FromResult(policy.Build());
        }


        return FallbackPolicyProvider.GetPolicyAsync(policyName);
    }
}

 

加入 Policy Authorization 的 DI Container

原本是

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Permission", policy =>
        policy.Requirements.Add(new PermissionAuthorizationRequirement()));
}); 

換成

builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionAuthorizationPolicyProvider>();

 

上述的基本流程,應該是可以運作的,可以試著設定中斷點觀察,理解基本的運作之後接下來就要把授權整合進整個流程了。

實作

接下來我想要實作的授權流程如下

  • 請求受保護的端點
  • 通過身分驗證
  • 用身分 UserId 取得授權表
  • 沒有授權回傳 HttpStatus 403
  • 有授權回傳 HttpStatus 200

 

定義授權清單

  • 用常數列舉出授權
  • GetValues:用反射來取出有哪些值
public class Permission
{
    public class Operation
    {
        public const string Write = $"{nameof(Permission)}.{nameof(Operation)}:{nameof(Write)}";
        public const string Read = $"{nameof(Permission)}.{nameof(Operation)}:{nameof(Read)}";

        private static readonly Lazy<Dictionary<string, Type>> s_values
            = new(() =>
            {
                return FieldTypeAssistant.GetStaticFieldName<Operation>()
                    .ToDictionary(p => p.Key,
                        p => p.Value,
                        StringComparer.InvariantCultureIgnoreCase);
            });

        public static Dictionary<string, Type> GetValues()
            => s_values.Value;
    }
}

 

實作取得授權清單

來源可以是資料庫,這裡我用記憶體

public class PermissionAuthorizationProvider : IPermissionAuthorizationProvider
{
    private readonly Dictionary<string, IEnumerable<string>> _clientPermissions =
        new(StringComparer.InvariantCultureIgnoreCase)
        {
            { "yao", new[] { Permission.Operation.Read, Permission.Operation.Write } },
            { "jojo", new[] { Permission.Operation.Read} }
        };

    public IEnumerable<string> GetPermissions(string userId)
    {
        if (this._clientPermissions.TryGetValue(userId, out var result) == false)
        {
            result = new List<string>();
        }

        return result;
    }
}

當授權流程都定案之後,可以被抽換的就是這個點,到時候就可以根據你的測試案例來決定需要甚麼授權

實作 PermissionAuthorizationHandler

這裡就是授權的主要流程

  • 用戶有沒有權限可以訪問端點,用程式來寫就是判斷 PermissionAuthorizationRequirement.PolicyName 的值有沒有在授權清單裡 (IPermissionAuthorizationProvider.GetPermissions)
  • 若沒有權限,呼叫 AuthorizationHandlerContext.Fail
  • 若有權限,呼叫 AuthorizationHandlerContext.Succeed
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionAuthorizationRequirement>
{
    private readonly IPermissionAuthorizationProvider _authorizationProvider;

    public PermissionAuthorizationHandler(IPermissionAuthorizationProvider authorizationProvider)
    {
        this._authorizationProvider = authorizationProvider;
    }

    protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
        PermissionAuthorizationRequirement requirement)
    {
        if (context.User.Identity.IsAuthenticated == false)
        {
            context.Fail(new AuthorizationFailureReason(this, $"目前請求沒有通過驗證"));
            return;
        }

        var userId = context.User.Identity.Name;
        var permissions = this._authorizationProvider.GetPermissions(userId);
        if (permissions.Any(p => p.StartsWith(requirement.PolicyName, StringComparison.InvariantCultureIgnoreCase)) ==
            false)
        {
            context.Fail(new AuthorizationFailureReason(this, $"用戶 '{userId}',沒有授權 '{requirement.PolicyName}'"));
        }

        if (context.HasFailed == false)
        {
            context.Succeed(requirement);
        }
    }
}

 

實作 PermissionAuthorizationMiddlewareResultHandler

根據 Forbidden 寫詳細的 Log,回應 HttpStatus 403 及粗略的內容

public class PermissionAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler
{
    private readonly ILogger<PermissionAuthorizationMiddlewareResultHandler> _logger;

    private readonly JsonSerializerOptions _jsonSerializerOptions;

    public PermissionAuthorizationMiddlewareResultHandler(
        ILogger<PermissionAuthorizationMiddlewareResultHandler> logger,
        JsonSerializerOptions jsonSerializerOptions)
    {
        this._logger = logger;
        this._jsonSerializerOptions = jsonSerializerOptions;
    }

    public async Task HandleAsync(
        RequestDelegate next,
        HttpContext context,
        AuthorizationPolicy policy,
        PolicyAuthorizationResult authorizeResult)
    {
        var permissionAuthorizationRequirements = policy.Requirements.OfType<PermissionAuthorizationRequirement>();

        if (authorizeResult.Forbidden
            && permissionAuthorizationRequirements.Any())
        {
            context.Response.StatusCode = 403;
            this._logger.LogInformation("{AuthorizationFailureResults}", new
            {
                ErrorCode = "Invalid Authorization",
                ErrorMessages = authorizeResult.AuthorizationFailure.FailureReasons
            });

            // 回傳前端模糊訊息
            await context.Response.WriteAsJsonAsync(new
            {
                ErrorCode = "Invalid Authorization",
                ErrorMessages = new[] { "Please contact your administrator" }
                // ErrorMessages = authorizeResult.AuthorizationFailure.FailureReasons
            }, this._jsonSerializerOptions);
            return;
        }

        await next.Invoke(context);

        // await next(context);
    }
}

 

設定驗證授權 DI Container

builder.Services.AddSingleton<IBasicAuthenticationProvider, BasicAuthenticationProvider>();
builder.Services.AddBasicAuthentication(options => { });
builder.Services.AddSingleton<IAuthorizationHandler, PermissionAuthorizationHandler>();
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, PermissionAuthorizationMiddlewareResultHandler>();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionAuthorizationPolicyProvider>();
builder.Services.AddSingleton<IPermissionAuthorizationProvider, PermissionAuthorizationProvider>();

 

設定 Middleware

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

 

設定端點

[ApiController]
[Route("[controller]")]
public class PermissionController : ControllerBase
{
    private readonly ILogger<TestController> _logger;

    public PermissionController(ILogger<TestController> logger)
    {
        this._logger = logger;
    }

    [Authorize(Policy = Permission.Operation.Read)]
    [HttpGet]
    public async Task<IActionResult> Get()
    {
        return this.Ok("好");
    }
}

 

PermissionAuthorizationMiddleware 整合測試

這裡我用 new WebApplicationFactory<Program>().WithWebHostBuilder() 來建立 TestServer 實例,其中 WithWebHostBuilder 具有覆蓋Program 的機制,這裡注入 DefaultBasicAuthenticationProvider,覆蓋原有的設定,它回傳驗證通過

private static WebApplicationFactory<Program> CreateTestServer()
{
    var server = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
    {
        builder.ConfigureServices(services =>
        {
            services.AddSingleton<IBasicAuthenticationProvider, DefaultBasicAuthenticationProvider>();
            services.AddControllers()
                .AddApplicationPart(typeof(TestController).Assembly);
        });
    });
    return server;
}

 

完整代碼如下:

[TestClass]
public class PermissionAuthorizationMiddleware整合測試
{
    [TestMethod]
    public async Task 訪問受保護的服務_授權成功()
    {
        var server = CreateTestServer();
        var httpClient = server.CreateClient();
        var url = "permission";
        var clientId = "YAO";
        var clientSecret = "9527";
        var request = new HttpRequestMessage(HttpMethod.Get, url)
        {
            Headers = { Authorization = CreateAuthenticationHeaderValue(clientId, clientSecret) }
        };
        var response = httpClient.SendAsync(request).Result;
        var content = await response.Content.ReadAsStringAsync();
        Console.WriteLine(content);
        Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);
    }

    [TestMethod]
    public async Task 訪問受保護的服務_授權失敗()
    {
        var server = CreateTestServer();
        var httpClient = server.CreateClient();
        var url = "permission";
        var clientId = "jojo";
        var clientSecret = "9527";
        var request = new HttpRequestMessage(HttpMethod.Get, url)
        {
            Headers = { Authorization = CreateAuthenticationHeaderValue(clientId, clientSecret) }
        };
        var response = httpClient.SendAsync(request).Result;
        var content = await response.Content.ReadAsStringAsync();
        Console.WriteLine(content);
        Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode);
    }

    private static WebApplicationFactory<Program> CreateTestServer()
    {
        var server = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                services.AddSingleton<IBasicAuthenticationProvider, DefaultBasicAuthenticationProvider>();
                services.AddControllers()
                    .AddApplicationPart(typeof(TestController).Assembly);
            });
        });
        return server;
    }

    private static AuthenticationHeaderValue CreateAuthenticationHeaderValue(string clientId, string clientSecret)
    {
        var authenticationString = $"{clientId}:{clientSecret}";
        var base64Encoded = Convert.ToBase64String(Encoding.ASCII.GetBytes(authenticationString));
        return new AuthenticationHeaderValue("basic", base64Encoded);
    }
}

範例位置

sample.dotblog/WebAPI/Security/Lab.AspNetCore.Security at master · yaochangyu/sample.dotblog (github.com)

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo