Liam W
封面

ASP.NET Core 基于角色的 JWT 令牌

作者
王亮·发表于 3 年前

原文:https://bit.ly/3vYljq3
作者:Rick Strahl
翻译:精致码农-王亮
声明:我翻译技术文章不是逐句翻译的,而是根据我自己的理解来表述的。其中可能会去除一些本人实在不知道如何组织但又不影响理解的句子。

ASP.NET Core 中的认证和授权仍然是配置中最麻烦的组件。似乎几乎在每一个应用程序上,我都会遇到一些与 Auth 有关的问题。四个版本带来了三种不同的身份验证实现,功能的更新也留下了一大波过时的信息。今天,我看着 Web API 基于角色 JWT 授权认证的过时信息,陷入了一个土拨鼠日(译注:形容不断重复的日子)的循环中。

目前在 ASP.NET Core 中的 JWT 令牌(Token)配置实际上非常好用,只要你把正确的配置咒语串起来。Auth 配置的部分问题是,大多数配置只需按固定的“仪式”进行操作。例如,设置IssuerAudience我们似乎完全不需要关心它们是什么,但它们是 JWT 令牌要求的一部分,确实需要配置。幸运的是,这些设置中只有少数几个是真正需要的,大部分都是模板。

在这篇文章中,我具体讲一下:

  • ASP.NET Core Web API 的认证
  • JWT 令牌的使用
  • 基于角色授权
  • 只使用底层功能–不使用 ASP.NET Core Identity

配置

认证(Authentication)和授权(Authorization)在 ASP.NET Core 中作为中间件提供,你必须在ConfigureServices()中配置它们,并在Configure()中连接中间件。

配置 JWT 认证和授权

第一步是在Startup文件中的ConfigureServices()中配置认证(Authentication)。在这里添加 JWT 令牌配置,并将所需组件添加到 ASP.NET Core 的处理管道中:

// in ConfigureServices()

// config shown  for reference values
config.JwtToken.Issuer = "https://mysite.com";
config.JwtToken.Audience = "https://mysite.com";
config.JwtToken.SigningKey = "12345@4321";  //  some long id

// Configure Authentication
services.AddAuthentication( auth=>
{
    auth.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    auth.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    options.SaveToken = true;
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidIssuer = config.JwtToken.Issuer,
        ValidateAudience = true,
        ValidAudience = config.JwtToken.Audience,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config.JwtToken.SigningKey))
    };
}

JWT 认证有一堆的设置,其中大部分是足够神秘的,所以我几乎只是将它们复制和粘贴。我只想说,这些设置大多是关于设置协议和令牌包装器(Wrapper)的。通常情况下,我将这些值存储在我的应用程序的配置中,这样它就会通过 .NET 配置 Provider 提取进来,而上面的config就是那个特定的配置实例。

在这个全局配置中没有什么是针对角色的。所有基于角色的相关配置都发生在后面的认证(Authenticate)端点中创建令牌的时候。

令牌和哈希如何工作

在进入这里之前,我们先来回顾一下基于令牌的身份验证是如何工作的,以及这些设置值是如何融入这个方案的。

上面的设置值配置了令牌的常用值和用于签署令牌的密钥。它们提供身份识别标记,以确保生成的令牌是唯一的。我认为这些值是一个基本的令牌包装,通常在你验证用户后,当你创建令牌并将令牌作为 Web 请求的一部分提供给用户之时,你将向令牌添加你的自定义、应用特定的 Claim,。

IssuerSigningKey是这个配置中最重要的部分,它用于将最终的令牌与包装器以及任何添加的声明进行哈希(Hash)。该哈希值用于验证令牌的真实性。请注意,虽然生成的令牌被编码为 Base64,但它本身并不安全,即使在客户端,内容也可以被解码。也就是说,你可以将任何 JWT 令牌粘贴到 JWT.io 这个网站中,对令牌的内容进行解码。

哈希确保了令牌不能被改变。当令牌与请求一起发送时,它将由 ASP.NET Core 的 JWToken 中间件进行验证,它首先根据令牌数据验证哈希值,然后根据包含的授权信息进行认证/授权。如果客户端或其他实体以任何方式更改了令牌,则哈希值将无法验证通过,会被直接拒绝。之后在中间件管道的授权部分进行用户名和角色等的匹配。

添加 Auth 中间件

接下来我们需要在Startup文件的Configure中使用app.UseAuthentication()app.UseAuthorization()添加实际的中间件:

// in Startup.Configure()
app.UseHttpsRedirection();
app.UseRouting();

// *** These are the important ones - note order matters ***
app.UseAuthentication();
app.UseAuthorization();

app.UseStatusCodePages();
//app.UseDefaultFiles(); // so index.html is not required
//app.UseStaticFiles();

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

请注意,顺序对于认证(Authentication)和授权(Authorization)很重要。这两个需要在 Routing 之后但在任何 HTTP 输出中间件之前添加,最重要的是在app.UseEndpoints()之前。

使用 Web API 端点认证用户

接下来,我们需要在应用程序中通过询问凭证来验证用户,然后生成一个令牌并将其返回给 API 客户端。

这很可能发生在 Controller 的 Action 方法或中间件端点处理程序中。下面是使用 Controller 的 Action 方法示例:

[AllowAnonymous]
[HttpPost]
[Route("authenticate")]
public object Authenticate(AuthenticateRequestModel loginUser)
{
    // My application logic to validate the user
    // returns a user entity with Roles collection
    var bus = new AccountBusiness();
    var user = bus.AuthenticateUser(loginUser.Username, loginUser.Password);
    if (user == null)
        throw new ApiException("Invalid Login Credentials: " + bus.ErrorMessage, 401);

    var claims = new List<Claim>();
    claims.Add(new Claim("Username",loginUser.Username));
    claims.Add(new Claim("DisplayName",loginUser.Name));

    // Add roles as multiple claims
    foreach(var role in user.Roles)
    {
        claims.Add(new Claim(ClaimTypes.Role, role.Name));
    }
    // Optionally add other app specific claims as needed
    claims.Add(new Claim("UserState", UserState.ToString()));

    // create a new token with token helper and add our claim
    // from `Westwind.AspNetCore`  NuGet Package
    var token = JwtHelper.GetJwtToken(
        loginUser.Username,
        Configuration.JwtToken.SigningKey,
        Configuration.JwtToken.Issuer,
        Configuration.JwtToken.Audience,
        TimeSpan.FromMinutes(Configuration.JwtToken.TokenTimeoutMinutes),
        claims.ToArray());

    return new
    {
        token = JwtHelper.GetJwtTokenString(token),
        expires = token.ValidTo
    };
}

我正在使用一个JwtHelper类来实际生成一个令牌,这样我就不必在每个应用中记住JwtHelper类实现的这个重复的“仪式”。这段代码创建了令牌,并从中提取了一个字符串,准备作为承载令牌值返回。下面是这个类的完整代码:

public class JwtHelper
{

    /// <summary>
    /// Returns a Jwt Token from basic input parameters
    /// </summary>
    public static JwtSecurityToken GetJwtToken(
        string username,
        string uniqueKey,
        string issuer,
        string audience,
        TimeSpan expiration,
        Claim[] additionalClaims = null)
    {
        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub,username),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
        };

        if (additionalClaims is object)
        {
            var claimList = new List<Claim>(claims);
            claimList.AddRange(additionalClaims);
            claims = claimList.ToArray();
        }

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(uniqueKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        return new JwtSecurityToken(
            issuer: issuer,
            audience: audience,
            expires: DateTime.UtcNow.Add(expiration),
            claims: claims,
            signingCredentials: creds
        );
    }

    /// <summary>
    /// Returns a token string from base claims
    /// </summary>
    public static string GetJwtTokenString(
        string username,
        string uniqueKey,
        string issuer,
        string audience,
        TimeSpan expiration,
        Claim[] additionalClaims = null)
    {
        var token = GetJwtToken(username, uniqueKey, issuer, audience, expiration, additionalClaims);
        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    /// <summary>
    /// Converts an existing Jwt Token to a string
    /// </summary>
    public static string GetJwtTokenString(JwtSecurityToken token)
    {
        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    /// <summary>
    /// Returns an issuer key
    /// </summary>
    public static SymmetricSecurityKey GetSymetricSecurityKey(string issuerKey)
    {
        return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(issuerKey));
    }
}

Controller 的Authenticate()代码首先使用一个应用程序特定的业务对象来验证用户,用户登录信息作为 API 调用的一部分传入该方法(比如 HTML 网页的登录表单)。如果用户是有效的,我就创建新的 Claim,这些 Claim 被打包到令牌中。

令牌包括用户名和角色,这是 ASP.NET Core 授权工作所需的内容。然后,如果有必要,我可以添加一些额外的应用程序特定的 Claim,比如上面例子中的DisplayName和自定义UserState对象。这些声明会随令牌一起,以便在后续请求提取,而不必再访问后端数据库检索它们。

最后,使用JwtHelperGetJwtToken()生成令牌,并使用GetJwtTokenString()将令牌转换为字符串,这个字符串将被客户端放在请求头中携带到后台服务端。

请注意,要确保可以匿名访问 Authentication 方法。如果 Controller 标注了 [Authorize] 特性,则需要在Authenticate()方法上标注[AllowAnonymous]特性。

Claim 和角色

ASP.NET Core 使用 Claim 进行认证。Claim 是你可以存储在令牌中的数据片段,这些数据与令牌一起携带,并可以从令牌中读取。对于授权来说,角色可以作为 Claim。

在 .NET Core 3.1 和 5.x 中,为授权添加 ASP.NET Core 角色识别的正确语法是,为每个角色添加多个 Claim:

// Add roles as multiple claims
foreach(var role in user.Roles)
{
    claims.Add(new Claim(ClaimTypes.Role, role.Name));

    // these also work - and reduce token size
    // claims.Add(new Claim("roles", role.Name));
    // claims.Add(new Claim("role", role.Name));
}

访问生成 JWT 令牌的 API

到这,我已经有了一个用于认证的 API 端点,我可以从这个端点上获取一个令牌。下面是这个请求的样子:

传入用户名和密码,则会返回令牌和到期时间。你可以在 jwt.io 查看这个令牌和它生成的内容:

请注意,该令牌很容易被外部工具解码,与我的应用程序完全无关。这意味着所包含的令牌数据是不安全的。然而,除非数据由原始的签名密钥签名,否则无法更改该令牌中的值并提供给服务器应用程序。这可以防止令牌被篡改。

一旦生成了令牌并发送给客户端,客户端就可以在后续的请求中使用它来添加相应的授权请求头:

Authorization: Bearer 123456******

确保 API 的安全

现在剩下的就是通过在 Controller 或端点方法上添加[Authorize]特性来选择性或限制对 API 的访问。

我可以使用以下特性之一,或者完全不使用特性(对于开放访问):

  • 普通的[Authorize]让任何经过认证的用户进入
  • 基于角色的[Authorize(Roles = "Administrator,ReportUser")]访问
  • 允许匿名[AllowAnonymous]访问

请注意,这些特性可以在 Controller 类或 Action 方法上标注,而且它们是自上而下分层工作的,所以一个类属性适用于所有的 Action 方法。这就是 [AllowAnonymous] 的用武之地,它可以覆盖一两个可能需要开放访问的请求(如Authenticate()Logout())。

要为任何登录用户设置授权,只需使用[Authorize]即可:

[Authorize]   // just require ANY authentication
[Route("/api/v1/lookups")]
public class IdLookupController : BaseApiController

在这种情况下,你可能需要对用户进行一些额外的验证,以确保你有正确的用户进行特定的操作。

要设置特定角色的限制,你可以使用Roles参数:

[Authorize(Roles = "Administrator")]
[HttpPost]
[Route("customers")]
public async Task<SaveResponseModel> SaveCustomer(IdvCustomer model)

现在只有那些属于 Administrator 组的人有访问权。角色可以是使用逗号分隔的列表,如使用“Administrator, ReportUser”来允许多个角色访问。

使用令牌访问安全端点

现在 API 已经安全了,我们必须在每个请求中传递 Bearer 令牌来进行验证。它看起来像这样:

瞧,我现在可以访问管理员组保护的 POST 操作了。

这就完成了一个闭环…

总结

在最近的版本中,ASP.NET Core 中的身份验证和授权已经变得简单了很多,但是要找到正确的文档来设置 JWT 令牌身份验证的所有相关信息仍然不易。关于身份验证的信息很多,很容易在文档中迷失方向,并最终可能选择过时的信息,因为在整个 ASP.NET Core 版本中,身份验证的行为已经发生了重大变化。(基于本文)如果你要查找额外的信息,请确保它是 3.1 及以后的版本。

在这篇文章中,我已经解决了 3.1 和 5.0 版本的问题。值得庆幸的是,5.0 没有看到对认证/授权 API 的进一步破坏性改变。

通常情况下,我写下这篇文章是为了让我自己安心,这样我就能在一个地方得到所有的信息。希望你们中的一些人也会觉得这很有用。