1. 程式人生 > >Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(四)

Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(四)

在上一講中,我們已經完成了一個完整的案例,在這個案例中,我們可以通過Angular單頁面應用(SPA)進行登入,然後通過後端的Ocelot API閘道器整合IdentityServer4完成身份認證。在本講中,我們會討論在當前這種架構的應用程式中,如何完成使用者授權。

回顧

  • 《Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(一)》
  • 《Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(二)》
  • 《Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權(三)》

使用者授權簡介

在繼續分析我們的應用程式之前,我們簡單回顧一下使用者授權。在使用者登入的過程中,系統首先確定當前試圖登入的使用者是否為合法使用者,也就是該使用者是否被允許訪問應用程式,在這個過程中,登入流程並不負責檢查使用者對哪些資源具有訪問許可權,反正系統中存在使用者的合法記錄,就認證通過。接下來,該使用者賬戶就需要訪問系統中的各個功能模組,並檢視或者修改系統中的業務資料,此時,授權機制就會發揮作用,以便檢查當前登入使用者是否被允許訪問某些功能模組或者某些資料,以及該使用者對這些資料是否具有讀寫許可權。這種決定使用者是否被允許以某種方式訪問系統中的某些資源的機制,稱為授權。

最常見的授權可以基於使用者組,也可以基於使用者角色,還可以組合使用者組與角色,實現基於角色的授權(Role Based Access Control,RBAC)。比如:某個“使用者”屬於“管理員組”,而“管理員組”的所有“使用者”都具有“管理員角色”,對於“管理員角色”,系統允許它可以管理和組織系統中的業務資料,但不能對使用者賬戶進行管理,系統希望只有超級管理員才可以管理使用者賬戶。於是,當某個使用者賬戶被新增到“管理員組”之後,該使用者賬戶就自動被賦予了“管理員角色”,它可以管理系統中的業務資料,但仍然無法對系統中的使用者賬戶進行管理,因為那是“超級管理員”的事情。

從應用程式的架構角度來看,不難得出這樣的結論:使用者認證可以通過第三方的框架或者解決方案來完成,但使用者授權一般都是在應用程式內部完成的,因為它的業務性很強。不同系統可以有不同的授權方式,但認證方式還是相對統一的,比如讓使用者提供使用者名稱密碼,或者通過第三方身份供應商(Identity Provider,IdP)完成單點登入等等。縱觀當下流行的認證服務供應商(例如Auth0),它們在認證這部分的功能非常強大,但僅提供一些相對簡單基礎的授權服務,幫助應用程式完成一些簡單的授權需求,雖然應用程式也可以依賴第三方服務供應商來統一完成認證與授權,但這並不是一個很好的架構實踐,因為對第三方服務的依賴性太強。

回顧我們的案例,至今為止,我們僅僅完成了使用者認證的部分,接下來,一起看看在Ocelot API閘道器中如何做使用者授權。

使用者授權的實現

在系統架構中引入API閘道器之後,實現使用者授權可以有以下兩種方式:

  1. 在API閘道器處完成使用者授權。這種方式不需要後臺的服務各自實現自己的授權體系,使用者授權由API閘道器代為完成,如果授權失敗,API閘道器會直接返回授權失敗,不會將客戶端請求進一步轉發給後端的服務。優點是可以實現統一的授權機制,並且減少後端服務的處理壓力,後端服務無需關注和處理授權相關的邏輯;缺點是API閘道器本身需要知道系統的使用者授權策略
  2. API閘道器將使用者賬戶資訊傳遞給後端服務,由服務各自實現授權。這種做法優點是API閘道器無需關心由應用程式業務所驅動的授權機制,缺點是每個服務要各自管理自己的授權邏輯

後端服務授權

先來看看第二種方式,也就是API閘道器將使用者賬戶資訊傳遞給後端服務,由後端服務完成授權。在前文中,我們可以看到,Access Token中已經包含了如下四個User Claims:

  • nameidentifier
  • name
  • emailaddress
  • role

Ocelot允許將Token中所包含的Claims通過HTTP Header的形式傳遞到後端服務上去,做法非常簡單,只需要修改Ocelot的配置檔案即可,例如:

{
  "ReRoutes": [
    {
      "DownstreamPathTemplate": "/weatherforecast",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5000
        }
      ],
      "UpstreamPathTemplate": "/api/weather",
      "UpstreamHttpMethod": [ "Get" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "AuthKey",
        "AllowedScopes": []
      },
      "AddHeadersToRequest": {
        "X-CLAIMS-NAME-IDENTIFIER": "Claims[http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier] > value > |",
        "X-CLAIMS-NAME": "Claims[http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name] > value > |",
        "X-CLAIMS-EMAIL": "Claims[http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress] > value > |",
        "X-CLAIMS-ROLE": "Claims[http://schemas.microsoft.com/ws/2008/06/identity/claims/role] > value > |"
      }
    }
  ]
}

然後重新執行服務,並在後端服務的API Controller中設定斷點,可以看到,這四個Claims的資料都可以通過Request.Headers得到:

有了這個資訊,服務端就可以得知目前是哪個使用者賬戶在請求API呼叫,並且它是屬於哪個角色,剩下的工作就是基於這個角色資訊來決定是否允許當前使用者訪問當前的API。很顯然,這裡需要一種合理的設計,而且至少需要滿足以下兩個需求:

  1. 授權機制的實現應該能夠被後端多個服務所重用,以便解決“每個服務要各自管理自己的授權邏輯”這一弊端
  2. API控制器不應該自己實現授權部分的程式碼,可以通過擴充套件中介軟體並結合C# Attribute的方式完成

在這裡我們就不深入討論如何去設計這樣一套許可權認證系統了,今後有機會再介紹吧。

注:Ocelot可以支援多種Claims的轉換形式,這裡介紹的AddHeadersToRequest只是其中的一種,更多方式可以參考:https://ocelot.readthedocs.io/en/latest/features/claimstransformation.html

Ocelot API閘道器授權

通過Ocelot閘道器授權,有兩種比較常用的方式,一種是在配置檔案中,針對不同的downstream配置,設定其RouteClaimsRequirement配置,以便指定哪些使用者角色能夠被允許訪問所請求的API資源。比如:

{
  "ReRoutes": [
    {
      "DownstreamPathTemplate": "/weatherforecast",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5000
        }
      ],
      "UpstreamPathTemplate": "/api/weather",
      "UpstreamHttpMethod": [ "Get" ],
      "AuthenticationOptions": {
        "AuthenticationProviderKey": "AuthKey",
        "AllowedScopes": []
      },
      "RouteClaimsRequirement": {
        "Role": "admin"
      }
    }
  ]
}

上面高亮部分的程式碼指定了只有admin角色的使用者才能訪問/weatherforecast API,這裡的“Role”就是Claim的名稱,而“admin”就是Claim的值。如果我們在此處將Role設定為superadmin,那麼前端頁面就無法正常訪問API,而是獲得403 Forbidden的狀態碼:

注意:理論上講,此處的“Role”原本應該是使用標準的Role Claim的名稱,即原本應該是:

