【ASP.NET Core】按使用者角色授權

東邪獨孤發表於2023-01-14

上次老周和大夥伴們分享了有關按使用者Level授權的技巧,本文我們們聊聊以使用者角色來授權的事。

按使用者角色授權其實更好弄,畢竟這個功能是內部整合的,多數場景下我們不需要擴充套件,不用自己寫處理程式碼。從功能語義上說,授權分為按角色授權和按策略授權,而從程式碼本質上說,角色權授其實是包含在策略授權內的。怎麼說呢?往下看。

角色授權主要依靠 RolesAuthorizationRequirement 類,來看一下原始碼精彩片段回放。

public class RolesAuthorizationRequirement : AuthorizationHandler<RolesAuthorizationRequirement>, IAuthorizationRequirement
{
    public RolesAuthorizationRequirement(IEnumerable<string> allowedRoles)
    {
        ……
        AllowedRoles = allowedRoles;
    }

    public IEnumerable<string> AllowedRoles { get; }

    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RolesAuthorizationRequirement requirement)
    {
        if (context.User != null)
        {
            var found = false;

            foreach (var role in requirement.AllowedRoles)
            {
                // 重點在這裡
                if (context.User.IsInRole(role))
                {
                    found = true;    //說明是符合角色要求的
                    break;
                }
            }

            if (found)
            {
                // 滿足要求
                context.Succeed(requirement);
            }
        }
        return Task.CompletedTask;
    }

    ……
}

這個是不是有點熟悉呢?對的,上一篇博文里老周介紹過,實現 IAuthorizationRequirement 介面表示一個用於授權的必備條件(或者叫必備要素),AuthorizationHandler 負責驗證這些必備要素是否滿足要求。上一篇博文中,老周是把實現 IAuthorizationRequirement 介面和重寫抽象類 AuthorizationHandler<TRequirement> 分成兩部分完成,而這裡,RolesAuthorizationRequirement 直接一步到位,兩個一起實現。

好,理論先說到這兒,下面我們們來過一把程式碼癮,後面我們們回過頭來再講。我們們的主題是說授權,不是驗證。當然這兩者通常是一起的,因為授權的前提是要驗證透過。所以為了方便簡單,老周還是選擇內建的 Cookie 驗證方案。不過這一回不搞使用者名稱、密碼什麼的了,而是直接用 Claim 設定角色就行了,畢竟我們的主題是角色授權。

public class LoginController : Controller
{
    [HttpGet("/login")]
    public IActionResult Login() => View();

    [HttpPost("/login")]
    public async void Login(string role)
    {
        Claim c = new(ClaimTypes.Role, role);
        ClaimsIdentity id = new(new[] { c }, CookieAuthenticationDefaults.AuthenticationScheme);
        ClaimsPrincipal p = new ClaimsPrincipal(id);
        await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, p);
    }

    [HttpGet("/denied")]
    public IActionResult DeniedAcc() => Content("不好意思,你無權訪問");

      [HttpGet("/logout")]
      public async void Logout()=> await HttpContext.SignOutAsync();

}

無比聰明的你一眼能看出,這是 MVC 控制器,並且實現登入有關的功能:

/login:進入登入頁

/logout:登出

/denied:表白失敗被拒絕,哦不,授權失敗被拒絕後訪問

Login 方法有兩個,沒引數的是 GET 版,有引數的是 POST 版。當以 POST 方式訪問時,會有一個 role 引數,表示被選中的角色。這裡為了簡單,不用輸使用者名稱密碼了,直接選個角色就登入。

Login 檢視如下:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

<div>
    <p>登入角色:</p>
    <form method="post">
        <select name="role">
            <option value="admin">管理員</option>
            <option value="svip" selected>超級會員</option>
            <option value="gen">普通客戶</option>
        </select>
        <button type="submit">登入</button>
    </form>
</div>

select 元素的名稱為 role,正好與 Login 方法(post)的引數 role 相同,能進行模型繫結。 

admin 角色表示管理員,svip 角色表示超級VIP客戶,gen 角色表示普通客戶。假設這是一家大型紙尿褲批發店的客戶管理系統。這年頭,連買紙尿褲也要分三六九等了。

下面是該紙尿褲批發店為不同客戶群提供的服務。

[Route("znk")]
public class 紙尿褲Controller : Controller
{
    [Route("genindex")]
    [Authorize(Roles = "gen")]
    public IActionResult IndexGen()
    {
        return Content("普通客戶瀏覽頁");
    }

    [Route("adminindex")]
    [Authorize(Roles = "admin")]
    public IActionResult IndexAdmin()
    {
        return Content("管理員專場");
    }

    [Route("svipindex")]
    [Authorize(Roles = "svip")]
    public IActionResult IndexSVIP() => Content("超級會員殺熟通道");
}

注意上面授權特性,不需要指定策略名稱,只需指定你要求的角色名稱即可。

在應用程式的初始化配置上,我們們設定 Cookie 驗證。

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(opt =>
{
    opt.LoginPath = "/login";
    opt.AccessDeniedPath = "/denied";
    opt.LogoutPath = "/logout";
    opt.ReturnUrlParameter = "url";
    opt.Cookie.Name = "_XXX_FFF_";
});
var app = builder.Build();

那幾個路徑就是剛才 Login 控制器上的訪問路徑。

因為不需要配置授權策略,所以不需要呼叫 AddAuthorization 擴充套件方法。主要是這個方法你在呼叫 AddControllersWithViews 方法時會自動呼叫,所以,如無特殊配置,我們們不用手動開啟授權功能。像 MVC、RazorPages 等這些功能,預設會配置授權的。

假如我要訪問紙尿褲批發店的超級會員通道,訪問 /znk/svipindex,這時候會跳轉到登入介面,並且 url 引數包含要回撥的路徑。

 

預設是選中“超級會員”的,此時點選“登入”,就能獲取授權。

 

如果選擇“普通客戶”,就會授失敗,拒絕訪問。

 

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

雖然角色授權功能我們們輕鬆實現了,可是,隨之而來的會產生一些疑問。不知道你有沒有這些疑問,反正老周有。

1、既然在程式碼上角色授權是包含在策略授權中的,那我們們沒配置策略啊,為啥不出錯?

AuthorizationPolicy 類有個靜態方法—— CombineAsync,這個方法的功能是合併已有的策略。但,我們們重點看這一段:

AuthorizationPolicyBuilder? policyBuilder = null;
if (!skipEnumeratingData)
{
    foreach (var authorizeDatum in authorizeData)
    {
        if (policyBuilder == null)
        {
            policyBuilder = new AuthorizationPolicyBuilder();
        }

        var useDefaultPolicy = !(anyPolicies);
        // 如果有指定策略名稱,就合併
        if (!string.IsNullOrWhiteSpace(authorizeDatum.Policy))
        {
            var policy = await policyProvider.GetPolicyAsync(authorizeDatum.Policy).ConfigureAwait(false);
            if (policy == null)
            {
                throw new InvalidOperationException(Resources.FormatException_AuthorizationPolicyNotFound(authorizeDatum.Policy));
            }
            policyBuilder.Combine(policy);
            useDefaultPolicy = false;
        }

        // 如果指定了角色名稱,呼叫 RequireRole 方法新增必備要素
        var rolesSplit = authorizeDatum.Roles?.Split(',');
        if (rolesSplit?.Length > 0)
        {
            var trimmedRolesSplit = rolesSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim());
            policyBuilder.RequireRole(trimmedRolesSplit);
            useDefaultPolicy = false;
        }

        // 同理,如果指定的驗證方案,新增之
        var authTypesSplit = authorizeDatum.AuthenticationSchemes?.Split(',');
        if (authTypesSplit?.Length > 0)
        {
            foreach (var authType in authTypesSplit)
            {
                if (!string.IsNullOrWhiteSpace(authType))
                {
                    policyBuilder.AuthenticationSchemes.Add(authType.Trim());
                }
            }
        }
 ……

原來,在合併策略過程中,會根據 IAuthorizeData 提供的內容動態新增 IAuthorizationRequirement 物件。這裡出現了個 IAuthorizeData  介面,這廝哪來的?莫急,你看看我們們剛才在 紙尿褲 控制器上應用了啥特性。

 [Route("adminindex")]
 [Authorize(Roles = "admin")]
 public IActionResult IndexAdmin()
 {
     return Content("管理員專場");
 }

對,就是它!AuthorizeAttribute,你再看看它實現了什麼介面。

public class AuthorizeAttribute : Attribute, IAuthorizeData

再回憶一下剛剛這段:

 var rolesSplit = authorizeDatum.Roles?.Split(',');
 if (rolesSplit?.Length > 0)
 {
     var trimmedRolesSplit = rolesSplit.Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim());
     policyBuilder.RequireRole(trimmedRolesSplit);
     ……
 }

原來這裡面還有玄機,Role 可以指定多個角色的喲,用逗號(當然是英文的逗號)隔開。如

 [Route("adminindex")]
 [Authorize(Roles = "admin, svip")]
 public IActionResult IndexAdmin()
 {
     ……
 }

 

2、我沒有在中介軟體管道上呼叫 app.UseAuthorization(),為什麼能執行授權處理?

你會發現,在 app 上不呼叫 UseAuthorization 擴充套件方法也能使授權生效。因為像 RazorPages、MVC 這些東東還有一個概念,叫 Filter,可以翻譯為“篩選器”或“過濾器”。老周比較喜歡叫過濾器,因為這叫法生動自然,篩選器感覺是機器翻譯。

在過濾器裡,有專門用在授權方面的介面。

同步:IAuthorizationFilter

非同步:IAsyncAuthorizationFilter

在過濾器中,同步介面和非同步介面只實現其中一個即可。如果你兩個都實現了,那隻執行非同步介面。所以,你兩個都實現純屬白淦,畢竟非同步優先。為啥?你看看 ResourceInvoker 類的原始碼就知道了。

 switch (next)
 {
     case State.InvokeBegin:
         {
             goto case State.AuthorizationBegin;
         }

     case State.AuthorizationBegin:
         {
             _cursor.Reset();
             goto case State.AuthorizationNext;
         }

     case State.AuthorizationNext:
         {
             var current = _cursor.GetNextFilter<IAuthorizationFilter, IAsyncAuthorizationFilter>();
             if (current.FilterAsync != null)  // 執行非同步方法
             {
                 if (_authorizationContext == null)
                 {
                     _authorizationContext = new AuthorizationFilterContextSealed(_actionContext, _filters);
                 }

                 state = current.FilterAsync;
                 goto case State.AuthorizationAsyncBegin;
             }
             else if (current.Filter != null) // 執行同步方法
             {
                 if (_authorizationContext == null)
                 {
                     _authorizationContext = new AuthorizationFilterContextSealed(_actionContext, _filters);
                 }

                 state = current.Filter;
                 goto case State.AuthorizationSync;
             }
             else
             {
          // 如果都不是授權過濾器,直接 End
goto case State.AuthorizationEnd; } } case State.AuthorizationAsyncBegin: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); var filter = (IAsyncAuthorizationFilter)state; var authorizationContext = _authorizationContext; _diagnosticListener.BeforeOnAuthorizationAsync(authorizationContext, filter); _logger.BeforeExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAsyncAuthorizationFilter.OnAuthorizationAsync), filter); var task = filter.OnAuthorizationAsync(authorizationContext); if (!task.IsCompletedSuccessfully) { next = State.AuthorizationAsyncEnd; return task; } goto case State.AuthorizationAsyncEnd; } case State.AuthorizationAsyncEnd: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); var filter = (IAsyncAuthorizationFilter)state; var authorizationContext = _authorizationContext; _diagnosticListener.AfterOnAuthorizationAsync(authorizationContext, filter); _logger.AfterExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAsyncAuthorizationFilter.OnAuthorizationAsync), filter); if (authorizationContext.Result != null) { goto case State.AuthorizationShortCircuit; } // 完成後直接下一個授權過濾器 goto case State.AuthorizationNext; } case State.AuthorizationSync: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); var filter = (IAuthorizationFilter)state; var authorizationContext = _authorizationContext; _diagnosticListener.BeforeOnAuthorization(authorizationContext, filter); _logger.BeforeExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAuthorizationFilter.OnAuthorization), filter); filter.OnAuthorization(authorizationContext); _diagnosticListener.AfterOnAuthorization(authorizationContext, filter); _logger.AfterExecutingMethodOnFilter( FilterTypeConstants.AuthorizationFilter, nameof(IAuthorizationFilter.OnAuthorization), filter); if (authorizationContext.Result != null) { goto case State.AuthorizationShortCircuit; } // 完成後直接一下授權過濾器 goto case State.AuthorizationNext; } case State.AuthorizationShortCircuit: { Debug.Assert(state != null); Debug.Assert(_authorizationContext != null); Debug.Assert(_authorizationContext.Result != null); Log.AuthorizationFailure(_logger, (IFilterMetadata)state); // This is a short-circuit - execute relevant result filters + result and complete this invocation. isCompleted = true; _result = _authorizationContext.Result; return InvokeAlwaysRunResultFilters(); } case State.AuthorizationEnd: { goto case State.ResourceBegin; }

程式碼很長,老周總結一下它的執行軌跡:

1、AuthorizationBegin 授權開始

2、AuthorizationNext 下一個過濾器

3、如果是非同步,走 AuthorizationAsyncBegin

      如果同步,走 AuthorizationSync

      如果都不是,直接走到 AuthorizationEnd

4、非同步:AuthorizationAsyncBegin --> AuthorizationAsyncEnd --> AuthorizationNext(回第2步,有請下一位過濾俠)

      同步:AuthorizationSync --> AuthorizationNext(回第2步,有請下一位)

5、AuthorizationEnd 退場,進入 ResourceFilter 主會場

6、在2、3、4步過程中,如果授權失敗或出錯,直接短路,走 AuthorizationShortCircuit

你瞧,是不是同步和非同步只執行一個?

預設的授權過濾器實現 IAsyncAuthorizationFilter,即 AuthorizeFilter 類。所以,授權處理就是在這裡被觸發了。

var policyEvaluator = context.HttpContext.RequestServices.GetRequiredService<IPolicyEvaluator>();

// 先進行驗證
var authenticateResult = await policyEvaluator.AuthenticateAsync(effectivePolicy, context.HttpContext);

// 如果允許匿名訪問,後面的工作就免了
if (HasAllowAnonymous(context))
{
    return;
}

// 驗證過了,再評估授權策略
var authorizeResult = await policyEvaluator.AuthorizeAsync(effectivePolicy, authenticateResult, context.HttpContext, context);

if (authorizeResult.Challenged) //沒登入呢,去登入
{
    context.Result = new ChallengeResult(effectivePolicy.AuthenticationSchemes.ToArray());
}
else if (authorizeResult.Forbidden)  //授權失敗,拒絕訪問
{
    context.Result = new ForbidResult(effectivePolicy.AuthenticationSchemes.ToArray());
}

但是,這個授權過濾器在 MvcOptions 的 Filters 中沒有啊,它是啥時候弄進去的?這貨不是在 Filters 中配置的,而是在 Application Model 初始化時透過 AuthorizationApplicationModelProvider 類弄進去的。AuthorizationApplicationModelProvider 類實現了 IApplicationModelProvider 介面,但不對外公開。

 public void OnProvidersExecuting(ApplicationModelProviderContext context)
 {
     if (context == null)
     {
         throw new ArgumentNullException(nameof(context));
     }

     if (_mvcOptions.EnableEndpointRouting)
     {
         // When using endpoint routing, the AuthorizationMiddleware does the work that Auth filters would otherwise perform.
         // Consequently we do not need to convert authorization attributes to filters.
         return;
     }

     foreach (var controllerModel in context.Result.Controllers)
     {
         var controllerModelAuthData = controllerModel.Attributes.OfType<IAuthorizeData>().ToArray();
         if (controllerModelAuthData.Length > 0)
         {
             controllerModel.Filters.Add(GetFilter(_policyProvider, controllerModelAuthData));
         }
         foreach (var attribute in controllerModel.Attributes.OfType<IAllowAnonymous>())
         {
             controllerModel.Filters.Add(new AllowAnonymousFilter());
         }

         foreach (var actionModel in controllerModel.Actions)
         {
             var actionModelAuthData = actionModel.Attributes.OfType<IAuthorizeData>().ToArray();
             if (actionModelAuthData.Length > 0)
             {
                 actionModel.Filters.Add(GetFilter(_policyProvider, actionModelAuthData));
             }

             foreach (var _ in actionModel.Attributes.OfType<IAllowAnonymous>())
             {
                 actionModel.Filters.Add(new AllowAnonymousFilter());
             }
         }
     }
 }

而 filter 是在 GetFilter 方法生成的。

    public static AuthorizeFilter GetFilter(IAuthorizationPolicyProvider policyProvider, IEnumerable<IAuthorizeData> authData)
    {
        // The default policy provider will make the same policy for given input, so make it only once.
        // This will always execute synchronously.
        if (policyProvider.GetType() == typeof(DefaultAuthorizationPolicyProvider))
        {
            var policy = AuthorizationPolicy.CombineAsync(policyProvider, authData).GetAwaiter().GetResult()!;
            return new AuthorizeFilter(policy);
        }
        else
        {
            return new AuthorizeFilter(policyProvider, authData);
        }
    }

 

3、RolesAuthorizationRequirement 實現了 IAuthorizationHandler 介面,可是它又沒註冊到服務容器中,HandlerAsync 方法又是怎麼呼叫的?

RolesAuthorizationRequirement 一步到位,既實現了 IAuthorizationRequirement 介面又實現抽象類 AuthorizationHandler<TRequirement>。它雖然沒有在服務容器中註冊,可服務容器中註冊了 PassThroughAuthorizationHandler 類,有它在,各種實現 IAuthorizationHandler 介面的 Requirement 都能順利執行,看看原始碼。

public class PassThroughAuthorizationHandler : IAuthorizationHandler
{
    ……

    public async Task HandleAsync(AuthorizationHandlerContext context)
    {
        foreach (var handler in context.Requirements.OfType<IAuthorizationHandler>())
        {
            await handler.HandleAsync(context).ConfigureAwait(false);
            if (!_options.InvokeHandlersAfterFailure && context.HasFailed)
            {
                break;
            }
        }
    }
}

看,這不就執行了嗎。

至此,我們們就知道這角色授權的流程是怎麼走的了。

相關文章