ASP.NET Core 身份驗證及鑒權
目錄
環境
- VS 2017
- ASP.NET Core 2.2
目標
以相對簡單優雅的方式實現用戶身份驗證和鑒權,解決以下兩個問題:
- 無狀態的身份驗證服務,使用請求頭附加訪問令牌,幾乎適用於手機、網頁、桌面應用等所有客戶端
- 基於功能點的權限訪問控制,可以將任意功能點權限集合授予用戶或角色,無需硬編碼角色權限,非常靈活
項目准備
-
創建一個ASP.NET Core Web應用程序
- 使用ASP.NET Core 2.2
- 模板選[空]
- 不啟用HTTPS
- 不進行身份驗證
-
通過NuGet安裝
Swashbuckle.AspNetCore程序包,並在Startup類中啟用Swagger支持因為這個示例項目不打算編寫前端網頁,所以直接使用Swagger來調試,真的很方便。
-
添加一個空的MVC控制器(HomeController)和一個空的API控制器(AuthController)
HomeController.Index()方法中只寫一句簡單的跳轉代碼即可:return new RedirectResult("~/swagger");AuthController類中隨便寫一兩個骨架方法,方便看效果。 -
運行項目,會自動打開瀏覽器並跳轉到Swagger頁面。
身份驗證
定義基本類型和接口
-
ClaimTypes 定義一些常用的聲明類型常量
-
IClaimsSession 表示當前會話信息的接口
-
ClaimsSession 會話信息實現類
根據聲明類型從ClaimsPrincipal.ClaimsIdentity屬性中讀取用戶ID、用戶名等信息。實際項目中可從此類繼承或完全重新實現自己的Session類,以添加更多的會話信息(例如工作部門)
-
IToken 登錄令牌接口
包含訪問令牌、刷新令牌、令牌時效等令牌 -
IIdentity 身份證明接口
包含用戶基本信息及令牌信息 -
IAuthenticationService 驗證服務接口
抽象出來的驗證服務接口,僅規定了四個身份驗證相關的方法,如需擴展可定義由此接口派生的接口。方法名 返回值類型 說明 Login(userName, password) IIdentity 根據用戶名及密碼驗證其身份,成功則返回身份證明 Logout() void 注銷本次登錄,即使未登錄也不報錯 RefreshToken(refreshToken) Token 刷新登錄令牌,如果當前用戶未登錄則報錯 ValidateToken(accessToken) IIdentity 驗證訪問令牌,成功則返回身份證明 -
SimpleToken 登錄令牌的簡化實現
這個類提不提供都可以,實際項目中大家生成Token的算法肯定是各不相同的,提供簡單實現僅用於演示
編寫驗證處理器
-
BearerDefaults 定義了一些與身份驗證相關的常量
如:AuthenticationScheme
-
BearerOptions 身份驗證選項類
從
AuthenticationSchemeOptions繼承而來 -
BearerValidatedContext 驗證結果上下文
-
BearerHandler 身份驗證處理器 <= 關鍵類
覆蓋了
HandleAuthenticateAsync()方法,實現自定義的身份驗證邏輯,簡述如下:-
獲取訪問令牌。從請求頭中獲取
authorization信息,如果沒有則從請求的參數中獲取 -
如果訪問令牌為空,則終止驗證,但不報錯,直接返回
AuthenticateResult.NoResult() -
調用從構造函數注入的
IAuthenticationService實例的ValidateToken()方法,驗證訪問令牌是否有效,如果該方法觸發異常(例如令牌過期)則捕獲后通過AuthenticateResult.Fail()返回錯誤信息,如果該方法返回值為空(例如訪問令牌根本不存在)則返回AuthenticateResult.NoResult(),不報錯。 -
到這一步說明身份驗證已經通過,而且拿到身份證明信息,根據該信息創建
Claim數組,然后再創建一個包含這些Claim數據的ClaimsPrincipal實例,並將Thread.CurrentPrincipal設置為該實例。重點:其實,
HttpContext.User屬性的類型正是CurrentPrincipal,而其值應該就是來自於Thread.CurrentPrincipal。 -
構造
BearerValidatedContext實例,並將其Principal屬性賦值為上面創建的ClaimsPrincipal實例,然后調用Success()方法,表示驗證成功。最后返回該實例的Result屬性值。
-
-
BearerExtensions 包含一些擴展方法,提供使用便利
重點在於
AddBearer()方法內調用builder.AddScheme<TOptions,THandler>()泛型方法時,分別使用了前面編寫的BearerOptions、BearerHandler類作為泛型參數。public static AuthenticationBuilder AddBearer(...) { return builder.AddScheme<BearerOptions, BearerHandler>(...); }如果想要自己實現
BearerHandler類的驗證邏輯,可以拋棄此類,重新編寫使用新Handler類的擴展方法
實現用戶身份驗證
說明
這部分是身份驗證的落地,實際項目中應該將上面兩步(定義基本類型和接口、編寫驗證處理器)的代碼抽象出來,成為獨立可復用的軟件包,利用該軟件包進行身份驗證的實現邏輯可參照此示例代碼。
實現步驟
-
Identity 身份證明實現類
-
SampleAuthenticationService 驗證服務的簡單實現
出於演示方便,固化了三個用戶(admin/123456、user/123、tester/123)
-
AuthController 通過HTTP向前端提供驗證服務的控制器類
提供了用戶登錄、令牌刷新、令牌驗證等方法。
-
還需要修改項目中
Startup.cs文件,添加依賴注入規則、身份驗證,並啟用身份驗證中間件。
在ConfigureServices方法內添加代碼://添加依賴注入規則 services.AddScoped<IClaimsSession, ClaimsSession>(); services.AddScoped<IAuthenticationService, SampleAuthenticationService>(); //添加身份驗證 services.AddAuthentication(options => { options.DefaultAuthenticateScheme = BearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = BearerDefaults.AuthenticationScheme; }).AddBearer();在
Configure()方法內添加代碼://啟用身份驗證中間件 app.UseAuthentication();
通過Swagger測試
-
測試登錄功能
啟動項目,自動進入[Swagger UI]界面,點擊
/api/Auth/Login方法,不修改輸入框中的內容直接點擊[Execute]按鈕,可以見到返回401錯誤碼。在輸入框中輸入
{"userName": "admin", "password": "123456"},然后點擊[Execute]按鈕,系統驗證成功並返回身份證明信息。

記下訪問令牌2ad43df2c11d48a18a88441adbf4994a和刷新令牌9bbaf811ed8b4d29b638777d4f89238e
-
測試刷新登錄令牌
點擊
/api/Auth/Refresh方法,在輸入框中輸入上面獲取到的刷新令牌9bbaf811ed8b4d29b638777d4f89238e,然后點擊[Execute]按鈕,返回401錯誤碼。原因是因為我們並未提供訪問令牌。點擊方法名右側的[鎖]圖標,在彈出框中輸入之前獲取的訪問令牌
2ad43df2c11d48a18a88441adbf4994a並點擊[Authorize]按鈕后關閉對話框,重新點擊[Execute]按鈕,成功獲取到新的登錄令牌。

-
測試驗證訪問令牌
點擊
/api/Auth/Validate方法,在輸入框中輸入第一次獲取的到訪問令牌2ad43df2c11d48a18a88441adbf4994a,然后點擊[Execute]按鈕,返回400錯誤碼,表明發起的請求參數有誤。因為此方法是支持匿名訪問的,所以錯誤碼不會是401.將輸入框內容修改為新的訪問令牌
f37542e162ed4855921ddf26b05c3f25,然后點擊[Execute]按鈕,驗證成功,返回了對應的用戶身份證明信息。

權限鑒定
在ASP.NET Core項目中實現基於角色的授權很容易,在一些權限管理並不復雜的項目中,采取這種方式來實現權限鑒定簡單可行。有興趣可以參考這篇博文ASP.NET Core 認證與授權5:初識授權
但是,對於稍微復雜一些的項目,權限划分又細又多,如果采用這種方式,要覆蓋到各種各樣的權限組合,需要在代碼中定義相當多的角色,大大增加項目維護工作,並且很不靈活。
這里借鑒ABP框架中權限鑒定的一些思想,來實現基於功能點的權限訪問控制。
非常感謝ASP.NET Core和ABP等諸多優秀的開源項目,向你們致敬!
不得不說ABP框架非常優秀,但是我並不喜歡使用它,因為我沒有能力和精力搞清楚它的詳細設計思路,而且很多功能我根本不需要。
思路
ASP.NET Core提供了一個IAuthorizationFilter接口,如果在控制器類上添加[授權過濾]特性,相應的AuthorizationFilter類的OnAuthorization()方法會在控制器的Action之前運行,如果在該方法中設置AuthorizationFilterContext.Result為一個錯誤的response,Action將不會被調用。
基於這個思路,我們設計了以下方案:
-
編寫一個Attribute(特性)類,包含以下兩個屬性:
Permissions:需要檢查的權限數組
RequireAllPermissions:是否需要擁有數組中全部權限,如果為否則擁有任一權限即可
-
定義一個
IPermissionChecker接口,在接口中定義IsGrantedAsync()方法,用於執行權限鑒定邏輯 -
編寫一個AuthorizationFilterAttribute特性類(應用目標為class),通過屬性注入
IPermissionChecker實例。然后在OnAuthorization()方法內調用IPermissionChecker實例的IsGrantedAsync()方法,如果該方法返回值為false,則返回403錯誤,否則正常放行。
編寫過濾器類及相關接口
-
ApiAuthorizeAttribute類
[AttributeUsage(AttributeTargets.Method)] public class ApiAuthorizeAttribute : Attribute, IFilterMetadata { public string[] Permissions { get; } public bool RequireAllPermissions { get; set; } public ApiAuthorizeAttribute(params string[] permissions) { Permissions = permissions; } } -
IPermissionChecker接口定義
public interface IPermissionChecker { Task<bool> IsGrantedAsync(string permissionName); } -
AuthorizationFilterAttribute類
[AttributeUsage(AttributeTargets.Class)] public class AuthorizationFilterAttribute : Attribute, IAuthorizationFilter { [Injection] //屬性注入 public IPermissionChecker PermissionChecker { get; set; } = NullPermissionChecker.Instance; public void OnAuthorization(AuthorizationFilterContext context) { if(存在[AllowAnonymous]特性) return; var authorizeAttribute = 從context.Filters中析出ApiAuthorizeAttribute foreach (var permission in authorizeAttribute.Permissions) { //檢查各項權限 var granted = PermissionChecker.IsGrantedAsync(permission).Result; } if(檢查未通過) context.Result = new ObjectResult("未授權") { StatusCode = 403 }; } } -
配合屬性注入提供NullPermissionChecker類,在
IsGrantedAsync()方法內直接返回true。
實現屬性注入
做好上面的准備,我們應該可以開始着手在項目內應用權限鑒定功能了,不過ASP.NET Core內置的DI框架並不支持屬性注入,所以還得添加屬性注入的功能。
-
定義InjectionAttribute類,用於顯式聲明應用了此特性的屬性將使用依賴注入
/// <summary> /// 在屬性上添加此特性,以聲明該屬性需要使用依賴注入 /// </summary> [AttributeUsage(AttributeTargets.Property)] public class InjectionAttribute : Attribute { } -
添加一個
PropertiesAutowiredFilterProvider類,從DefaultFilterProvider類派生public class PropertiesAutowiredFilterProvider : DefaultFilterProvider { private static IDictionary<string, IEnumerable<PropertyInfo>> _publicPropertyCache = new Dictionary<string, IEnumerable<PropertyInfo>>(); public override void ProvideFilter(FilterProviderContext context, FilterItem filterItem) { base.ProvideFilter(context, filterItem); //在調用基類方法之前filterItem變量不會有值 var filterType = filterItem.Filter.GetType(); if (!_publicPropertyCache.ContainsKey(filterType.FullName)) { var ps=filterType.GetProperties(BindingFlags.Public|BindingFlags.Instance) .Where(c => c.GetCustomAttribute<InjectionAttribute>() != null); _publicPropertyCache[filterType.FullName] = ps; } var injectionProperties = _publicPropertyCache[filterType.FullName]; if (injectionProperties?.Count() == 0) return; //下面是注入屬性實例的關鍵代碼 var serviceProvider = context.ActionContext.HttpContext.RequestServices; foreach (var item in injectionProperties) { var service = serviceProvider.GetService(item.PropertyType); if (service == null) { throw new InvalidOperationException($"Unable to resolve service for type '{item.PropertyType.FullName}' while attempting to activate '{filterType.FullName}'"); } item.SetValue(filterItem.Filter, service); } } } -
還有非常關鍵的一步,在
Startup.ConfigureServices()中添加下面的代碼,替換IFilterProvider接口的實現類為上面編寫的PropertiesAutowiredFilterProvider類services.Replace(ServiceDescriptor.Singleton<Microsoft.AspNetCore.Mvc.Filters.IFilterProvider, PropertiesAutowiredFilterProvider>());
實現用戶權限鑒定
終於,我們可以在項目內應用權限鑒定功能了。
編碼
-
首先,我們定義一些功能點權限常量
public static class PermissionNames { public const string TestAdd = "Test.Add"; public const string TestEdit = "Test.Edit"; public const string TestDelete = "Test.Delete"; } -
接着,添加一個新的用於測試的控制器類
[AuthorizationFilter] [Route("api/[controller]")] [ApiController] public class TestController : ControllerBase { [Injection] public IClaimsSession Session { get; set; } [HttpGet] [Route("[action]")] public IActionResult CurrentUser() => Ok(Session?.UserName); [ApiAuthorize] [HttpGet("{id}")] public IActionResult Get(int id)=> Ok(id); [ApiAuthorize(PermissionNames.TestAdd)] [HttpPost] [Route("[action]")] public IActionResult Create()=> Ok(); [ApiAuthorize(PermissionNames.TestEdit, RequireAllPermissions = false)] [HttpPost] [Route("[action]")] public IActionResult Update()=> Ok(); [ApiAuthorize(PermissionNames.TestAdd, PermissionNames.TestEdit, RequireAllPermissions = false)] [HttpPost] [Route("[action]")] public IActionResult Patch() => Ok(); [ApiAuthorize(PermissionNames.TestDelete)] [HttpDelete("{id}")] public IActionResult Delete(int id) => Ok(); }在控制器類上添加了[AuthorizationFilter]特性,除了
CurrentUser()方法以外,都添加了[ApiAuthorize]特性,所需的權限各不相同,為簡化測試所有的Action都直接返回OkResult。 -
實現一個用於演示的權限檢查器類
public class SamplePermissionChecker : IPermissionChecker { private readonly Dictionary<long, string[]> userPermissions = new Dictionary<long, string[]> { //Id=1的用戶具有Test模塊的全部功能 { 1, new[] { PermissionNames.TestAdd, PermissionNames.TestEdit, PermissionNames.TestDelete } }, //Id=2的用戶具有Test模塊的編輯和刪除功能 { 2, new[] { PermissionNames.TestEdit, PermissionNames.TestDelete } } }; public IClaimsSession Session { get; } //通過構造函數注入IClaimsSession實例,以便在權限鑒定方法中獲取用戶信息 public SamplePermissionChecker(IClaimsSession session) { this.Session = session; } public Task<bool> IsGrantedAsync(string permissionName) { if(!userPermissions.Any(p => p.Key == Session.UserId)) return Task.FromResult(false); var up = userPermissions.Where(p => p.Key == Session.UserId).First(); var granted = up.Value.Any(permission => permission.Equals(permissionName, StringComparison.InvariantCultureIgnoreCase)); return Task.FromResult(granted); } } -
最后還需要修改項目中
Startup.cs文件,添加依賴注入規則services.AddSingleton<IPermissionChecker, SamplePermissionChecker>();因為SamplePermissionChecker類中並沒有需要進程間隔離的數據,所以使用單例模式注冊就可以了。不過這樣一來,因為該類通過構造函數注入了
IClaimsSession接口實例,在構建Checker類實例時將觸發異常。考慮到CliamsSession類中只有方法沒有數據 ,改為單例也並無妨,於是將該接口也改為單例模式注冊。
通過Swagger測試
-
測試未登錄時僅可訪問
/api/Test/CurrentUser -
測試以用戶user登錄,可以訪問
/api/Test/CurrentUser和GET請求/api/Test/{id} -
測試以用戶admin登錄,可以訪問除
/api/Test/Add以外的接口
測試
編寫了命令行程序,用來測試前面實現的Web API服務。
測試不同用戶同時訪問時Session是否正確
-
測試方法
同時運行三個測試程序,都選擇[測試身份驗證],然后分別輸入不同的用戶身份序號,快速切換三個程序並按下回車鍵,三個測試程序會各自發起100次請求,每次請求間隔100毫秒。
例如同時打開三個命令行終端執行:dotnet .\CustomAuthorization.test.dll
-
測試結果
三個測試程序從后台服務所獲取到的當前用戶信息完全匹配。

測試以不同用戶身份訪問需要權限的接口
-
測試方法
預設的權限為:admin=>全部權限,user=>除
Test.Add以外權限,tester=>無。分別以admin、user、tester三個用戶身份請求
/api/test下的所有接口,並模擬令牌過期的場景。 -
測試結果
可以見到,以過期的令牌發起請求時,后台返回的狀態為Unauthorized,當用戶未獲得足夠的授權時后台返回的狀態為Forbidden。
測試通過!

重要更新
在實際生產環境中,往往會在控制器方法中調用異步方法來提高並發,例如異步發消息、異步寫文件等等。
但是這樣一來,之前在HandleAuthenticateAsync()方法中通過Thread.CurrentPrincipal屬性來保存用戶信息的做法就行不通了,因為在調用異步方法后,當前線程已經被改變了。如果獲取到的線程是新創建的還好,頂多是Thread.CurrentPrincipal屬性為null,獲取用戶失敗而已;要是萬一從線程池拿到是另一次會話保存的用戶信息,那就會發生嚴重的BUG,導致用戶信息混亂。
好在ASP.NET Core提供了另一種獲取HTTP上下文的方法,通過注入IHttpContextAccessor實例,可以讀取HttpContextAccessor.HttpContext.User屬性值,也可以修改該屬性值,而且不受線程切換的影響。
所以,修改所有讀取Thread.CurrentPrincipal及為該屬性賦值的代碼,替換為HttpContextAccessor.HttpContext.User。
ASP.NET Core 2.2之后,必須調用services.AddHttpContextAccessor()才能注入IHttpContextAccessor實例
從源碼倉庫中簽出一份代碼,打開
.\src\Http\Http\src\HttpServiceCollectionExtensions.cs文件,可以見到代碼:services.TryAddSingleton<IHttpContextAccessor, HttpContextAccessor>();
原來HttpContextAccessor是以單例模式注冊的,所以就能在多線程之間共享同一實例了。
但是它是如何做到不同會話之間隔離的呢(也就是每次請求的HttpContext實例其實不同),通過查看HttpContextAccessor.cs代碼,發現奧秘就在new AsyncLocal<HttpContextHolder>()。
之后在TestController控制器中增加了一個AsyncTest接口方法,進行了多次測試,具體見下圖:

可以見到,在異步方法內,使用await調用了兩次異步方法,結果發現經過兩次異步調用后,當前線程有時會與第一個異步方法內的線程相同,有時會不同,帶有一定的隨機性。所以千萬不能依賴某些表面上看來合理的規律,使用多線程得非常小心,多嘗試多總結,做到安全穩定。
最后
源代碼托管在gitee.com
歡迎轉載,請在明顯位置給出出處及鏈接。
