在基於SqlSugar的開發框架中,我們設計了一些系統服務層的基類,在基類中會有很多涉及到相關的數據處理操作的,如果需要跟蹤具體是那個用戶進行操作的,那麼就需要獲得當前用戶的身份信息,包括在Web API的控制器中也是一樣,需要獲得對應的用戶身份信息,才能進行相關的身份鑒別和處理操作。本篇隨筆介紹基... ...
在基於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或者緩存方式實現記錄用戶身份的信息實現,如下是它們的類關係。
在上面的代碼中,我們註入一個 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的程式,我們在登錄界面中處理用戶登錄操作
但用戶確認登錄的時候,測試用戶的賬號密碼,成功則在本地設置用戶的身份信息。
/// <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中,進程內的所有線程共用這份用戶信息數據。
跟蹤介面的調用,我們可以查看到對應的用戶身份信息了。
可以看到,這個介面已經註入到了服務類中,並且獲得了相應的用戶身份信息了。
同樣在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; }
說生成的一系列字元串,我們可以通過解碼工具,可以解析出來對應的信息的。
在登錄授權的這個時候,控制器會把相關的Claim信息寫入到token中的,我們在客戶端發起對控制器方法的調用的時候,這些身份信息會轉換成對象信息。
我們調試控制器的方法入口,如可以通過Fiddler的測試介面的調用情況。
可以看到CurrentApiUser的信息就是我們發起用戶身份信息,如下圖所示。
在監視視窗中查看IApiUserSession對象,可以查看到對應的信息。
3、基於記憶體緩存的用戶身份介面實現處理方式
在前面介紹的IApiUserSession的介面實現的時候,我們也提供了另外一個基於MemoryCache的緩存實現方式,和基於Principal憑證信息處理不同,我們這個是基於MemoryCache的存儲方式。
它的實現方法也是類似的,我們這裡也一併介紹一下。
/// <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]