ASP.NET Core 项目简单实现身份验证及鉴权

时间:2021-08-27 03:02:53

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]按钮,系统验证成功并返回身份证明信息。

ASP.NET Core 项目简单实现身份验证及鉴权

记下访问令牌2ad43df2c11d48a18a88441adbf4994a和刷新令牌9bbaf811ed8b4d29b638777d4f89238e

  • 测试刷新登录令牌

    点击/api/Auth/Refresh方法,在输入框中输入上面获取到的刷新令牌9bbaf811ed8b4d29b638777d4f89238e,然后点击[Execute]按钮,返回401错误码。原因是因为我们并未提供访问令牌。

    点击方法名右侧的[锁]图标,在弹出框中输入之前获取的访问令牌2ad43df2c11d48a18a88441adbf4994a并点击[Authorize]按钮后关闭对话框,重新点击[Execute]按钮,成功获取到新的登录令牌。

ASP.NET Core 项目简单实现身份验证及鉴权

  • 测试验证访问令牌

    点击/api/Auth/Validate方法,在输入框中输入第一次获取的到访问令牌2ad43df2c11d48a18a88441adbf4994a,然后点击[Execute]按钮,返回400错误码,表明发起的请求参数有误。因为此方法是支持匿名访问的,所以错误码不会是401.

    将输入框内容修改为新的访问令牌f37542e162ed4855921ddf26b05c3f25,然后点击[Execute]按钮,验证成功,返回了对应的用户身份证明信息。

ASP.NET Core 项目简单实现身份验证及鉴权

权限鉴定

  在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

  • 测试结果

    三个测试程序从后台服务所获取到的当前用户信息完全匹配。

ASP.NET Core 项目简单实现身份验证及鉴权

测试以不同用户身份访问需要权限的接口

  • 测试方法

    预设的权限为:admin=>全部权限,user=>除Test.Add以外权限,tester=>无。

    分别以admin、user、tester三个用户身份请求/api/test下的所有接口,并模拟令牌过期的场景。

  • 测试结果

    可以见到,以过期的令牌发起请求时,后台返回的状态为Unauthorized,当用户未获得足够的授权时后台返回的状态为Forbidden。

    测试通过!

ASP.NET Core 项目简单实现身份验证及鉴权

重要更新

在实际生产环境中,往往会在控制器方法中调用异步方法来提高并发,例如异步发消息、异步写文件等等。

但是这样一来,之前在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接口方法,进行了多次测试,具体见下图:

ASP.NET Core 项目简单实现身份验证及鉴权

可以见到,在异步方法内,使用await调用了两次异步方法,结果发现经过两次异步调用后,当前线程有时会与第一个异步方法内的线程相同,有时会不同,带有一定的随机性。所以千万不能依赖某些表面上看来合理的规律,使用多线程得非常小心,多尝试多总结,做到安全稳定。

最后

源代码托管在gitee.com

欢迎转载,请在明显位置给出出处及链接