驗證替使用者建立身分識別,授權則是用來判斷使用者能不能使用某一個功能,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);
    }
}範例位置
若有謬誤,煩請告知,新手發帖請多包涵
Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET