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 框架 https://aspnetboilerplate.com/ 中权限鉴定的一些思想, 来实现基于功能点的权限访问控制.
非常感谢 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.
测试通过!
最后
源代码托管在 gitee.com
来源: https://www.cnblogs.com/wiseant/p/10515842.html