4

基于SqlSugar的开发框架循序渐进介绍(6)-- 在基类接口中注入用户身份信息接口

 1 year ago
source link: https://www.cnblogs.com/wuhuacong/p/16305022.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

在基于SqlSugar的开发框架中,我们设计了一些系统服务层的基类,在基类中会有很多涉及到相关的数据处理操作的,如果需要跟踪具体是那个用户进行操作的,那么就需要获得当前用户的身份信息,包括在Web API的控制器中也是一样,需要获得对应的用户身份信息,才能进行相关的身份鉴别和处理操作。本篇随笔介绍基于Principal的用户身份信息的存储和读取操作,以及在适用于Winform程序中的内存缓存的处理方式,从而通过在基类接口中注入用户身份信息接口方式,获得当前用户的详细身份信息。

1、用户身份接口的定义和基类接口注入 

为了方便获取用户身份的信息,我们定义一个接口 IApiUserSession 如下所示。

   /// <summary>
    /// API接口授权获取的用户身份信息-接口
    /// </summary>
    public interface IApiUserSession
    {
        /// <summary>
        /// 用户登录来源渠道,0为网站,1为微信,2为安卓APP,3为苹果APP   
        /// </summary>
        string Channel { get; }

        /// <summary>
        /// 用户ID
        /// </summary>
        int? Id { get; }

        /// <summary>
        /// 用户名称
        /// </summary>
        string Name { get; }

        /// <summary>
        /// 用户邮箱(可选)   
        /// </summary>
        string Email { get; }

        /// <summary>
        /// 用户手机(可选)   
        /// </summary>
        string Mobile { get; }

        /// <summary>
        /// 用户全名称(可选)   
        /// </summary>
        string FullName { get; }

        /// <summary>
        /// 性别(可选)
        /// </summary>
        string Gender { get; }

        /// <summary>
        /// 所属公司ID(可选)   
        /// </summary>
        string Company_ID { get; }

        /// <summary>
        /// 所属公司名称(可选)   
        /// </summary>
        string CompanyName { get; }

        /// <summary>
        /// 所属部门ID(可选)   
        /// </summary>
        string Dept_ID { get; }

        /// <summary>
        /// 所属部门名称(可选)   
        /// </summary>
        string DeptName { get; }

        /// <summary>
        /// 把用户信息设置到缓存中去
        /// </summary>
        /// <param name="info">用户登陆信息</param>
        /// <param name="channel">默认为空,用户登录来源渠道:0为网站,1为微信,2为安卓APP,3为苹果APP </param>
        void SetInfo(LoginUserInfo info, string channel = null);
    }

其中的SetInfo是为了在用户身份登录确认后,便于将用户信息存储起来的一个接口方法。其他属性定义用户相关的信息。

由于这个用户身份信息的接口,我们提供给基类进行使用的,默认我们在基类定义一个接口对象,并通过提供默认的NullApiUserSession实现,便于引用对应的身份属性信息。

NullApiUserSession只是提供一个默认的实现,实际在使用的时候,我们会注入一个具体的接口实现来替代它的。

    /// <summary>
    /// 提供一个空白实现类,具体使用IApiUserSession的时候,会使用其他实现类
    /// </summary>
    public class NullApiUserSession : IApiUserSession
    {
        /// <summary>
        /// 单件实例
        /// </summary>
        public static NullApiUserSession Instance { get; } = new NullApiUserSession();

        public string Channel => null;

        public int? Id => null;

        public string Name => null;

..................

/// <summary> /// 设置信息(保留为空) /// </summary> public void SetInfo(LoginUserInfo info, string channel = null) { } }

在之前介绍的SqlSugar框架的时候,我们介绍到数据访问操作的基类定义,如下所示。

    /// <summary>
    /// 基于SqlSugar的数据库访问操作的基类对象
    /// </summary>
    /// <typeparam name="TEntity">定义映射的实体类</typeparam>
    /// <typeparam name="TKey">主键的类型,如int,string等</typeparam>
    /// <typeparam name="TGetListInput">或者分页信息的条件对象</typeparam>
    public abstract class MyCrudService<TEntity, TKey, TGetListInput> : 
        IMyCrudService<TEntity, TKey, TGetListInput>
        where TEntity : class, IEntity<TKey>, new()
        where TGetListInput : IPagedAndSortedResultRequest
    {
        /// <summary>
        /// 数据库上下文信息
        /// </summary>
        protected DbContext dbContext;

/// <summary> /// 当前Api用户信息 /// </summary> public IApiUserSession CurrentApiUser { get; set; } public MyCrudService() { dbContext = new DbContext(); CurrentApiUser = NullApiUserSession.Instance;//空实现 }

在最底层的操作基类中,我们就已经注入了用户身份信息,这样我们不管操作任何函数处理,都可以通过该用户身份信息接口CurrentApiUser获得对应的用户属性信息了。

在具体的业务服务层中,我们继承该基类,并提供构造函数注入方式,让基类获得对应的 IApiUserSession接口的具体实例。

    /// <summary>
    /// 应用层服务接口实现
    /// </summary>
    public class CustomerService : MyCrudService<CustomerInfo, string, CustomerPagedDto>, ICustomerService
    {
        /// <summary>
        /// 构造函数
        /// </summary>
        /// <param name="currentApiUser">当前用户接口</param>
        public CustomerService(IApiUserSession currentApiUser)
        {
            this.CurrentApiUser = currentApiUser;
        }
          
         ........

    }

如果有其他服务接口需要引入,那么我们继续增加其他接口注入即可。

    /// <summary>
    /// 角色信息 应用层服务接口实现
    /// </summary>
    public class RoleService : MyCrudService<RoleInfo,int, RolePagedDto>, IRoleService
    {
        private IOuService _ouService;
        private IUserService _userService;

        /// <summary>
        /// 默认构造函数
        /// </summary>
        /// <param name="currentApiUser">当前用户接口</param>
        /// <param name="ouService">机构服务接口</param>
        /// <param name="userService">用户服务接口</param>
        public RoleService(IApiUserSession currentApiUser, IOuService ouService, IUserService userService)
        {
            this.CurrentApiUser = currentApiUser;
            this._ouService = ouService;
            this._userService = userService;
        }

由于该接口是通过构造函数注入的,因此在系统运行前,我们需要往IOC容器中注册对应的接口实现类(由于IApiUserSession 提供了多个接口实现,我们这里不自动加入它的对应接口,而通过手工加入)。

在Winform或者控制台程序,启动程序的时候,手工加入对应的接口到IOC容器中即可。

/// <summary>
/// 应用程序的主入口点。
/// </summary>
[STAThread]
static void Main()
{
    // IServiceCollection负责注册
    IServiceCollection services = new ServiceCollection();
    //services.AddSingleton<IDictDataService, DictDataService>();
    
    //调用自定义的服务注册
    ServiceInjection.ConfigureRepository(services);            
    
    //添加IApiUserSession实现类
    //services.AddSingleton<IApiUserSession, ApiUserCache>(); //缓存实现方式
    services.AddSingleton<IApiUserSession, ApiUserPrincipal>(); //CurrentPrincipal实现方式

如果是Web API或者asp.net core项目中加入,也是类似的处理方式。

var builder = WebApplication.CreateBuilder(args);
//配置依赖注入访问数据库
ServiceInjection.ConfigureRepository(builder.Services);

//添加IApiUserSession实现类
builder.Services.AddSingleton<IApiUserSession, ApiUserPrincipal>();

前面介绍了,IApiUserSession的一个空白实现,是默认的接口实现,我们具体会使用基于Principal或者缓存方式实现记录用户身份的信息实现,如下是它们的类关系。

8867-20220524153746543-559367845.png

在上面的代码中,我们注入一个 ApiUserPrincipal 的用户身份接口实现。

2、基于Principal的用户身份信息的存储和读取操作

 ApiUserPrincipal 的用户身份接口实现是可以实现Web及Winform的用户身份信息的存储的。

首先我们先定义一些存储声明信息的键,便于统一处理。

    /// <summary>
    /// 定义一些常用的ClaimType存储键
    /// </summary>
    public class ApiUserClaimTypes
    {
        public const string Id = JwtClaimTypes.Id;
        public const string Name = JwtClaimTypes.Name;
        public const string NickName = JwtClaimTypes.NickName;
        public const string Email = JwtClaimTypes.Email;
        public const string PhoneNumber = JwtClaimTypes.PhoneNumber;
        public const string Gender = JwtClaimTypes.Gender;
        public const string FullName = "FullName";
        public const string Company_ID = "Company_ID";
        public const string CompanyName = "CompanyName";
        public const string Dept_ID = "Dept_ID";
        public const string DeptName = "DeptName";

        public const string Role = ClaimTypes.Role;
    }

 ApiUserPrincipal 用户身份接口实现的定义如下代码所示。

    /// <summary>
    /// 基于ClaimsPrincipal实现的用户信息接口。
    /// </summary>
    [Serializable]
    public class ApiUserPrincipal : IApiUserSession
    {
        /// <summary>
        /// IHttpContextAccessor对象
        /// </summary>
        private readonly IHttpContextAccessor _httpContextAccessor;

        /// <summary>
        /// 如果IHttpContextAccessor.HttpContext?.User非空获取HttpContext的ClaimsPrincipal,否则获取线程的CurrentPrincipal
        /// </summary>
        protected ClaimsPrincipal Principal => _httpContextAccessor?.HttpContext?.User ?? (Thread.CurrentPrincipal as ClaimsPrincipal);

        /// <summary>
        /// 默认构造函数
        /// </summary>
        /// <param name="httpContextAccessor"></param>
        public ApiUserPrincipal(IHttpContextAccessor httpContextAccessor)
        {
            _httpContextAccessor = httpContextAccessor;
        }

        /// <summary>
        /// 默认构造函数
        /// </summary>
        public ApiUserPrincipal() { }

基于Web  API的时候,用户身份信息是基于IHttpContextAccessor 注入的接口获得 httpContextAccessor?.HttpContext?.User 的 ClaimsPrincipal 属性操作的。

我们获取用户身份的属性的时候,直接通过这个属性判断获取即可。

        /// <summary>
        /// 用户ID
        /// </summary>
        public int? Id => this.Principal?.FindFirst(ApiUserClaimTypes.Id)?.Value.ToInt32();

        /// <summary>
        /// 用户名称
        /// </summary>
        public string Name => this.Principal?.FindFirst(ApiUserClaimTypes.Name)?.Value;

而上面同时也提供了一个基于Windows的线程Principal 属性(Thread.CurrentPrincipal )的声明操作,操作模型和Web 的一样的,因此Web和WinForm的操作是一样的。

在用户登录接口处理的时候,我们需要统一设置一下用户对应的声明信息,存储起来供查询使用。

        /// <summary>
        /// 主要用于Winform写入Principal的ClaimsIdentity
        /// </summary>
        public void SetInfo(LoginUserInfo info, string channel = null)
        {
            //new WindowsPrincipal(WindowsIdentity.GetCurrent());

            var claimIdentity = new ClaimsIdentity("login");
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.Id, info.ID ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.Name, info.UserName ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.Email, info.Email ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.PhoneNumber, info.MobilePhone ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.Gender, info.Gender ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.FullName, info.FullName ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.Company_ID, info.CompanyId ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.CompanyName, info.CompanyName ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.Dept_ID, info.DeptId ?? ""));
            claimIdentity.AddClaim(new Claim(ApiUserClaimTypes.DeptName, info.DeptName ?? ""));

            //此处不可以使用下面注释代码
            //this.Principal?.AddIdentity(claimIdentity);

            //Thread.CurrentPrincipal设置会导致在异步线程中设置的结果丢失
            //因此统一采用 AppDomain.CurrentDomain.SetThreadPrincipal中设置,确保进程中所有线程都会复制到信息
            IPrincipal principal = new GenericPrincipal(claimIdentity, null);
            AppDomain.CurrentDomain.SetThreadPrincipal(principal);
        }

在上面中,我特别声明“Thread.CurrentPrincipal设置会导致在异步线程中设置的结果丢失” ,这是我在反复测试中发现,不能在异步方法中设置Thread.CurrentPrincipal的属性,否则属性会丢失,因此主线程的Thread.CurrentPrincipal 会赋值替换掉异步线程中的Thread.CurrentPrincipal属性。

而.net 提供了一个程序域的方式设置CurrentPrincipal的方法,可以或者各个线程中统一的信息。

AppDomain.CurrentDomain.SetThreadPrincipal(principal);

基于WInform的程序,我们在登录界面中处理用户登录操作

8867-20220524155648117-882234787.png

 但用户确认登录的时候,测试用户的账号密码,成功则在本地设置用户的身份信息。

        /// <summary>
        /// 统一设置登陆用户相关的信息
        /// </summary>
        /// <param name="info">当前用户信息</param>
        public async Task SetLoginInfo(LoginResult loginResult)
        {
            var info = loginResult.UserInfo; //用户信息

            //获取用户的角色集合
            var roles = await BLLFactory<IRoleService>.Instance.GetRolesByUser(info.Id);
            //判断用户是否超级管理员||公司管理员
            var isAdmin = roles.Any(r => r.Name == RoleInfo.SuperAdminName || r.Name == RoleInfo.CompanyAdminName);

            //初始化权限用户信息
            Portal.gc.UserInfo = info; //登陆用户
            Portal.gc.RoleList = roles;//用户的角色集合
            Portal.gc.IsUserAdmin = isAdmin;//是否超级管理员或公司管理员
            Portal.gc.LoginUserInfo = this.ConvertToLoginUser(info); //转换为窗体可以缓存的对象

            //设置身份信息到共享对象中(Principal或者Cache) 
            BLLFactory<IApiUserSession>.Instance.SetInfo(Portal.gc.LoginUserInfo);

            await Task.CompletedTask;
        }

通过SetInfo,我们把当前用户的信息设置到了域的Principal中,进程内的所有线程共享这份用户信息数据。

跟踪接口的调用,我们可以查看到对应的用户身份信息了。

8867-20220524160102754-904264770.png

可以看到,这个接口已经注入到了服务类中,并且获得了相应的用户身份信息了。

同样在Web API的登录处理的时候,会生成相关的JWT token的信息的。

           var loginResult = await this._userService.VerifyUser(dto.LoginName, dto.Password, ip);
            if (loginResult != null && loginResult.UserInfo != null)
            {
                var userInfo = loginResult.UserInfo;

                authResult.AccessToken = GenerateToken(userInfo); //令牌
                authResult.Expires = expiredDays * 24 * 3600; //失效秒数
                authResult.Succes = true;//成功

                //设置缓存用户信息
                //SetUserCache(userInfo);
            }
            else
            {
                authResult.Error = loginResult?.ErrorMessage;
            }

其中生成的JWT token的逻辑如下所示。

        /// <summary>
        /// 生成JWT用户令牌
        /// </summary>
        /// <returns></returns>
        private string GenerateToken(UserInfo userInfo)
        {
            var claims = new List<Claim>
            {
                new Claim(ApiUserClaimTypes.Id, userInfo.Id.ToString()),
                new Claim(ApiUserClaimTypes.Email, userInfo.Email),
                new Claim(ApiUserClaimTypes.Name, userInfo.Name),
                new Claim(ApiUserClaimTypes.NickName, userInfo.Nickname),
                new Claim(ApiUserClaimTypes.PhoneNumber, userInfo.MobilePhone),
                new Claim(ApiUserClaimTypes.Gender, userInfo.Gender),

                new Claim(ApiUserClaimTypes.FullName, userInfo.FullName),
                new Claim(ApiUserClaimTypes.Company_ID, userInfo.Company_ID),
                new Claim(ApiUserClaimTypes.CompanyName, userInfo.CompanyName),
                new Claim(ApiUserClaimTypes.Dept_ID, userInfo.Dept_ID),
                new Claim(ApiUserClaimTypes.DeptName, userInfo.DeptName),
            };

            var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Secret"]));
            var jwt = new JwtSecurityToken
            (
                issuer: _configuration["Jwt:Issuer"],
                audience: _configuration["Jwt:Audience"],
                claims: claims,
                expires: DateTime.Now.AddDays(expiredDays),//有效时间
                signingCredentials: new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256)
             );
            var token = new JwtSecurityTokenHandler().WriteToken(jwt);
            return token;
        }

说生成的一系列字符串,我们可以通过解码工具,可以解析出来对应的信息的。

8867-20220524165739551-1039560322.png
8867-20220524165926593-291277863.png

在登录授权的这个时候,控制器会把相关的Claim信息写入到token中的,我们在客户端发起对控制器方法的调用的时候,这些身份信息会转换成对象信息。

我们调试控制器的方法入口,如可以通过Fiddler的测试接口的调用情况。

8867-20220524172046391-1717635673.png

可以看到CurrentApiUser的信息就是我们发起用户身份信息,如下图所示。

8867-20220524165440340-1509951806.png

在监视窗口中查看IApiUserSession对象,可以查看到对应的信息。

8867-20220524170243991-1249997375.png

3、基于内存缓存的用户身份接口实现处理方式

 在前面介绍的IApiUserSession的接口实现的时候,我们也提供了另外一个基于MemoryCache的缓存实现方式,和基于Principal凭证信息处理不同,我们这个是基于MemoryCache的存储方式。

8867-20220524153746543-559367845.png

它的实现方法也是类似的,我们这里也一并介绍一下。

    /// <summary>
    /// 基于MemeoryCache实现的用户信息接口
    /// </summary>
    public class ApiUserCache : IApiUserSession
    {
        /// <summary>
        /// 内存缓存对象
        /// </summary>
        private static readonly ObjectCache Cache = MemoryCache.Default;

        /// <summary>
        /// 默认构造函数
        /// </summary>
        public ApiUserCache()
        {
        }

        /// <summary>
        /// 把用户信息设置到缓存中去
        /// </summary>
        /// <param name="info">用户登陆信息</param>
        public void SetInfo(LoginUserInfo info, string channel = null)
        {
            SetItem(ApiUserClaimTypes.Id, info.ID);
            SetItem(ApiUserClaimTypes.Name, info.UserName);
            SetItem(ApiUserClaimTypes.Email, info.Email);
            SetItem(ApiUserClaimTypes.PhoneNumber, info.MobilePhone);
            SetItem(ApiUserClaimTypes.Gender, info.Gender);
            SetItem(ApiUserClaimTypes.FullName, info.FullName);
            SetItem(ApiUserClaimTypes.Company_ID, info.CompanyId);
            SetItem(ApiUserClaimTypes.CompanyName, info.CompanyName);
            SetItem(ApiUserClaimTypes.Dept_ID, info.DeptId);
            SetItem(ApiUserClaimTypes.DeptName, info.DeptName);
        }

        /// <summary>
        /// 设置某个属性对象
        /// </summary>
        /// <param name="key"></param>
        /// <param name="value"></param>
        private void SetItem(string key, object value)
        {
            if (!string.IsNullOrEmpty(key))
            {
                Cache.Set(key, value ?? "", DateTimeOffset.MaxValue, null);
            }
        }

        /// <summary>
        /// 用户ID
        /// </summary>
        public int? Id => (Cache.Get(ApiUserClaimTypes.Id) as string)?.ToInt32();

        /// <summary>
        /// 用户名称
        /// </summary>
        public string Name => Cache.Get(ApiUserClaimTypes.Name) as string;

        /// <summary>
        /// 用户邮箱(可选)   
        /// </summary>
        public string Email => Cache.Get(ApiUserClaimTypes.Email) as string;

         ..............
    }

我们通过 MemoryCache.Default 构造一个内存缓存的对象,然后在设置信息的时候,把用户信息按照键值方式设置即可。在Winform中我们可以采用内存缓存的方式存储用户身份信息,而基于Web方式的,则会存在并发多个用户的情况,不能用缓存来处理。

一般情况下,我们采用 ApiUserPrincipal 来处理用户身份信息就很好了。

4、单元测试的用户身份处理

在做单元测试的时候,我们如果需要设置测试接口的用户身份信息,那么就需要在初始化函数里面设置好用户信息,如下所示。

    [TestClass]
    public class UnitTest1
    {
        private static IServiceProvider Provider = null;

        /*
        带有[ClassInitialize()] 特性的方法在执行类中第一个测试之前调用。
        带有[TestInitialize()] 特性的方法在执行每个测试前都会被调用,一般用来初始化环境,为单元测试配置一个特定已知的状态。
        带有[ClassCleanup()] 特性的方法将在类中所有的测试运行完后执行。
        */
        //[TestInitialize] //每个测试前调用
        [ClassInitialize] //测试类第一次调用
        public static void Setup(TestContext context)
        {
            // IServiceCollection负责注册
            IServiceCollection services = new ServiceCollection();
            //调用自定义的服务注册
            ServiceInjection.ConfigureRepository(services);

            //注入当前Api用户信息处理实现,服务对象可以通过IApiUserSession获得用户信息
            //services.AddSingleton<IApiUserSession, ApiUserCache>(); //缓存实现方式
            services.AddSingleton<IApiUserSession, ApiUserPrincipal>(); //CurrentPrincipal实现方式

            // IServiceProvider负责提供实例
            Provider = services.BuildServiceProvider();

            //模拟写入登录用户信息
            WriteLoginInfo();
        }

        /// <summary>
        /// 写入用户登陆信息,IApiUserSession接口才可使用获取身份
        /// </summary>
        static void WriteLoginInfo()
        {
            var mockUserInfo = new LoginUserInfo()
            {
                ID = "1",
                Email = "[email protected]",
                MobilePhone = "18620292076",
                UserName = "admin",
                FullName = "伍华聪"
            };

            //通过使用全局IServiceProvider的接口获得服务接口实例
            Provider.GetService<IApiUserSession>().SetInfo(mockUserInfo);
        }

上面的方法初始化了测试类的信息,方法调用的时候,我们获得对应的接口实例处理即可,如下测试代码所示。

        /// <summary>
        /// 测试查找记录
        /// </summary>
        /// <returns></returns>
        [TestMethod]
        public async Task TestMethod1()
        {
            var input = new DictTypePagedDto()
            {
                Name = "客户"
            };

            var service = Provider.GetService<IDictTypeService>();
            var count = await service.CountAsync(s => true);
            Assert.AreNotEqual(0, count);

            var list = await service.GetAllAsync();
            Assert.IsNotNull(list);
            Assert.IsNotNull(list.Items);
            Assert.IsTrue(list.Items.Count > 0);

            list = await service.GetListAsync(input);
            Assert.IsNotNull(list);
            Assert.IsNotNull(list.Items);
            Assert.IsTrue(list.Items.Count > 0);

            var ids = list.Items.Select(s => { return s.Id; }).Take(2);
            list = await service.GetAllByIdsAsync(ids);
            Assert.IsNotNull(list);
            Assert.IsNotNull(list.Items);
            Assert.IsTrue(list.Items.Count > 0);


            var id = list.Items[0].Id;
            var info = await service.GetAsync(id);
            Assert.IsNotNull(info);
            Assert.AreEqual(id, info.Id);

            info = await service.GetFirstAsync(s => true);
            Assert.IsNotNull(info);

            await Task.CompletedTask;
        }

系列文章:

基于SqlSugar的开发框架的循序渐进介绍(1)--框架基础类的设计和使用

基于SqlSugar的开发框架循序渐进介绍(2)-- 基于中间表的查询处理

基于SqlSugar的开发框架循序渐进介绍(3)-- 实现代码生成工具Database2Sharp的整合开发

基于SqlSugar的开发框架循序渐进介绍(4)-- 在数据访问基类中对GUID主键进行自动赋值处理 

基于SqlSugar的开发框架循序渐进介绍(5)-- 在服务层使用接口注入方式实现IOC控制反转

 《基于SqlSugar的开发框架循序渐进介绍(6)-- 在基类接口中注入用户身份信息接口 


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK