ASP.NET Core 項目簡單實現身份驗證及鑒權


ASP.NET Core 身份驗證及鑒權

目錄

環境

  • VS 2017
  • ASP.NET Core 2.2

目標

  以相對簡單優雅的方式實現用戶身份驗證和鑒權,解決以下兩個問題:

  • 無狀態的身份驗證服務,使用請求頭附加訪問令牌,幾乎適用於手機、網頁、桌面應用等所有客戶端
  • 基於功能點的權限訪問控制,可以將任意功能點權限集合授予用戶或角色,無需硬編碼角色權限,非常靈活

項目准備

  1. 創建一個ASP.NET Core Web應用程序

    • 使用ASP.NET Core 2.2
    • 模板選[空]
    • 不啟用HTTPS
    • 不進行身份驗證
  2. 通過NuGet安裝Swashbuckle.AspNetCore程序包,並在Startup類中啟用Swagger支持

    因為這個示例項目不打算編寫前端網頁,所以直接使用Swagger來調試,真的很方便。

  3. 添加一個空的MVC控制器(HomeController)和一個空的API控制器(AuthController)

    HomeController.Index()方法中只寫一句簡單的跳轉代碼即可:

    return new RedirectResult("~/swagger");

    AuthController類中隨便寫一兩個骨架方法,方便看效果。

  4. 運行項目,會自動打開瀏覽器並跳轉到Swagger頁面。

身份驗證

定義基本類型和接口

  1. ClaimTypes 定義一些常用的聲明類型常量

  2. IClaimsSession 表示當前會話信息的接口

  3. ClaimsSession 會話信息實現類
    根據聲明類型從ClaimsPrincipal.ClaimsIdentity屬性中讀取用戶ID、用戶名等信息。

    實際項目中可從此類繼承或完全重新實現自己的Session類,以添加更多的會話信息(例如工作部門)

  4. IToken 登錄令牌接口
    包含訪問令牌、刷新令牌、令牌時效等令牌

  5. IIdentity 身份證明接口
    包含用戶基本信息及令牌信息

  6. IAuthenticationService 驗證服務接口
    抽象出來的驗證服務接口,僅規定了四個身份驗證相關的方法,如需擴展可定義由此接口派生的接口。

    方法名 返回值類型 說明
    Login(userName, password) IIdentity 根據用戶名及密碼驗證其身份,成功則返回身份證明
    Logout() void 注銷本次登錄,即使未登錄也不報錯
    RefreshToken(refreshToken) Token 刷新登錄令牌,如果當前用戶未登錄則報錯
    ValidateToken(accessToken) IIdentity 驗證訪問令牌,成功則返回身份證明
  7. SimpleToken 登錄令牌的簡化實現

    這個類提不提供都可以,實際項目中大家生成Token的算法肯定是各不相同的,提供簡單實現僅用於演示

編寫驗證處理器

  1. BearerDefaults 定義了一些與身份驗證相關的常量

    如:AuthenticationScheme

  2. BearerOptions 身份驗證選項類

    AuthenticationSchemeOptions繼承而來

  3. BearerValidatedContext 驗證結果上下文

  4. BearerHandler 身份驗證處理器 <= 關鍵類

    覆蓋了HandleAuthenticateAsync()方法,實現自定義的身份驗證邏輯,簡述如下:

    1. 獲取訪問令牌。從請求頭中獲取authorization信息,如果沒有則從請求的參數中獲取

    2. 如果訪問令牌為空,則終止驗證,但不報錯,直接返回AuthenticateResult.NoResult()

    3. 調用從構造函數注入的IAuthenticationService實例的ValidateToken()方法,驗證訪問令牌是否有效,如果該方法觸發異常(例如令牌過期)則捕獲后通過AuthenticateResult.Fail()返回錯誤信息,如果該方法返回值為空(例如訪問令牌根本不存在)則返回AuthenticateResult.NoResult(),不報錯。

    4. 到這一步說明身份驗證已經通過,而且拿到身份證明信息,根據該信息創建Claim數組,然后再創建一個包含這些Claim數據的ClaimsPrincipal實例,並將Thread.CurrentPrincipal設置為該實例。

      重點:其實,HttpContext.User屬性的類型正是CurrentPrincipal,而其值應該就是來自於Thread.CurrentPrincipal

    5. 構造BearerValidatedContext實例,並將其Principal屬性賦值為上面創建的ClaimsPrincipal實例,然后調用Success()方法,表示驗證成功。最后返回該實例的Result屬性值。

  5. BearerExtensions 包含一些擴展方法,提供使用便利

    重點在於AddBearer()方法內調用builder.AddScheme<TOptions,THandler>()泛型方法時,分別使用了前面編寫的BearerOptionsBearerHandler類作為泛型參數。

    public static AuthenticationBuilder AddBearer(...)
    {
        return builder.AddScheme<BearerOptions, BearerHandler>(...);
    }
    

    如果想要自己實現BearerHandler類的驗證邏輯,可以拋棄此類,重新編寫使用新Handler類的擴展方法

實現用戶身份驗證

說明

  這部分是身份驗證的落地,實際項目中應該將上面兩步(定義基本類型和接口、編寫驗證處理器)的代碼抽象出來,成為獨立可復用的軟件包,利用該軟件包進行身份驗證的實現邏輯可參照此示例代碼。

實現步驟

  1. Identity 身份證明實現類

  2. SampleAuthenticationService 驗證服務的簡單實現

    出於演示方便,固化了三個用戶(admin/123456、user/123、tester/123)

  3. AuthController 通過HTTP向前端提供驗證服務的控制器類

    提供了用戶登錄、令牌刷新、令牌驗證等方法。

  4. 還需要修改項目中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將不會被調用。

基於這個思路,我們設計了以下方案:

  1. 編寫一個Attribute(特性)類,包含以下兩個屬性:

    Permissions:需要檢查的權限數組

    RequireAllPermissions:是否需要擁有數組中全部權限,如果為否則擁有任一權限即可

  2. 定義一個IPermissionChecker接口,在接口中定義IsGrantedAsync()方法,用於執行權限鑒定邏輯

  3. 編寫一個AuthorizationFilterAttribute特性類(應用目標為class),通過屬性注入IPermissionChecker實例。然后在OnAuthorization()方法內調用IPermissionChecker實例的IsGrantedAsync()方法,如果該方法返回值為false,則返回403錯誤,否則正常放行。

編寫過濾器類及相關接口

  1. 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;
            }
        }
    
  2. IPermissionChecker接口定義

        public interface IPermissionChecker
        {
            Task<bool> IsGrantedAsync(string permissionName);
        }
    
  3. 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 };
            }
        }
    
  4. 配合屬性注入提供NullPermissionChecker類,在IsGrantedAsync()方法內直接返回true。

實現屬性注入

  做好上面的准備,我們應該可以開始着手在項目內應用權限鑒定功能了,不過ASP.NET Core內置的DI框架並不支持屬性注入,所以還得添加屬性注入的功能。

  1. 定義InjectionAttribute類,用於顯式聲明應用了此特性的屬性將使用依賴注入

    /// <summary>
    /// 在屬性上添加此特性,以聲明該屬性需要使用依賴注入
    /// </summary>
    [AttributeUsage(AttributeTargets.Property)]
    public class InjectionAttribute : Attribute { }
    
  2. 添加一個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);
            }
        }
    }
    
  3. 還有非常關鍵的一步,在Startup.ConfigureServices()中添加下面的代碼,替換IFilterProvider接口的實現類為上面編寫的PropertiesAutowiredFilterProvider

    services.Replace(ServiceDescriptor.Singleton<Microsoft.AspNetCore.Mvc.Filters.IFilterProvider, PropertiesAutowiredFilterProvider>());
    

實現用戶權限鑒定

  終於,我們可以在項目內應用權限鑒定功能了。

編碼

  1. 首先,我們定義一些功能點權限常量

    public static class PermissionNames
    {
        public const string TestAdd = "Test.Add";
        public const string TestEdit = "Test.Edit";
        public const string TestDelete = "Test.Delete";
    }
    
  2. 接着,添加一個新的用於測試的控制器類

        [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

  3. 實現一個用於演示的權限檢查器類

    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);
        }
    
    }
    
  4. 最后還需要修改項目中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
歡迎轉載,請在明顯位置給出出處及鏈接


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM