微服務系列之授權認證(二) identity server 4

2022-09-20 21:02:32

1.簡介

  IdentityServer4 是為ASP.NET Core系列量身打造的一款基於 OpenID Connect 和 OAuth 2.0 認證授權框架。
    官方檔案https://identityserver4.readthedocs.io/en/latest/

    框架原始碼:https://github.com/IdentityServer/IdentityServer4 

  IdentityServer主要使用場景:

  1)基於中臺認證中心的saas系統/pass系統的單點登入或者做為統一認證授權入口(授權模式:授權碼模式Authorization Code或者混合模式hybrid);

  2)用於API服務與API服務之間的固定token通訊,或者某業務系統服務群集與其他業務系統的服務群集之間通訊,或者某業務系統群集服務與中臺服務群集之間通訊,所使用的授權模式為使用者端模式Client Credential;

  3)用於移動使用者端與API服務之間通訊,授權碼模式為自定義授權碼。

  4)用於給第三方使用者端授權使用平臺資料資源,類似微信、支付寶等使用者授權給。主要授權模式為權碼模式Authorization Code

2.Identity Server入門demo

 新建.net core 3.1專案,nuget安裝IdentityServer4,我這裡是3.14版本

  

正常來說,商業業務,Api資源、Client使用者端、Identity資源、User等資料儲存在資料庫,token可以儲存在資料庫也可以儲存到redis,這裡為了入門演示,使用記憶體模式,快速搭建。

定義一個類,建立API資源,使用者端client,我們這裡只使用使用者端模式授權,篇幅問題,其他授權方式就不一一寫了,基本都差不多

public class TestConfig
    {
        /// <summary>
        /// Api資源 
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource(){
                    Name = "myapi",
                    ApiSecrets= new List<Secret>(){
                        new Secret(){
                            Description = "secret",
                            Value = "secret".Sha256()
                        }
                    },
                    Scopes = new List<Scope>(){
                        new Scope(){
                            Name = "apim"
                        }
                    }
                },
            };
        }

        /// <summary>
        /// client
        /// </summary>
        /// <returns></returns>
        public static IEnumerable<Client> GetClients()
        {

            return new List<Client>
            {
               new Client()
               {
                   ClientId="client",//定義使用者端ID
             
                   //AllowedGrantTypes = new List<string>()
                   //{
                   //    GrantTypes.ResourceOwnerPassword.FirstOrDefault(),
                   //    GrantType.ClientCredentials,
                   //    GrantType.Hybrid
                   //},
                   //必須是單個指定授權型別,可能是記憶體模式問題。
                   AllowedGrantTypes = GrantTypes.ClientCredentials,
                   // 用於認證的密碼
                    ClientSecrets =
                    {
                        new Secret("secret".Sha256())
                    },
                   AllowedScopes= {"apim"},
                   AccessTokenLifetime = 360000000
               },
             };
        }

        public static List<TestUser> GetTestUsers()
        {
            return new List<TestUser>
            {
                new TestUser()
                {
                     SubjectId = "1",
                     Username = "test",
                     Password = "123456"
                }
            };
        }
    }  

在啟動類注入

  public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            #region 記憶體方式
            services.AddIdentityServer()
                .AddDeveloperSigningCredential()//新增證書加密方式,執行該方法,會先判斷tempkey.rsa證書檔案是否存在,如果不存在的話,就建立一個新的tempkey.rsa證書檔案,如果存在的話,就使用此證書檔案。
                .AddInMemoryApiResources(TestConfig.GetApiResources())//把受保護的Api資源新增到記憶體中
                .AddInMemoryClients(TestConfig.GetClients())//使用者端設定新增到記憶體中
                .AddTestUsers(TestConfig.GetTestUsers())//測試的使用者新增進來
            .AddDeveloperSigningCredential();
            #endregion
        }

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }
      
            app.UseStaticFiles();

            app.UseRouting();
        //新增中介軟體       //這個必須在UseRouting和UseEndpoints中間。如果IdentityServer伺服器端和API端要寫在一起, //那麼這個必須在UseAuthorization和UseAuthentication的上面。 app.UseIdentityServer(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); }); }

 然後啟動服務,使用postman存取identity server 4預設的endpoint地址發現檔案:

使用identityServer4的發現檔案中的token_endpoint獲取token

token已經獲取了,可以使用發現檔案裡的introspection_endpoint來驗證token

上圖可見,我們已經為client使用者端,建立了一個擁有存取scope為apim許可權的token

接下來,建立一個受保護的api服務,同樣建立一個.net core 3.1服務,並nuget包安裝Microsoft.AspNetCore.Authentication.JwtBearer,選擇3.14版本,根據.net core版本來

在啟動類中,設定認證和授權DI,和新增認證授權中介軟體:

public class Startup
    {
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        readonly string MyAllowSpecificOrigins = "_myAllowSpecificOrigins";
        public void ConfigureServices(IServiceCollection services)
        {
            //設定認證
            services.AddAuthentication("Bearer")
                .AddJwtBearer(options =>
                {
                    options.Authority = "http://localhost:5000";//剛才啟動的授權認證服務
                    options.RequireHttpsMetadata = false;
                    options.TokenValidationParameters = new TokenValidationParameters //不驗證jwt的aud資訊
                    {
                        ValidateAudience = false
                    };

                });
            // 設定授權策略
            services.AddAuthorization(options =>
            {
                //定義授權策略,這個名字可以隨便起
                options.AddPolicy("ApiScope", policy =>
                {
                    policy.RequireAuthenticatedUser();
                    //
                    policy.RequireClaim("scope", "apim");//策略需要scope有apim
                });
                options.AddPolicy("ApiScope2", policy =>
                {
                    policy.RequireAuthenticatedUser();
                    //
                    policy.RequireClaim("scope", "apim2");
                });
            });
            services.AddSingleton<IHttpContextAccessor, HttpContextAccessor>();
            services
            .AddCors(options =>
            {
                options.AddPolicy(MyAllowSpecificOrigins,
                builder => builder.AllowAnyOrigin()
                .WithMethods("GET", "POST", "HEAD", "PUT", "DELETE", "OPTIONS")
                );
            }).AddMvc();
            services.AddControllers();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            app.Use((context, next) =>
            {
                context.Request.EnableBuffering();
                return next();
            });
            app.UseRouting();
            //跨域設定
            app.UseCors(MyAllowSpecificOrigins);

            //身份驗證中介軟體 (身份驗證必須在授權的前面)
            app.UseAuthentication();

            //授權驗證中介軟體
            app.UseAuthorization();

            app.UseEndpoints(endpoints =>
            {
                endpoints.MapControllers();
            });
            app.UseEndpoints(endpoints =>
            {
                endpoints.MapGet("/", async context =>
                {
                    await context.Response.WriteAsync("Hello World!");
                });
            });
        }
    }

  寫2個介面

注意,Authorize標籤可生效於類或者方法上,,根據不同的授權策略來合理安排需要保護的資源。最後,可以用剛才的token來存取這個API,,如果token錯誤會401,如果根據授權策略的不同,比如mytwo介面受到apiScope2策略保護,apiScope2策略需要apim2這個scope許可權,因為剛才我們獲取的token只包含apim這個scope許可權,所以存取會返回403許可權不足,大家可以去試試,我試過了就不貼圖。

至此demo結束,大家可以去試試其他模式的獲取token方式

3.IdentityServer4的資料儲存

  商業級專案,授權資源是需要持久化儲存的,官方已經提供了基於ef core的來維護我們授權資源和token的管理模型、上下文、倉儲介面等,具體我就不寫了,推薦參考這篇文章.net core 3.1 Identity Server4 (EntityFramework Core 設定) - 塵葉心繁的專欄 - TNBLOG。下面貼幾張基於Identity Server4 EFCore包管理的APIResource、Client、Identity資源、Token的相關程式碼簡介

services.AddIdentityServer()
                    .AddConfigurationStore(options =>  //注入idenity相關資源上下文
                    {
                        options.ResolveDbContextOptions = (provider, builder) =>
                        {
                            builder.UseSqlServer(Configuration.GetSection("Database:ConnectString").Value,
                                sql => sql.MigrationsAssembly(migrationsAssembly));
                        };
                    })
                    .AddOperationalStore(options =>  //注入Token管理上下文
                    {
                        options.ConfigureDbContext = builder =>
                            builder.UseSqlServer(Configuration.GetSection("Database:ConnectString").Value,
                                sql => sql.MigrationsAssembly(migrationsAssembly));
                        options.EnableTokenCleanup = true;
                        options.TokenCleanupInterval = 3600;
                    })
                   .AddDeveloperSigningCredential();
   private ConfigurationDbContext _dbContext;
        private PersistedGrantDbContext _grantdbContext; //這個就是identity資源上下文
        private IOptions<IdentityOption> _identityOption; //這個就是token上下文
        private IMediator _mediator;
        public ClientManager(ConfigurationDbContext dbContext, IOptions<IdentityOption> identityOption, PersistedGrantDbContext grantdbContext, IMediator mediator)
        {
            _dbContext = dbContext;
            _identityOption = identityOption;
            _grantdbContext = grantdbContext;
            _mediator = mediator;
        }

下面是部分Client使用者端管理程式碼

public async Task<Client> CreateClient(ClientEntity clientEntity)
        {
            if (_dbContext.Clients.Any(m => m.ClientName == clientEntity.ClientName))
                throw new Exception("clientName Duplicate");
            if (_dbContext.Clients.Any(m => m.ClientId == clientEntity.ClientId))
                throw new Exception("clientId Duplicate");
            IdentityServer4.EntityFramework.Entities.Client client = new IdentityServer4.EntityFramework.Entities.Client()
            {
                ClientId = clientEntity.ClientId,
                ClientSecrets = new List<IdentityServer4.EntityFramework.Entities.ClientSecret>()
                        {
                            new IdentityServer4.EntityFramework.Entities.ClientSecret(){
                                 Value=clientEntity.Sha256Secret,
                                 Description=clientEntity.Secret
                            }
                        },
                ClientName = clientEntity.ClientName,
                // ClientUri = clientEntity.ClientUri,
                Description = clientEntity.Description,
                AccessTokenType = 1,
                RequireConsent = clientEntity.RequireConsent,
                AccessTokenLifetime = clientEntity.AccessTokenLifetime,
                AllowOfflineAccess = true,
                RedirectUris = new List<IdentityServer4.EntityFramework.Entities.ClientRedirectUri>(),
                PostLogoutRedirectUris = new List<IdentityServer4.EntityFramework.Entities.ClientPostLogoutRedirectUri>(),
                AllowedGrantTypes = new List<IdentityServer4.EntityFramework.Entities.ClientGrantType>(),
                Claims = new List<IdentityServer4.EntityFramework.Entities.ClientClaim>()
            };

            if (clientEntity.RedirectUris.Count > 0)
            {
                foreach (var url in clientEntity.RedirectUris)
                {
                    client.RedirectUris.Add(new IdentityServer4.EntityFramework.Entities.ClientRedirectUri()
                    {
                        RedirectUri = url
                    });
                }

            }
            if (clientEntity.PostLogoutRedirectUris.Count > 0)
            {
                foreach (var url in clientEntity.PostLogoutRedirectUris)
                {
                    client.PostLogoutRedirectUris.Add(new IdentityServer4.EntityFramework.Entities.ClientPostLogoutRedirectUri()
                    {
                        PostLogoutRedirectUri = url
                    });
                }

            }

            //平臺預設開放這三個型別
            var typeList = new List<string>() { "hybrid", "client_credentials", "delegation" };
            typeList.ForEach(type =>
            {
                client.AllowedGrantTypes.Add(new IdentityServer4.EntityFramework.Entities.ClientGrantType()
                {
                    GrantType = type
                });
            });

            var res = await _dbContext.Clients.AddAsync(client);
            await _dbContext.SaveChangesAsync();
            return res.Entity;
        }

        public async Task<Client> UpdateClient(ClientEntity clientEntity)
        {
            if (_dbContext.Clients.Any(m => m.ClientName == clientEntity.ClientName && m.Id != clientEntity.Id))
                throw new Exception("clientName Duplicate");
            var client = await _dbContext.Clients
                                            .Include(x => x.AllowedGrantTypes)
                                            .Include(x => x.RedirectUris)
                                            .Include(x => x.PostLogoutRedirectUris)
                                            .Include(x => x.AllowedScopes)
                                            .Include(x => x.ClientSecrets)
                                            .Include(x => x.Claims)
                                            .Include(x => x.IdentityProviderRestrictions)
                                            .Include(x => x.AllowedCorsOrigins)
                                            .Include(x => x.Properties)
                                            .FirstOrDefaultAsync(x => x.Id == clientEntity.Id);
            if (client == null)
                throw new Exception("Client Not Exists!");
            client.ClientName = clientEntity.ClientName;
            client.Description = clientEntity.Description;
            client.AccessTokenLifetime = clientEntity.AccessTokenLifetime;
            client.RequireConsent = clientEntity.RequireConsent;
            client.Enabled = clientEntity.Enabled;
            client.RedirectUris = new List<IdentityServer4.EntityFramework.Entities.ClientRedirectUri>();
            client.PostLogoutRedirectUris = new List<IdentityServer4.EntityFramework.Entities.ClientPostLogoutRedirectUri>();
            client.AllowedScopes = new List<IdentityServer4.EntityFramework.Entities.ClientScope>();
            client.AllowedGrantTypes = new List<IdentityServer4.EntityFramework.Entities.ClientGrantType>();

            if (clientEntity.RedirectUris.Count > 0)
            {
                foreach (var url in clientEntity.RedirectUris)
                {
                    client.RedirectUris.Add(new IdentityServer4.EntityFramework.Entities.ClientRedirectUri { RedirectUri = url });
                }
            }

            if (clientEntity.PostLogoutRedirectUris.Count > 0)
            {
                foreach (var url in clientEntity.PostLogoutRedirectUris)
                {
                    client.PostLogoutRedirectUris.Add(new IdentityServer4.EntityFramework.Entities.ClientPostLogoutRedirectUri { PostLogoutRedirectUri = url });
                }

            }

            foreach (string scope in clientEntity.AllowedScopes)
            {
                client.AllowedScopes.Add(new IdentityServer4.EntityFramework.Entities.ClientScope { Scope = scope });
            }

            foreach (string key in clientEntity.AllowedGrantTypes)
            {
                client.AllowedGrantTypes.Add(new IdentityServer4.EntityFramework.Entities.ClientGrantType { GrantType = key });
            }

            var res = _dbContext.Clients.Update(client);
            await _dbContext.SaveChangesAsync();
            return res.Entity;
        }