但由於ASP.NET Core框架在處理JSON配置檔案時存在特殊性,使得上述標準的Role Claim的名稱無法被正確解析,因此,也就無法在RouteClaimsRequirement中正常使用。目前的解決方案就是使用者認證後,在Access Token中帶入一個自定義的Role Claim(在這裡我使用最簡單的名字“Role”作為這個自定義的Claim的名稱,這也是為什麼上面的JSON配置例子中,使用的是“Role”,而不是“http://schemas.microsoft.com/ws/2008/06/identity/claims/role”),而要做到這一點,就要修改兩個地方。

首先,在IdentityService的Config.cs檔案中,增加一個自定義的User Claim:

public static IEnumerable<ApiResource> GetApiResources() =>
    new[]
    {
        new ApiResource("api.weather", "Weather API")
        {
            Scopes =
            {
                new Scope("api.weather.full_access", "Full access to Weather API")
            },
            UserClaims =
            {
                ClaimTypes.NameIdentifier,
                ClaimTypes.Name,
                ClaimTypes.Email,
                ClaimTypes.Role,
                "Role"
            }
        }
    };

然後,在註冊新使用者的API中,當用戶註冊資訊包含Role時,將“Role” Claim也新增到資料庫中:

[HttpPost]
[Route("api/[controller]/register-account")]
public async Task<IActionResult> RegisterAccount([FromBody] RegisterUserRequestViewModel model)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var user = new AppUser { UserName = model.UserName, DisplayName = model.DisplayName, Email = model.Email };


    var result = await _userManager.CreateAsync(user, model.Password);

    if (!result.Succeeded) return BadRequest(result.Errors);

    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.NameIdentifier, user.UserName));
    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Name, user.DisplayName));
    await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Email, user.Email));

    if (model.RoleNames?.Count > 0)
    {
        var validRoleNames = new List<string>();
        foreach (var roleName in model.RoleNames)
        {
            var trimmedRoleName = roleName.Trim();
            if (await _roleManager.RoleExistsAsync(trimmedRoleName))
            {
                validRoleNames.Add(trimmedRoleName);
                await _userManager.AddToRoleAsync(user, trimmedRoleName);
            }
        }

        await _userManager.AddClaimAsync(user, new Claim(ClaimTypes.Role, string.Join(',', validRoleNames)));
        await _userManager.AddClaimAsync(user, new Claim("Role", string.Join(',', validRoleNames)));
    }

    return Ok(new RegisterUserResponseViewModel(user));
}

修改完後,重新通過呼叫這個register-account API來新建一個使用者來進行測試,一切正常的話,就可以通過Ocelot API閘道器中的RouteClaimsRequirement來完成授權了。

通過Ocelot閘道器授權的另一種做法是使用程式碼實現。通過程式碼方式,可以實現更為複雜的授權策略,我們仍然以“角色”作為授權參照,我們可以首先定義所需的授權策略:

public void ConfigureServices(IServiceCollection services)
{
    services.AddOcelot();
    services.AddAuthentication()
        .AddIdentityServerAuthentication("AuthKey", options =>
        {
            options.Authority = "http://localhost:7889";
            options.RequireHttpsMetadata = false;
        });

    services.AddAuthorization(options =>
    {
        options.AddPolicy("admin", builder => builder.RequireRole("admin"));
        options.AddPolicy("superadmin", builder => builder.RequireRole("superadmin"));
    });

    services.AddCors(options => options.AddPolicy("AllowAll", p => p.AllowAnyOrigin()
       .AllowAnyMethod()
       .AllowAnyHeader()));
}

然後使用Ocelot的AuthorisationMiddleware中介軟體,來定義我們的授權處理邏輯:

app.UseOcelot((b, c) =>
{
    c.AuthorisationMiddleware = async (ctx, next) =>
    {
        if (ctx.DownstreamReRoute.DownstreamPathTemplate.Value == "/weatherforecast")
        {
            var authorizationService = ctx.HttpContext.RequestServices.GetService<IAuthorizationService>();
            var result = await authorizationService.AuthorizeAsync(ctx.HttpContext.User, "superadmin");
            if (result.Succeeded)
            {
                await next.Invoke();
            }
            else
            {
                ctx.Errors.Add(new UnauthorisedError($"Fail to authorize policy: admin"));
            }
        }
        else
        {
            await next.Invoke();
        }
    };

    b.BuildCustomOcelotPipeline(c).Build();
    
}).Wait();

當然,上面的BuildCustomOcelotPipeline方法的目的就是將一些預設的Ocelot中介軟體加入到管道中,否則整個Ocelot框架是不起作用的。我將這個方法定義為一個擴充套件方法,程式碼如下:

public static class Extensions
{
    private static void UseIfNotNull(this IOcelotPipelineBuilder builder,
        Func<DownstreamContext, Func<Task>, Task> middleware)
    {
        if (middleware != null)
        {
            builder.Use(middleware);
        }
    }

    public static IOcelotPipelineBuilder BuildCustomOcelotPipeline(this IOcelotPipelineBuilder builder,
        OcelotPipelineConfiguration pipelineConfiguration)
    {
        builder.UseExceptionHandlerMiddleware();
        builder.MapWhen(context => context.HttpContext.WebSockets.IsWebSocketRequest,
            app =>
            {
                app.UseDownstreamRouteFinderMiddleware();
                app.UseDownstreamRequestInitialiser();
                app.UseLoadBalancingMiddleware();
                app.UseDownstreamUrlCreatorMiddleware();
                app.UseWebSocketsProxyMiddleware();
            });
        builder.UseIfNotNull(pipelineConfiguration.PreErrorResponderMiddleware);
        builder.UseResponderMiddleware();
        builder.UseDownstreamRouteFinderMiddleware();
        builder.UseSecurityMiddleware();
        if (pipelineConfiguration.MapWhenOcelotPipeline != null)
        {
            foreach (var pipeline in pipelineConfiguration.MapWhenOcelotPipeline)
            {
                builder.MapWhen(pipeline);
            }
        }
        builder.UseHttpHeadersTransformationMiddleware();
        builder.UseDownstreamRequestInitialiser();
        builder.UseRateLimiting();

        builder.UseRequestIdMiddleware();
        builder.UseIfNotNull(pipelineConfiguration.PreAuthenticationMiddleware);
        if (pipelineConfiguration.AuthenticationMiddleware == null)
        {
            builder.UseAuthenticationMiddleware();
        }
        else
        {
            builder.Use(pipelineConfiguration.AuthenticationMiddleware);
        }
        builder.UseClaimsToClaimsMiddleware();
        builder.UseIfNotNull(pipelineConfiguration.PreAuthorisationMiddleware);
        if (pipelineConfiguration.AuthorisationMiddleware == null)
        {
            builder.UseAuthorisationMiddleware();
        }
        else
        {
            builder.Use(pipelineConfiguration.AuthorisationMiddleware);
        }
        builder.UseClaimsToHeadersMiddleware();
        builder.UseIfNotNull(pipelineConfiguration.PreQueryStringBuilderMiddleware);
        builder.UseClaimsToQueryStringMiddleware();
        builder.UseLoadBalancingMiddleware();
        builder.UseDownstreamUrlCreatorMiddleware();
        builder.UseOutputCacheMiddleware();
        builder.UseHttpRequesterMiddleware();

        return builder;
    }
}

與上文所提交的“後端服務授權”類似,我們需要在Ocelot API閘道器上定義並實現授權策略,有可能是需要設計一些框架來簡化使用者資料的訪問並提供靈活的、可複用的授權邏輯,由於這部分內容跟每個應用程式的業務關係較為密切,所以本文也就不深入討論了。

總結

至此,有關Angular SPA基於Ocelot API閘道器與IdentityServer4的身份認證與授權的介紹,就告一段落了。通過四篇文章,我們從零開始,一步步搭建微服務、基於IdentityServer4的IdentityService、Ocelot API閘道器以及Angular單頁面應用,並逐步介紹了認證與授權的實現過程。雖然沒有最終實現一個可被重用的授權框架,但基本架構也算是完整了,今後有機會我可以再補充認證、授權的相關內容,歡迎閱讀並提寶貴意見。

原始碼

訪問以下Github地址以獲取原始碼:

https://github.com/daxnet/identity-