在基于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實作類
前面介紹了,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
基于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
{
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]",
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;
}