.NET Core微服務之基於Ocelot+IdentityServer實現統一驗證與授權


Tip: 此篇已加入.NET Core微服務基礎系列文章索引

一、案例結構總覽

  這里,假設我們有兩個客戶端(一個Web網站,一個移動App),他們要使用系統,需要通過API網關(這里API網關始終作為客戶端的統一入口)先向IdentityService進行Login以進行驗證並獲取Token,在IdentityService的驗證過程中會訪問數據庫以驗證。然后再帶上Token通過API網關去訪問具體的API Service。這里我們的IdentityService基於IdentityServer4開發,它具有統一登錄驗證和授權的功能。

二、改寫API Gateway

  這里主要基於前兩篇已經搭好的API Gateway進行改寫,如不熟悉,可以先瀏覽前兩篇文章:Part 1Part 2

2.1 配置文件的改動

  ......  
  "AuthenticationOptions": {
    "AuthenticationProviderKey": "ClientServiceKey",
    "AllowedScopes": []
  }
  ......  
  "AuthenticationOptions": {
    "AuthenticationProviderKey": "ProductServiceKey",
    "AllowedScopes": []
  }
  ......  

  上面分別為兩個示例API Service增加Authentication的選項,為其設置ProviderKey。下面會對不同的路由規則設置的ProviderKey設置具體的驗證方式。

2.2 改寫StartUp類

    public void ConfigureServices(IServiceCollection services)
    {
        // IdentityServer
        #region IdentityServerAuthenticationOptions => need to refactor
        Action<IdentityServerAuthenticationOptions> isaOptClient = option =>
            {
                option.Authority = Configuration["IdentityService:Uri"];
                option.ApiName = "clientservice";
                option.RequireHttpsMetadata = Convert.ToBoolean(Configuration["IdentityService:UseHttps"]);
                option.SupportedTokens = SupportedTokens.Both;
                option.ApiSecret = Configuration["IdentityService:ApiSecrets:clientservice"];
            };

        Action<IdentityServerAuthenticationOptions> isaOptProduct = option =>
        {
            option.Authority = Configuration["IdentityService:Uri"];
            option.ApiName = "productservice";
            option.RequireHttpsMetadata = Convert.ToBoolean(Configuration["IdentityService:UseHttps"]);
            option.SupportedTokens = SupportedTokens.Both;
            option.ApiSecret = Configuration["IdentityService:ApiSecrets:productservice"];
        }; 
        #endregion

        services.AddAuthentication()
            .AddIdentityServerAuthentication("ClientServiceKey", isaOptClient)
            .AddIdentityServerAuthentication("ProductServiceKey", isaOptProduct);
        // Ocelot
        services.AddOcelot(Configuration);
        ......       
    }

  這里的ApiName主要對應於IdentityService中的ApiResource中定義的ApiName。這里用到的配置文件定義如下:

  "IdentityService": {
    "Uri": "http://localhost:5100",
    "UseHttps": false,
    "ApiSecrets": {
      "clientservice": "clientsecret",
      "productservice": "productsecret"
    }
  }
View Code

  這里的定義方式,我暫時還沒想好怎么重構,不過肯定是需要重構的,不然這樣一個一個寫比較繁瑣,且不利於配置。

三、新增IdentityService

這里我們會基於之前基於IdentityServer的兩篇文章,新增一個IdentityService,不熟悉的朋友可以先瀏覽一下Part 1Part 2

3.1 准備工作

  新建一個ASP.NET Core Web API項目,綁定端口5100,NuGet安裝IdentityServer4。配置好證書,並設置其為“較新則復制”,以便能夠在生成目錄中讀取到。

3.2 定義一個InMemoryConfiguration用於測試

    /// <summary>
    /// One In-Memory Configuration for IdentityServer => Just for Demo Use
    /// </summary>
    public class InMemoryConfiguration
    {
        public static IConfiguration Configuration { get; set; }
        /// <summary>
        /// Define which APIs will use this IdentityServer
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new[]
            {
                new ApiResource("clientservice", "CAS Client Service"),
                new ApiResource("productservice", "CAS Product Service"),
                new ApiResource("agentservice", "CAS Agent Service")
            };
        }

        /// <summary>
        /// Define which Apps will use thie IdentityServer
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<Client> GetClients()
        {
            return new[]
            {
                new Client
                {
                    ClientId = "cas.sg.web.nb",
                    ClientName = "CAS NB System MPA Client",
                    ClientSecrets = new [] { new Secret("websecret".Sha256()) },
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AllowedScopes = new [] { "clientservice", "productservice",
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile }
                },
                new Client
                {
                    ClientId = "cas.sg.mobile.nb",
                    ClientName = "CAS NB System Mobile App Client",
                    ClientSecrets = new [] { new Secret("mobilesecret".Sha256()) },
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AllowedScopes = new [] { "productservice",
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile }
                },
                new Client
                {
                    ClientId = "cas.sg.spa.nb",
                    ClientName = "CAS NB System SPA Client",
                    ClientSecrets = new [] { new Secret("spasecret".Sha256()) },
                    AllowedGrantTypes = GrantTypes.ResourceOwnerPassword,
                    AllowedScopes = new [] { "agentservice", "clientservice", "productservice",
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile }
                },
                new Client
                {
                    ClientId = "cas.sg.mvc.nb.implicit",
                    ClientName = "CAS NB System MVC App Client",
                    AllowedGrantTypes = GrantTypes.Implicit,
                    RedirectUris = { Configuration["Clients:MvcClient:RedirectUri"] },
                    PostLogoutRedirectUris = { Configuration["Clients:MvcClient:PostLogoutRedirectUri"] },
                    AllowedScopes = new [] {
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Profile,
                        "agentservice", "clientservice", "productservice"
                    },
                    //AccessTokenLifetime = 3600, // one hour
                    AllowAccessTokensViaBrowser = true // can return access_token to this client
                }
            };
        }

        /// <summary>
        /// Define which IdentityResources will use this IdentityServer
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<IdentityResource> GetIdentityResources()
        {
            return new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
            };
        }
    }

  這里使用了上一篇的內容,不再解釋。實際環境中,則應該考慮從NoSQL或數據庫中讀取。

3.3 定義一個ResourceOwnerPasswordValidator

  在IdentityServer中,要實現自定義的驗證用戶名和密碼,需要實現一個接口:IResourceOwnerPasswordValidator

    public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
    {
        private ILoginUserService loginUserService;

        public ResourceOwnerPasswordValidator(ILoginUserService _loginUserService)
        {
            this.loginUserService = _loginUserService;
        }

        public Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
        {
            LoginUser loginUser = null;
            bool isAuthenticated = loginUserService.Authenticate(context.UserName, context.Password, out loginUser);
            if (!isAuthenticated)
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant, "Invalid client credential");
            }
            else
            {
                context.Result = new GrantValidationResult(
                    subject : context.UserName,
                    authenticationMethod : "custom",
                    claims : new Claim[] {
                        new Claim("Name", context.UserName),
                        new Claim("Id", loginUser.Id.ToString()),
                        new Claim("RealName", loginUser.RealName),
                        new Claim("Email", loginUser.Email)
                    }
                );
            }

            return Task.CompletedTask;
        }
    }

  這里的ValidateAsync方法中(你也可以把它寫成異步的方式,這里使用的是同步的方式),會調用EF去訪問數據庫進行驗證,數據庫的定義如下(密碼應該做加密,這里只做demo,沒用弄):

  

  至於EF部分,則是一個典型的簡單的Service調用Repository的邏輯,下面只貼Repository部分:

    public class LoginUserRepository : RepositoryBase<LoginUser, IdentityDbContext>, ILoginUserRepository
    {
        public LoginUserRepository(IdentityDbContext dbContext) : base(dbContext)
        {
        }

        public LoginUser Authenticate(string _userName, string _userPassword)
        {
            var entity = DbContext.LoginUsers.FirstOrDefault(p => p.UserName == _userName &&
                p.Password == _userPassword);

            return entity;
        }
    }
View Code

  其他具體邏輯請參考示例代碼。

3.4 改寫StarUp類

    public void ConfigureServices(IServiceCollection services)
    {
        // IoC - DbContext
        services.AddDbContextPool<IdentityDbContext>(
            options => options.UseSqlServer(Configuration["DB:Dev"]));
        // IoC - Service & Repository
        services.AddScoped<ILoginUserService, LoginUserService>();
        services.AddScoped<ILoginUserRepository, LoginUserRepository>();
        // IdentityServer4
        string basePath = PlatformServices.Default.Application.ApplicationBasePath;
        InMemoryConfiguration.Configuration = this.Configuration;
        services.AddIdentityServer()
            .AddSigningCredential(new X509Certificate2(Path.Combine(basePath,
                Configuration["Certificates:CerPath"]),
                Configuration["Certificates:Password"]))
            //.AddTestUsers(InMemoryConfiguration.GetTestUsers().ToList())
            .AddInMemoryIdentityResources(InMemoryConfiguration.GetIdentityResources())
            .AddInMemoryApiResources(InMemoryConfiguration.GetApiResources())
            .AddInMemoryClients(InMemoryConfiguration.GetClients())
            .AddResourceOwnerValidator<ResourceOwnerPasswordValidator>() .AddProfileService<ProfileService>();
        ......
    }

  這里高亮的是新增的部分,為了實現自定義驗證。關於ProfileService的定義如下:

    public class ProfileService : IProfileService
    {
        public async Task GetProfileDataAsync(ProfileDataRequestContext context)
        {
            var claims = context.Subject.Claims.ToList();
            context.IssuedClaims = claims.ToList();
        }

        public async Task IsActiveAsync(IsActiveContext context)
        {
            context.IsActive = true;
        }
    }
View Code

3.5 新增統一Login入口

  這里新增一個LoginController:

    [Produces("application/json")]
    [Route("api/Login")]
    public class LoginController : Controller
    {
        private IConfiguration configuration;
        public LoginController(IConfiguration _configuration)
        {
            configuration = _configuration;
        }

        [HttpPost]
        public async Task<ActionResult> RequestToken([FromBody]LoginRequestParam model)
        {
            Dictionary<string, string> dict = new Dictionary<string, string>();
            dict["client_id"] = model.ClientId;
            dict["client_secret"] = configuration[$"IdentityClients:{model.ClientId}:ClientSecret"];
            dict["grant_type"] = configuration[$"IdentityClients:{model.ClientId}:GrantType"];
            dict["username"] = model.UserName;
            dict["password"] = model.Password;

            using (HttpClient http = new HttpClient())
            using (var content = new FormUrlEncodedContent(dict))
            {
                var msg = await http.PostAsync(configuration["IdentityService:TokenUri"], content);
                if (!msg.IsSuccessStatusCode)
                {
                    return StatusCode(Convert.ToInt32(msg.StatusCode));
                }

                string result = await msg.Content.ReadAsStringAsync();
                return Content(result, "application/json");
            }
        }
    }

  這里假設客戶端會傳遞用戶名,密碼以及客戶端ID(ClientId,比如上面InMemoryConfiguration中的cas.sg.web.nb或cas.sg.mobile.nb)。然后構造參數再調用connect/token接口進行身份驗證和獲取token。這里將client_secret等機密信息封裝到了服務器端,無須客戶端傳遞(對於機密信息一般也不會讓客戶端知道):

  "IdentityClients": {
    "cas.sg.web.nb": {
      "ClientSecret": "websecret",
      "GrantType": "password"
    },
    "cas.sg.mobile.nb": {
      "ClientSecret": "mobilesecret",
      "GrantType": "password"
    }
  }

3.6 加入API網關中

  在API網關的Ocelot配置文件中加入配置,配置如下(這里我是開發用,所以沒有用服務發現,實際環境建議采用服務發現):

    // --> Identity Service Part
    {
      "UseServiceDiscovery": false, // do not use Consul service discovery in DEV env
      "DownstreamPathTemplate": "/api/{url}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": "5100"
        }
      ],
      "ServiceName": "CAS.IdentityService",
      "LoadBalancerOptions": {
        "Type": "RoundRobin"
      },
      "UpstreamPathTemplate": "/api/identityservice/{url}",
      "UpstreamHttpMethod": [ "Get", "Post" ],
      "RateLimitOptions": {
        "ClientWhitelist": [ "admin" ], // 白名單
        "EnableRateLimiting": true, // 是否啟用限流
        "Period": "1m", // 統計時間段:1s, 5m, 1h, 1d
        "PeriodTimespan": 15, // 多少秒之后客戶端可以重試
        "Limit": 10 // 在統計時間段內允許的最大請求數量
      },
      "QoSOptions": {
        "ExceptionsAllowedBeforeBreaking": 2, // 允許多少個異常請求
        "DurationOfBreak": 5000, // 熔斷的時間,單位為秒
        "TimeoutValue": 3000 // 如果下游請求的處理時間超過多少則視如該請求超時
      },
      "HttpHandlerOptions": {
        "UseTracing": false // use butterfly to tracing request chain
      },
      "ReRoutesCaseSensitive": false // non case sensitive
    }

四、改寫業務API Service

4.1 ClientService

  (1)安裝IdentityServer4.AccessTokenValidation

NuGet>Install-Package IdentityServer4.AccessTokenValidation

  (2)改寫StartUp類

    public IServiceProvider ConfigureServices(IServiceCollection services)
    {
        ......

        // IdentityServer
        services.AddAuthentication(Configuration["IdentityService:DefaultScheme"])
            .AddIdentityServerAuthentication(options =>
            {
                options.Authority = Configuration["IdentityService:Uri"];
                options.RequireHttpsMetadata = Convert.ToBoolean(Configuration["IdentityService:UseHttps"]);
            });

        ......
    }

  這里配置文件的定義如下:

  "IdentityService": {
    "Uri": "http://localhost:5100",
    "DefaultScheme":  "Bearer",
    "UseHttps": false,
    "ApiSecret": "clientsecret"
  }

4.2 ProductService

  與ClientService一致,請參考示例代碼。

五、測試

5.1 測試Client: cas.sg.web.nb

  (1)統一驗證&獲取token (by API網關)

  

  (2)訪問clientservice (by API網關)

  

  (3)訪問productservice(by API網關)

  

5.2 測試Client: cas.sg.mobile.nb

  由於在IdentityService中我們定義了一個mobile的客戶端,但是其訪問權限只有productservice,所以我們來測試一下:

  (1)統一驗證&獲取token

  

  (2)訪問ProductService(by API網關)

  

  (3)訪問ClientService(by API網關) => 401 Unauthorized

  

六、小結

  本篇主要基於前面Ocelot和IdentityServer的文章的基礎之上,將Ocelot和IdentityServer進行結合,通過建立IdentityService進行統一的身份驗證和授權,最后演示了一個案例以說明如何實現。不過,本篇實現的Demo還存在諸多不足,比如需要重構的代碼較多如網關中各個Api的驗證選項的注冊,沒有對各個請求做用戶角色和權限的驗證等等,相信隨着研究和深入的深入,這些都可以逐步解決。后續會探索一下數據一致性的基本知識以及框架使用,到時再做一些分享。

示例代碼

  Click Here => 點我進入GitHub

參考資料

  楊中科,《.NET Core微服務介紹課程

  

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM