Asp.Net Core 中IdentityServer4 授权中心之应用实战

2022-04-07 14:09:17 浏览数 (1)

一、前言

查阅了大多数相关资料,搜索到的IdentityServer4 的应用文章大多是比较简单并且多是翻译官网的文档编写的,我这里在 Asp.Net Core 中IdentityServer4 的应用分析中会以一个电商系统架构升级过程中普遍会遇到的场景进行实战性讲述分析,同时最后会把我的实战性的代码放到github 上,敬请大家关注!

这里就直接开始撸代码,概念性东西就已经不概述了,想要了解概念推荐大家查看我之前的文章和官方文档:

  • Asp.Net Core IdentityServer4 中的基本概念
  • IdentityServer4 官方文档

二、应用实战

2.1 模拟场景

最初小团队的电商系统场景如下图:

这张架构图缺点:

•发布频繁,发布影响整个电商系统•很难做到敏捷开发•维护性可能会存在一定的弊端,主要看内部架构情况。

大多数小电商团队对于多客户端登录授权来说可能已经实现了Oauth 2.0 的身份授权验证,但是是和电商业务集成在一个网关里面,这样不是很好的方式;由于公司业务横向扩大,产品经理调研了代理商业务,最终让技术开发代理商业务系统。架构师出于后续发展的各方面考虑,把代理商业务单独建立了一个独立的网关,并且把授权服务一并给独立出来,调整后的电商系统架构图如下:

身份授权从业务系统中拆分出来后,有了如下的优势:

•授权服务不受业务的影响,如果业务网关宕机了,那至少不会影响代理商网关的业务授权系统的使用•授权服务一旦建立,一般就很难进行升级,除非特殊情况。•在敏捷开发中,业务系统可能发布频繁,电商业务系统可能每天都是在频繁升级更新,这样也不至于影响了授权系统服务导致代理商业务受到影响

代理商业务引入进来后,同时又增加了秒杀活动,发现成交量大大增大,支付订单集中在某一时刻翻了十几倍,这时候整个电商业务API网关已经扛不住了,负载了几台可能也有点吃力;开发人员经过跟架构师一起讨论,得出了扛不住的原因:主要是秒杀活动高并发的支付,以至于整个电商业务系统受到影响,故准备把支付系统从业务系统中拆分出成独立的支付网关,并做了一定的负载,成功解决了以上问题,这时候整个电商系统架构图就演变成如下:

支付网关服务抽离后的优势:

•支付网关服务更新不会太频繁,可以减少整个系统的因为发布导致的一系列问题,增强稳定性•支付系统出现宕机不影响整个电商系统的使用,用户还可以浏览商品等等其他操作,技术和运维人员也比较好排查定位问题所在;提升用户体验,同时提升排查问题的效率。授权中心:单独一个服务网关,访问支付业务网关电商业务网关代理商业务网关都需要先通过授权中心获得授权拿到访问令牌AccessToken 才能正常的访问这些网关,这样授权模块就不会受任何的业务影响,同时各个业务网关也不需要写同样的授权业务的代码;业务网关仅仅只需关注本身的业务即可,授权中心仅仅只需要关注维护授权;经过这样升级改造后整个系统维护性得到很大的提高,相关的业务也可以针对具体情况进行选择性的扩容。

上面的电商网关演变架构图中我这里没有画出具体的请求流向,偷了个赖,这里还是先把OAuth2.0 的授权大体的流程图单独贴出来:

由于授权网关服务之前单独抽离出来了,这次把支付业务网关拆分出来就也比较顺利,一下子就完成了电商系统的架构升级。今天这篇文章的目的架构升级也就完成了,想要深入后续电商系统架构升级的同学可以关注后续给大家带来的微服务的相关分享,到时继续以这个例子来进行微服务架构上的演变升级,敬请大家关注。好了下面我们来回归该升级的和核心主题授权中心 IdentityServer4 的应用。

2.2 IdentityServer4 密码授权模式

授权网关服务

静态内存配置方式

定义资源

分资源分为身份资源(Identity resources)和API资源(API resources)。

我们先创建Jlion.NetCore.Identity.Service 网关服务,在网关服务中添加受保护的API资源,创建OAuthMemoryData 类代码如下:

代码语言:javascript复制
/// <summary>
/// Api资源 静态方式定义
/// </summary>
/// <returns></returns>
public static IEnumerable<ApiResource> GetApiResources()
{
       return new List<ApiResource>
       {
            new ApiResource(OAuthConfig.UserApi.ApiName,OAuthConfig.UserApi.ApiName),
       };
}

定义客户端Client

在OAuthMemoryData 类中定义一个客户端应用程序的Client,我们将使用它来访问我们的API资源代码如下:

