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:
1curl -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:
1curl -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 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
{
public BasicAuthenticationHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
ISystemClock clock) : base(options, logger, encoder, clock)
{
}
protected override Task 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 然后返回。
这段代码中有几点值得关注:
- BasicAuthenticationSchemeOptions 本身只是一个继承于 AuthenticationSchemeOptions 的 POCO 类。AuthenticationSchemeOptions 类通常是为了向 AuthenticationHandler 提供一些输入参数。比如,在某个自定义的用户认证逻辑中,可能需要通过环境变量读入字符串解密的密钥信息,此时就可以在这个自定义的 AuthenticationSchemeOptions 中增加一个 Passphrase 的属性,然后在 Startup.cs 中,通过 service.AddScheme 调用将从环境变量中读取的Passphrase 的值传入。
- 除了将用户名作为 Identity Claim 加入到 ClaimsPrincipal 中之外,我们还将用户的角色(Role)用逗号串联起来,作为 Role Claim 添加到ClaimsPrincipal 中,目前我们暂时不需要涉及角色相关的内容,但是先将这部分代码放在这里以备后用。另外,我们将用户的年龄(Age)放在 UserData claim中,在实际中应该是在用户对象上有该用户的出生日期,这样比较合理,然后这个出生日期应该放在 DateOfBirth claim 中,这里为了简单起见,就先放在UserData 中了。
- ClaimsPrincipal 的构造函数中,可以指定哪个 Claim类型可被用作用户名称,而哪个 Claim 类型又可被用作用户的角色。例如上面代码中,我们选择NameIdentifier 类型作为用户名,而 Role 类型作为用户角色,于是,在接下来的 Controller 代码中,由 NameIdentifier 这种 Claim 所指向的字符串值,就会被看成用户名而被绑定到 Identity.Name 属性上。
回过头来看看 BasicAuthenticationSchemeOptions 类,它的实现非常简单:
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo { Title = "WebAPIAuthSample", Version = "v1" });
});
services.AddAuthentication("Basic")
.AddScheme(
"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(
"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
{
}
然后分别实现 SuperAdminAuthorizationHandle 和GreaterThan18AuthorizationHandler:
实现逻辑也非常清晰:在 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();
services.AddSingleton();
}
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
{
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 的认证与授权体系非常灵活,能够集成各种不同的认证机制与授权方式,文章也无法进行全面详细的介绍。不过无论何种框架哪种实现,它的实现基础也就是本文所介绍的这些内容,如果打算自己开发一套认证和授权的框架,也可以参考本文。