快速理解ASP.NET Core的认证与授权

By | 2021年11月21日

本文结构

ASP.NET Core的认证与授权已经不是什么新鲜事了,微软官方的文档对于如何在ASP.NET Core中实现认证与授权有着非常详细深入的介绍。但有时候在开发过程中,我们也往往会感觉无从下手,或者由于一开始没有进行认证授权机制的设计与规划,使得后期出现一些混乱的情况。这里我就尝试结合一个实际的例子,从0到1来介绍ASP.NET Core中如何实现自己的认证与授权机制。

当我们使用Visual Studio自带的ASP.NET Core Web API项目模板新建一个项目的时候,Visual Studio会问我们是否需要启用认证机制,如果你选择了启用,那么Visual Studio会在项目创建的时候,加入一些辅助依赖和一些辅助类,比如加入对Entity Framework以及ASP.NET Identity的依赖,以帮助你实现基于Entity Framework和ASP.NET Identity的身份认证。如果你还没有了解过ASP.NET Core的认证与授权的一些基础内容,那么当你打开这个由Visual Studio自动创建的项目的时候,肯定会一头雾水,不知从何开始,你甚至会怀疑自动创建的项目中,真的是所有的类或者方法都是必须的吗?所以,为了让本文更加简单易懂,我们还是选择不启用身份认证,直接创建一个最简单的ASP.NET Core Web API应用程序,以便后续的介绍。

新建一个ASP.NET Core Web API应用程序,这里我是在Linux下使用JetBrains Rider新建的项目,也可以使用标准的Visual Studio或者VSCode来创建项目。创建完成后,运行程序,然后使用浏览器访问/WeatherForecast端点,就可以获得一组随机生成的天气及温度数据的数组。你也可以使用下面的curl命令来访问这个API:

curl -X GET "http://localhost:5000/WeatherForecast" -H  "accept: text/plain"

现在让我们在WeatherForecastController的Get方法上设置一个断点,重新启动程序,仍然发送上述请求以命中断点,此时我们比较关心User对象的状态,打开监视器查看User对象的属性,发现它的IsAuthenticated属性为false:

在很多情况下,我们可能并不需要在Controller的方法中获取认证用户的信息,因此也从来不会关注User对象是否真的处于已被认证的状态。但是当API需要根据用户的某些信息来执行一些特殊逻辑时,我们就需要在这里让User的认证信息处于一种合理的状态:它是已被认证的,并且包含API所需的信息。这就是本文所要讨论的ASP.NET Core的认证与授权。

认证

应用程序对于使用者的身份认定包含两部分:认证授权。认证是指当前用户是否是系统的合法用户,而授权则是指定合法用户对于哪些系统资源具有怎样的访问权限。我们先来看如何实现认证。

在此,我们单说由ASP.NET Core应用程序本身实现的认证,不讨论具有统一Identity Provider完成身份认证的情况(比如单点登录),这样的话就能够更加清晰地了解ASP.NET Core本身的认证机制。接下来,我们尝试在ASP.NET Core应用程序上,实现Basic认证。

Basic认证需要将用户的认证信息附属在HTTP请求的Authorization的头(Header)上,认证信息是一串由用户名和密码通过BASE64编码后所产生的字符串,例如,当你采用Basic认证,并使用daxnet和password作为访问WeatherForecast API的用户名和密码时,你可能需要使用下面的命令行来调用WeatherForecast:

curl -X GET "http://localhost:5000/WeatherForecast" -H  "accept: text/plain" -H "Authorization: Basic ZGF4bmV0OnBhc3N3b3Jk"

在ASP.NET Core Web API中,当应用程序接收到上述请求后,就会从Request的Header里读取Authorization的信息,然后BASE64解码得到用户名和密码,然后访问数据库来确认所提供的用户名和密码是否合法,以判断认证是否成功。这部分工作通常可以采用ASP.NET Core Identity框架来实现,不过在这里,为了能够更加清晰地了解认证的整个过程,我们选择自己动手来实现。

首先,我们定义一个User对象,并且预先设计好几个用户,以便模拟存储用户信息的数据库,这个User对象的代码如下:

public class User
{
    public string UserName { get; set; }
    public string Password { get; set; }
    public IEnumerable<string> Roles { get; set; }
    public int Age { get; set; }

    public override string ToString() => UserName;

    public static readonly User[] AllUsers = {
        new User
        {
            UserName = "daxnet", Password = "password", Age = 16, Roles = new[] { "admin", "super_admin" }
        },
        new User
        {
            UserName = "admin", Password = "admin", Age = 29, Roles = new[] { "admin" }
        }
    };
}

该User对象包括用户名、密码以及它的角色名称,不过暂时我们不需要关心角色信息。User对象还包含一个静态字段,我们将它作为用户信息数据库来使用。

接下来,在应用程序中添加一个AuthenticationHandler,用来获取Request Header中的用户信息,并对用户信息进行验证,代码如下:

public class BasicAuthenticationHandler : AuthenticationHandler<BasicAuthenticationSchemeOptions>
{
    public BasicAuthenticationHandler(
        IOptionsMonitor<BasicAuthenticationSchemeOptions> options, 
        ILoggerFactory logger, 
        UrlEncoder encoder, 
        ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }

    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        if (!Request.Headers.ContainsKey("Authorization"))
        {
            return Task.FromResult(AuthenticateResult.Fail("Authorization header is not specified."));
        }
        var authHeader = Request.Headers["Authorization"].ToString();
        if (!authHeader.StartsWith("Basic "))
        {
            return Task.FromResult(
                AuthenticateResult.Fail("Authorization header value is not in a correct format"));
        }

        var base64EncodedValue = authHeader["Basic ".Length..];
        var userNamePassword = Encoding.UTF8.GetString(Convert.FromBase64String(base64EncodedValue));
        var userName = userNamePassword.Split(':')[0];
        var password = userNamePassword.Split(':')[1];
        var user = User.AllUsers.FirstOrDefault(u => u.UserName == userName && u.Password == password);
        if (user == null)
        {
            return Task.FromResult(AuthenticateResult.Fail("Invalid username or password."));
        }

        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, user.UserName),
            new Claim(ClaimTypes.Role, string.Join(',', user.Roles)),
            new Claim(ClaimTypes.UserData, user.Age.ToString())
        };
        var claimsPrincipal =
            new ClaimsPrincipal(new ClaimsIdentity(
                claims, 
                "Basic", 
                ClaimTypes.NameIdentifier, ClaimTypes.Role));
        var ticket = new AuthenticationTicket(claimsPrincipal, new AuthenticationProperties
        {
            IsPersistent = false
        }, "Basic");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
}

在上面的HandleAuthenticateAsync代码中,首先对Request Header进行合法性校验,比如是否包含Authorization的Header,以及Authorization Header的值是否合法,然后,将Authorization Header的值解析出来,通过Base64解码后得到用户名和密码,与用户信息数据库里的记录进行匹配,找到匹配的用户。接下来,基于找到的用户对象,创建ClaimsPrincipal,并基于ClaimsPrincipal创建AuthenticationTicket然后返回。

这段代码中有几点值得关注:

  1. BasicAuthenticationSchemeOptions本身只是一个继承于AuthenticationSchemeOptions的POCO类。AuthenticationSchemeOptions类通常是为了向AuthenticationHandler提供一些输入参数。比如,在某个自定义的用户认证逻辑中,可能需要通过环境变量读入字符串解密的密钥信息,此时就可以在这个自定义的AuthenticationSchemeOptions中增加一个Passphrase的属性,然后在Startup.cs中,通过service.AddScheme调用将从环境变量中读取的Passphrase的值传入
  2. 除了将用户名作为Identity Claim加入到ClaimsPrincipal中之外,我们还将用户的角色(Role)用逗号串联起来,作为Role Claim添加到ClaimsPrincipal中,目前我们暂时不需要涉及角色相关的内容,但是先将这部分代码放在这里以备后用。另外,我们将用户的年龄(Age)放在UserData claim中,在实际中应该是在用户对象上有该用户的出生日期,这样比较合理,然后这个出生日期应该放在DateOfBirth claim中,这里为了简单起见,就先放在UserData中了
  3. ClaimsPrincipal的构造函数中,可以指定哪个Claim类型可被用作用户名称,而哪个Claim类型又可被用作用户的角色。例如上面代码中,我们选择NameIdentifier类型作为用户名,而Role类型作为用户角色,于是,在接下来的Controller代码中,由NameIdentifier这种Claim所指向的字符串值,就会被看成用户名而被绑定到Identity.Name属性上

回过头来看看BasicAuthenticationSchemeOptions类,它的实现非常简单:

public class BasicAuthenticationSchemeOptions : AuthenticationSchemeOptions
{
        
}

接下来,在Startup.cs文件里,修改ConfigureServices和Configure方法,加入Authentication的支持:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
    });
    services.AddAuthentication("Basic")
        .AddScheme<BasicAuthenticationSchemeOptions, BasicAuthenticationHandler>(
            "Basic", options => { });
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebAPIAuthSample v1"));
    }

    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthentication();
    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

现在,运行应用程序,在WeatherForecastController的Get方法上设置断点,然后执行上面的curl命令,当断点被命中时,观察this.User对象可以发现,IsAuthenticated属性变为了true,Name属性也被设置为用户名:

大多数身份认证框架会提供一些辅助方法来帮助开发人员将AuthenticationHandler注册到应用程序中,例如,基于JWT持有者身份认证的框架会提供一个AddJwtBearer的方法,将JWT身份认证机制加入到应用程序中,它本质上也是调用AddScheme方法来完成AuthenticationHandler的注册。在这里,我们也可以自定义一个AddBasicAuthentication的扩展方法:

public static class Extensions
{
    public static AuthenticationBuilder AddBasicAuthentication(this AuthenticationBuilder builder)
        => builder.AddScheme<BasicAuthenticationSchemeOptions, BasicAuthenticationHandler>(
            "Basic", 
            options => { });
}

然后修改Starup.cs文件,将ConfigureServices方法改为下面这个样子:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
    });
    services.AddAuthentication("Basic").AddBasicAuthentication();
}

这样做的好处是,你可以为开发人员提供更多比较有针对性的配置认证机制的编程接口,这对于一个认证模块/框架的开发是一个很好的设计。

在curl命令中,如果我们没有指定Authorization Header,或者Authorization Header的值不正确,那么WeatherForecast API仍然可以被调用,只不过IsAuthenticated属性为false,也无法从this.User对象得到用户信息。其实,阻止未认证用户访问API并不是认证的事情,API被未认证(或者说未登录)用户访问也是合理的事情,因此,要实现对于未认证用户的访问限制,就需要进一步实现ASP.NET Core Web API的另一个安全控制组件:授权

授权

认证相比,授权的逻辑会比较复杂:认证更多是技术层面的事情,而授权则更多地与业务相关。市面上常见的认证机制顶多也就是那么几种或者十几种,而授权的方式则是多样化的,因为不同app不同业务,对于app资源访问的授权需求是不同的。最为常见的一种授权方式就是RBAC(Role Based Access Control,基于角色的访问控制),它定义了什么样的角色对于什么资源具有怎样的访问权限。在RBAC中,不同的用户都被赋予了不同的角色,而为了管理方便,又为具有相同资源访问权限的用户设计了用户组,而将访问控制设置在用户组上,更进一步,组和组之间还可以有父子关系。

请注意上面的黑体字,每一个黑体标注的词语都是授权相关的概念,在ASP.NET Core中,每一个授权需求(Authorization Requirement)对应一个实现IAuthorizationRequirement的类,并由AuthorizationHandler负责处理相应的授权逻辑。简单地理解,授权需求表示什么样的用户才能够满足被授权的要求,或者说什么样的用户才能够通过授权去访问资源。一个授权需求往往仅定义并处理一种特定的授权逻辑,ASP.NET Core允许将多个授权需求组合成授权策略(Authorization Policy)然后应用到被访问的资源上,这样的设计可以保证授权需求的设计与实现都是小粒度的,从而分离不同授权需求的关注点。在授权策略的层面,通过组合不同授权需求从而达到灵活实现授权业务的目的。

比如:假设app中有的API只允许管理员访问,而有的API只允许满18周岁的用户访问,而另外的一些API需要用户既是超级管理员又满18岁。那么就可以定义两种Authorization Requirement:GreaterThan18Requirement和SuperAdminRequirement,然后设计三种Policy:第一种只包含GreaterThan18Requirement,第二种只包含SuperAdminRequirement,第三种则同时包含这两种Requirement,最后将这些不同的Policy应用到不同的API上就可以了。

回到我们的案例代码,首先定义两个Requirement:SuperAdminRequirement和GreaterThan18Requirement:

public class SuperAdminRequirement : IAuthorizationRequirement
{
}
public class GreaterThan18Requirement : IAuthorizationRequirement
{
}

然后分别实现SuperAdminAuthorizationHandler和GreaterThan18AuthorizationHandler:

public class GreaterThan18AuthorizationHandler : AuthorizationHandler<GreaterThan18Requirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, GreaterThan18Requirement requirement)
    {
        if (context.User.HasClaim(c => c.Type == ClaimTypes.UserData) &&
            Convert.ToInt32(context.User.FindFirstValue(ClaimTypes.UserData)) > 18)
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

public class SuperAdminAuthorizationHandler : AuthorizationHandler<SuperAdminRequirement>
{
    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SuperAdminRequirement requirement)
    {
        if (context.User.HasClaim(c => c.Type == ClaimTypes.Role) &&
            (context.User.FindFirstValue(ClaimTypes.Role)
                ?.Split(',')
                ?.Contains("super_admin") ?? false))
        {
            context.Succeed(requirement);
        }

        return Task.CompletedTask;
    }
}

实现逻辑也非常清晰:在GreaterThan18AuthorizationHandler中,通过UserData claim获得年龄信息,如果年龄大于18,则授权成功;在SuperAdminAuthorizationHandler中,通过Role claim获得用户所处的角色,如果角色中包含super_admin,则授权成功。接下来就需要将这两个Requirement加到所需的Policy中,然后注册到应用程序里:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();
    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
    });
    services.AddAuthentication("Basic").AddBasicAuthentication();
    services.AddAuthorization(options =>
    {
        options.AddPolicy("AgeMustBeGreaterThan18", builder =>
        {
            builder.Requirements.Add(new GreaterThan18Requirement());
        });
        options.AddPolicy("UserMustBeSuperAdmin", builder =>
        {
            builder.Requirements.Add(new SuperAdminRequirement());
        });
    });
    services.AddSingleton<IAuthorizationHandler, GreaterThan18AuthorizationHandler>();
    services.AddSingleton<IAuthorizationHandler, SuperAdminAuthorizationHandler>();
}

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
        app.UseSwagger();
        app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "WebAPIAuthSample v1"));
    }

    app.UseHttpsRedirection();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
}

在ConfigureServices方法中,我们定义了两种Policy:AgeMustBeGreaterThan18和UserMustBeSuperAdmin,最后,在API Controller或者Action上,应用AuthorizeAttribute,从而指定所需的Policy即可。比如,如果希望WeatherForecase API只有年龄大于18岁的用户才能访问,那么就可以这样做:

[HttpGet] 
[Authorize(Policy = "AgeMustBeGreaterThan18")]
public IEnumerable<WeatherForecast> Get()
{
    var rng = new Random();
    return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = rng.Next(-20, 55),
            Summary = Summaries[rng.Next(Summaries.Length)]
        })
        .ToArray();
}

运行程序,假设有三个用户:daxnet、admin和foo,它们的BASE64认证信息分别为:

  • daxnet:ZGF4bmV0OnBhc3N3b3Jk
  • admin:YWRtaW46YWRtaW4=
  • foo:Zm9vOmJhcg==

那么,相同的curl命令,指定不同的用户认证信息时,得到的结果是不一样的:

daxnet用户年龄小于18岁,所以访问API不成功,服务端返回403:

admin用户满足年龄大于18岁的条件,所以可以成功访问API:

而foo用户本身没有在系统中注册,所以服务端返回401,表示用户没有认证成功:

小结

本文简要介绍了ASP.NET Core中用户身份认证与授权的基本实现方法,帮助初学者或者需要使用这些功能的开发人员快速理解这部分内容。ASP.NET Core的认证与授权体系非常灵活,能够集成各种不同的认证机制与授权方式,文章也无法进行全面详细的介绍。不过无论何种框架哪种实现,它的实现基础也就是本文所介绍的这些内容,如果打算自己开发一套认证和授权的框架,也可以参考本文。

(总访问量:1,154;当日访问量:1)

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据