代码语言:javascript复制
public static IEnumerable<Client> GetClients()
{
       return new List<Client>
       {
           new Client()
           {
               ClientId =OAuthConfig.UserApi.ClientId,
               AllowedGrantTypes = new List<string>()
               {
                   GrantTypes.ResourceOwnerPassword.FirstOrDefault(),//Resource Owner Password模式
               },
               ClientSecrets = {new Secret(OAuthConfig.UserApi.Secret.Sha256()) },
               AllowedScopes= {OAuthConfig.UserApi.ApiName},
               AccessTokenLifetime = OAuthConfig.ExpireIn,
           },
      };
 }

AllowedGrantTypes :配置授权类型,可以配置多个授权类型•ClientSecrets:客户端加密方式•AllowedScopes:配置授权范围,这里指定哪些API 受此方式保护•AccessTokenLifetime:配置Token 失效时间•GrantTypes:授权类型,这里使用的是密码模式ResourceOwnerPassword

代码中可以看到有一个OAuthConfig 类,这个类是我单独建的,是用于统一管理,方便维护,代码如下:

代码语言:javascript复制
 public class OAuthConfig
 {
        /// <summary>
        /// 过期秒数
        /// </summary>
        public const int ExpireIn = 36000;

        /// <summary>
        /// 用户Api相关
        /// </summary>
        public static class UserApi
        {
            public static string ApiName = "user_api";

            public static string ClientId = "user_clientid";

            public static string Secret = "user_secret";
        }
 }

如果后续架构升级,添加了其他的网关服务,则只需要在这里添加所需要保护的API 资源,也可以通过读取数据库方式读取受保护的Api资源。

接下来OAuthMemoryData 类添加测试用户,代码如下:

代码语言:javascript复制
/// <summary>
/// 测试的账号和密码
/// </summary>
/// <returns></returns>
public static List<TestUser> GetTestUsers()
{
    return new List<TestUser>
    {
        new TestUser()
        {
             SubjectId = "1",
             Username = "test",
             Password = "123456"
        }
    };
}

上面受保护的资源,和客户端以及测试账号都已经建立好了,现在需要把IdentityServer4 注册到DI中: Startup 中的ConfigureServices 代码如下:

代码语言:javascript复制
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    #region 内存方式
    services.AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryApiResources(OAuthMemoryData.GetApiResources())
        .AddInMemoryClients(OAuthMemoryData.GetClients())
        .AddTestUsers(OAuthMemoryData.GetTestUsers());
    #endregion

}

代码解读:

AddDeveloperSigningCredential:添加证书加密方式,执行该方法,会先判断tempkey.rsa证书文件是否存在,如果不存在的话,就创建一个新的tempkey.rsa证书文件,如果存在的话,就使用此证书文件。•AddInMemoryApiResources:把受保护的Api资源添加到内存中•AddInMemoryClients :客户端配置添加到内存中•AddTestUsers :测试的用户添加进来

最后通过UseIdentityServer()需要把IdentityServer4 中间件添加到Http管道中,代码如下:

代码语言:javascript复制
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
     if (env.IsDevelopment())
     {
         app.UseDeveloperExceptionPage();
     }

     app.UseIdentityServer();

     app.UseRouting();

     app.UseAuthorization();

     app.UseEndpoints(endpoints =>
     {
        endpoints.MapControllers();
     });
}

好了,现在授权网关服务代码已经完成,现在直接通过命令行方式启动,命令行启动如下,我指定5000端口,如下图:

电商用户网关Api项目

现在我来新建一个WebApi 大的用户网关服务项目,取名为Jlion.NetCore.Identity.UserApiService,新建后会默认有一个天气预报的api接口,代码如下:

代码语言:javascript复制
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };

    private readonly ILogger<WeatherForecastController> _logger;

    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }

    [HttpGet]
    public IEnumerable<WeatherForecast> Get()
    {
        var rng = new Random();
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

接下来在Startup 类中添加授权网关服务的配置到DI中,代码如下:

代码语言:javascript复制
 public void ConfigureServices(IServiceCollection services)
 {
       services.AddControllers();

       services.AddAuthorization();
       services.AddAuthentication("Bearer")
           .AddIdentityServerAuthentication(options =>
           {
               options.Authority = "http://localhost:5000";    //配置Identityserver的授权地址
               options.RequireHttpsMetadata = false;           //不需要https    
               options.ApiName = OAuthConfig.UserApi.ApiName;  //api的name,需要和config的名称相同
           });
  }

这里的options.ApiName 需要和网关服务中的Api 资源配置中的ApiName 一致

接下来需要把授权和认证中间件分别注册到Http 管道中,代码如下:

代码语言:javascript复制
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }


    app.UseRouting();

    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

现在授权服务网关启用已经完成,只需要在需要保护的Controller 中添加 Authorize 过滤器即可,现在我也通过命令行把需要保护的网关服务启动,如图:

现在我通过postman 工具来单独访问 用户网关服务API,不携带任何信息的情况下,如图:

从访问结果可以看出返回401 Unauthorized 未授权。

我们接下来再来访问授权服务网关,如图:

请求网关服务中body中携带了用户名及密码等相关信息,这是返回了access_token 及有效期等相关信息,我们再拿access_token 来继续上面的操作,访问用户业务网关的接口,如图:

访问结果中已经返回了我们所需要的接口数据,大家目前已经对密码模式的使用有了一定的了解,但是这时候可能会有人问我,我生产环境中可能需要通过数据库的方式进行用户信息的判断,以及客户端授权方式需要更加灵活的配置,可通过后台来配置ClientId以及授权方式等,那应该怎么办呢?下面我再来给大家带来生存环境中的实现方式。

数据库匹配验证方式

我们需要通过用户名和密码到数据库中验证方式则需要实现IResourceOwnerPasswordValidator 接口,并实现ValidateAsync 验证方法,简单的代码如下:

代码语言:javascript复制
public class ResourceOwnerPasswordValidator : IResourceOwnerPasswordValidator
{
    public async Task ValidateAsync(ResourceOwnerPasswordValidationContext context)
    {
        try
        {
            var userName = context.UserName;
            var password = context.Password;

            //验证用户,这么可以到数据库里面验证用户名和密码是否正确
            var claimList = await ValidateUserAsync(userName, password);

            // 验证账号
            context.Result = new GrantValidationResult
            (
                subject: userName,
                authenticationMethod: "custom",
                claims: claimList.ToArray()
             );
       }
       catch (Exception ex)
       {
            //验证异常结果
            context.Result = new GrantValidationResult()
            {
                IsError = true,
                Error = ex.Message
             };
       }
  }

    #region Private Method
    /// <summary>
    /// 验证用户
    /// </summary>
    /// <param name="loginName"></param>
    /// <param name="password"></param>
    /// <returns></returns>
    private async Task<List<Claim>> ValidateUserAsync(string loginName, string password)
    {
        //TODO 这里可以通过用户名和密码到数据库中去验证是否存在,
        // 以及角色相关信息,我这里还是使用内存中已经存在的用户和密码
        var user = OAuthMemoryData.GetTestUsers();

        if (user == null)
            throw new Exception("登录失败,用户名和密码不正确");

        return new List<Claim>()
        {
            new Claim(ClaimTypes.Name, $"{loginName}"),
        };
    }
    #endregion
}

用户密码验证器已经实现完成,现在需要把之前的通过AddTestUsers 方式改成AddResourceOwnerValidator<ResourceOwnerPasswordValidator>() 方式,修改后的代码如下:

代码语言:javascript复制
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    #region 数据库存储方式
    services.AddIdentityServer()
        .AddDeveloperSigningCredential()
        .AddInMemoryApiResources(OAuthMemoryData.GetApiResources())
        .AddInMemoryClients(OAuthMemoryData.GetClients())
        //.AddTestUsers(OAuthMemoryData.GetTestUsers());
        .AddResourceOwnerValidator<ResourceOwnerPasswordValidator>();
   #endregion
}

目前已经实现了用户名和密码数据库验证的方式,但是现在有人会考虑另外一个场景,客户端的授权方式等也需要通过后台可配置的方式,这样比较灵活,不通过代码中静态配置的方式,那应该这么办呢?官方考虑的很周到,我们可以使用IClientStore 接口,同时需要实现FindClientByIdAsync 方法,代码如下:

代码语言:javascript复制
public class ClientStore : IClientStore
{
    public async Task<Client> FindClientByIdAsync(string clientId)
    {
        #region 用户名密码
        var memoryClients = OAuthMemoryData.GetClients();
        if (memoryClients.Any(oo => oo.ClientId == clientId))
        {
           return memoryClients.FirstOrDefault(oo => oo.ClientId == clientId);
        }
        #endregion

        #region 通过数据库查询Client 信息
        return GetClient(clientId);
        #endregion
    }

    private Client GetClient(string client)
    {
        //TODO 根据数据库查询
        return null;
    }
}

StartupConfigureServices 代码AddInMemoryClients 改成AddClientStore<> 代码如下:

代码语言:javascript复制
public void ConfigureServices(IServiceCollection services)
{
     services.AddControllers();

     #region 数据库存储方式
     services.AddIdentityServer()
         .AddDeveloperSigningCredential()
         .AddInMemoryApiResources(OAuthMemoryData.GetApiResources())
         //.AddInMemoryClients(OAuthMemoryData.GetClients())
         .AddClientStore<ClientStore>()
         .AddResourceOwnerValidator<ResourceOwnerPasswordValidator>();
    #endregion
 }

好了数据库查询匹配方式也已经改造完了,业务网关服务不需要改动如何代码,运行结果这里就不在运行演示了。Demo 代码已经上传到github 上了,github 源代码地址:https://github.com/a312586670/IdentityServerDemo

结语:通过IdentityServer4 实现的简单授权中心的架构思想也就完成了,其实这里还缺少一个比较重要的东西,后续再统一补充分享。有错误地方还请留言指出!感谢!!!

0 人点赞