View Code

 下面以部分token管理程式碼

 public async Task<string> GenerateToken(int id, string nickName, string projectGroup, string contact, string useReason)
        {
            var client = await _dbContext.Clients
                                            .Include(x => x.AllowedGrantTypes)
                                            .Include(x => x.RedirectUris)
                                            .Include(x => x.PostLogoutRedirectUris)
                                            .Include(x => x.AllowedScopes)
                                            .Include(x => x.ClientSecrets)
                                            .Include(x => x.Claims)
                                            .Include(x => x.IdentityProviderRestrictions)
                                            .Include(x => x.AllowedCorsOrigins)
                                            .Include(x => x.Properties)
                                            .FirstOrDefaultAsync(x => x.Id == id);
            if (client == null)
                throw new Exception("Client Not Exists!");
            //初始化連線IdentityServer使用者端,這也是人家封裝好的http請求
            var discoveryClient = new DiscoveryClient(_identityOption.Value.Host)
            {
                Policy = new DiscoveryPolicy { RequireHttps = _identityOption.Value.Https, ValidateIssuerName = false }
            };
            //獲取endpint
            var discoveryResponse = await discoveryClient.GetAsync();
            //連線獲取token那個endpoint
            var tokenClient = new TokenClient(discoveryResponse.TokenEndpoint,
                                                  client.ClientId,
                                                  client.ClientSecrets.FirstOrDefault().Description);
            #region 計算當前client的擁有的API資源SCOPE
            var allScopes = client.AllowedScopes.Select(p => p.Scope).ToList();
            var apiResourceScopes = new List<string>();
            _dbContext.ApiResources.Include("Scopes").ToList().ForEach(api =>
            {
                if (api.Scopes.Count > 0)
                    apiResourceScopes.AddRange(api.Scopes.Select(p => p.Name).ToList());
            });
            var inScopes = apiResourceScopes.Intersect(allScopes);
            #endregion
            var scope = string.Join(" ", inScopes);
            //請求生成使用者端模式的token
            var tokenResponse = await tokenClient.RequestCustomGrantAsync("client_credentials", scope);

            if (tokenResponse.IsError)
            {
                throw new Exception(tokenResponse.Error);
            }
            else
            {
                //傳送事件
                var _key = string.Format("{0}:reference_token", tokenResponse.AccessToken).ToSha256();
                var tokenEntity = new TokenEntity(_key, nickName, projectGroup, contact, useReason, client.ClientId, tokenResponse.AccessToken);
                await _mediator.Publish<GenerateTokenEvent>(new GenerateTokenEvent(tokenEntity));
                return tokenResponse.AccessToken;
            }
        }
View Code

下面還有一段關於修改token過期時間的程式碼

 public async Task<bool> AddExpiration(string token, DateTime date)
        {
            if (!_dbContext.TokenEntities.Any(p => p.Token == token))
                throw new Exception("manage token not exsits");
            //這個key是經過一定格式後進行sha256加密後,作為資料庫表PersistedGrants一個唯一標識
            var _key = string.Format("{0}:reference_token", token).ToSha256();
            var persistedGrant = _grantdbContext.PersistedGrants.FirstOrDefault(p => p.Key == _key);
            if (persistedGrant == null)
                throw new Exception("token不存在或者TOKEN已過期");
            var data = JObject.Parse(persistedGrant.Data);
            var creation = data.Value<DateTime>("CreationTime");
            var lifetime = data.Value<int>("Lifetime");

            data["Lifetime"] = (int)((date - creation).TotalSeconds);

            persistedGrant.Expiration = date;
            persistedGrant.Data = data.ToString(Newtonsoft.Json.Formatting.None);

            _grantdbContext.PersistedGrants.Update(persistedGrant);
            await _grantdbContext.SaveChangesAsync();
            return true;
        }
View Code

再來一段自定義授權模式程式碼

public class DelegationGrantValidator : IExtensionGrantValidator//需要繼承一下型別驗證擴充套件介面
    {
        private readonly ITokenValidator _validator;//identity框架已實現的token驗證服務,直接注入使用

        public DelegationGrantValidator(ITokenValidator validator)
        {
            _validator = validator;
        }

        public string GrantType => "delegation";//自定義的授權型別,我這實現的是一個token交換token的型別

        public async Task ValidateAsync(ExtensionGrantValidationContext context)
        {
            var userToken = context.Request.Raw.Get("token");//獲取被交換token

            if (string.IsNullOrEmpty(userToken))
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
                return;
            }

            var result = await _validator.ValidateAccessTokenAsync(userToken);//直接使用人家實現好的token驗證服務驗證傳來的token
            if (result.IsError)
            {
                context.Result = new GrantValidationResult(TokenRequestErrors.InvalidGrant);
                return;
            }

            //宣告獲取使用者,如果有使用者,說明要換取使用者token
            var sub = result.Claims.FirstOrDefault(c => c.Type == "sub")?.Value;
            if (sub != null)
            {
                context.Result = new GrantValidationResult(sub, GrantType);//換取使用者token,
                return;
            }

            // 宣告中獲取使用者端ID,如果有clientId,說明換取使用者端token
            var client_id = result.Claims.FirstOrDefault(c => c.Type == "client_id")?.Value;
            if (client_id != null)
            {
                //context.Result = new GrantValidationResult(client_id, GrantType);
                context.Result = new GrantValidationResult();//換取使用者端token
                return;
            }

            context.Result = new GrantValidationResult(TokenRequestErrors.InvalidRequest);
        }
    }

builder.AddExtensionGrantValidator<DelegationGrantValidator>();注入DI。
View Code

IdentityServer4還可以擴充套件endpoint,但是擴充套件完後,在發現檔案不顯示,但是可以作為http使用,以下程式碼截圖供參考

4.結尾

  identityServer4要寫的東西實在太多,整體的把握理解還是有一定的複雜性的,我之前公司一個pass平臺專案,是基於認證中心,其他業務系統實現快速整合,我當時負責的就是授權資源、token管理,還有對IDP的授權型別、endpoint一些擴充套件,現在總結成部落格,寫的不是很細,希望對後來者帶來一些幫助和參考意義。