令牌认证.md 22 KB

Token Authentication

本文通过一个用 ASP.NET Core 构建的 API,从头开始开发,通过 JSON Web Token 来实现认证和授权。

概述

认证和授权是现代应用的主要要求。

随着 API 开发的普及,以服务于不同类型的客户端应用(如单页应用和移动应用),带来了使用去中心化技术和模式提供安全要求的必要性。

JSON 网络令牌规范就是为了满足这些需求而创建的,它允许不同平台的系统交换信息和验证权限。

JSON 网络令牌到底是什么?

JSON Web Token 是 RFC 7519 中定义的一种规范,它定义了一种使用 JSON 对象进行数据传输的安全方式。JSON Web Tokens 通过使用加密算法来保证信息的安全。

这些令牌被发送到 HTTP 请求的头中,以访问受保护的 API 端点。

令牌有哪些部分?

一个令牌由三部分组成:

  1. 头部:一般由两个属性组成。

    • "alg":用于签署令牌的加密算法,确保其完整性。
    • "typ":标记的类型。
   {
     "alg": "HS256",
     "typ": "JWT"
   }
  1. 有效载荷:令牌的一部分,其中包含与用户有关的索赔或 "断言",以及每个令牌中包含的默认索赔。  

基本上,它指的是客户端应用程序和 API 执行与用户权限相关的验证所需的所有用户数据,以及检查令牌是否有效及其数据完整性所需的索赔。  

有一些标准诉求,几乎所有的令牌都要实现。  

  • "jti":代表令牌的唯一标识符。
  • "aud":受众,即该令牌的对象(在本例中,请求令牌的应用程序)。
  • "iss":令牌发行者。o "iss":令牌发行者,即发出令牌的应用程序(本例中为 API)。
  • "exp":到期日期。
  • "nbf": 意思是 "not before". 它表示令牌到期日必须大于或等于指定的日期才有效。

  还有其他默认的说法,你可以在 RFC 中查看。

 

   {
     "jti": "198f3849-ce23-4876-b1bd-bc9c936eec76",
     "sub": "test@test.com",
     "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Common",
     "nbf": 1525268259,
     "exp": 1525268289,
     "iss": " JWPAPI ",
     "aud": "SampleAudience"
   }
  1. 签名:数字签名,目的是验证和核实令牌的完整性。它由头数据、有效载荷、秘钥和指定的加密算法组成。   如果黑客得到了令牌并更改了任何数据,令牌将变得无效,因为签名将与令牌的预期签名不一致。  
   HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

这三个部分被编码为基数 64 的字符串,并使用点连接,允许通过 HTTP 请求头发送结果数据。

   eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIxOThmMzg0OS1jZTIzLTQ4NzYtYjFiZC1iYzljOTM2ZWVjNzYiLCJzdWIiOiJ0ZXN0QHRlc3QuY29tIiwiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIjoiQ29tbW9uIiwibmJmIjoxNTI1MjY4MjU5LCJleHAiOjE1MjUyNjgyODksImlzcyI6IlNhbXBsZUlzc3VlciIsImF1ZCI6IkpXUEFQSSJ9.qGImJxUkWzale_IHgIC5s0WRWyetZsShnMuNErXKUe4

ASP.NET Core 的开发方法

当使用 ASP.NET Core 时,通过令牌创建 API 以满足安全要求的过程非常简单。该框架通过各种命名空间为令牌的生成和验证提供了支持。

示例 API 的源代码

应用范围

该 API 实现了以下功能:

  • 简单的用户注册,允许用户拥有唯一的登录名、密码及其角色(或权限)。
  • 访问令牌生成。
  • 令牌刷新,在访问令牌过期时创建新的令牌。
  • 访问令牌撤销。
  • 使用角色进行权限验证。

### 框架和库

除了 ASP.NET Core ,API 还依赖于以下框架和库:

  • 实体框架核心(将用户数据存储到数据库中)。
  • AutoMapper(将 API 资源映射到领域实体)。
  • Entity Framework InMemory 提供者(只是为了测试,而不是使用 SQL Server 等)。

从用户注册开始

应用用户有角色,角色代表他们的访问权限。

API 有两个预定义的角色。Common,验证普通用户的权限;Administrator,代表系统管理员的权限,这种方式模拟了现实世界的应用。当一个新用户被创建时,他们会自动接收普通用户的角色。

用户注册是通过以下 API 端点实现的:

/api/users

您可以向这个端点发送一个 POST 请求,指定有效的凭证(电子邮件和密码),以便将用户数据存储到数据库中。

API 有两个预定义的用户来测试对受保护的 API 资源的访问,一个具有普通用户权限,另一个具有管理员权限。

以下 JSON 数据可用于测试受保护端点:

({
  "email": "common@common.com",
  "password": "12345678"
},
{
  "email": "admin@admin.com",
  "password": "12345678"
})
[HttpPost]
public async Task<IActionResult> CreateUserAsync([FromBody] UserCredentialsResource userCredentials)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var user = _mapper.Map<UserCredentialsResource, User>(userCredentials);

    var response = await _userService.CreateUserAsync(user, ERole.Common);
    if(!response.Success)
    {
        return BadRequest(response.Message);
    }

    var userResource = _mapper.Map<User, UserResource>(response.User);
    return Ok(userResource);
}

API 端点与上述方法相匹配,来自 UsersControllerclass。

API 接收到对这个端点的请求,凭证被映射到 Userdomain 类的实例。然后,将用户数据发送给用户服务。

从 IUserServiceinterface 创建用户的方法,包括两个步骤:

  • 验证是否有任何现有的用户对应于指定的电子邮件。
  • 通过 IPasswordHasher 接口的实现,创建一个密码哈希。

哈希生成保证了所有密码将以安全的方式存储到数据库中。这个过程避免了黑客和恶意用户检索用户的真实密码。

创建和验证密码哈希的代码是基于 ASP.NET Identity 的密码哈希的实现。我基于这个问题设计了这个功能。

访问令牌生成

访问令牌是包含 API 用于验证特定用户数据以及客户端应用程序数据的声明的令牌。它们在 HTTP 请求头中发送。

该示例 API 生成的访问令牌包含以下数据:

  • 一个可用于访问受保护的 API 端点的编码令牌,它包含一个到期日期(在本例中,创建令牌后 30 秒)和一个包含用户声明以及有效受众和发行者的有效载荷。
  • 一个刷新令牌,它由一个编码令牌组成,其到期日在访问令牌到期日(本例中为 60 秒)之后,允许客户端应用程序在一个有效的访问令牌到期时请求一个新的访问令牌。
  • 访问令牌的过期日期(仅用于客户端验证目的)。

要获得访问令牌,请向以下 API 端点发出 POST 请求,在请求主体中发送用户凭证:

/api/login
[Route("/api/login")]
[HttpPost]
public async Task<IActionResult> LoginAsync([FromBody] UserCredentialsResource userCredentials)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var response = await _authenticationService.CreateAccessTokenAsync(userCredentials.Email, userCredentials.Password);
    if(!response.Success)
    {
        return BadRequest(response.Message);
    }

    var accessTokenResource = _mapper.Map<AccessToken, AccessTokenResource>(response.Token);
    return Ok(accessTokenResource);
}

认证服务使用 ITokenHandler 接口的实现来生成令牌。这个接口定义了创建访问令牌、获取刷新令牌和撤销刷新令牌的方法(后面这两个功能稍后会解释)。

该服务还依赖 IPasswordHasher 进行用户凭证验证。

public async Task<TokenResponse> CreateAccessTokenAsync(string email, string password)
{
    var user = await _userService.FindByEmailAsync(email);

    if (user == null || !_passwordHasher.PasswordMatches(password, user.Password))
    {
        return new TokenResponse(false, "Invalid credentials.", null);
    }

    var token = _tokenHandler.CreateAccessToken(user);

    return new TokenResponse(true, null, token);
}

令牌处理程序使用 appsettings.json 中的一些配置和用户数据来生成令牌。


"TokenOptions": {
  "Audience": "SampleAudience",
  "Issuer": "JWPAPI",
  "AccessTokenExpiration": 30,
  "RefreshTokenExpiration": 60
},

在本节中可以看到访问令牌和刷新令牌的受众、发行者和到期时间是如何定义的。

过期时间是以秒为单位设置的。这些配置通过 ASP.NET Core 依赖注入机制加载到 TokenOptionsclass 的实例中。

这里是生成访问令牌的方法的实现:

public AccessToken CreateAccessToken(User user)
{
        var refreshToken = BuildRefreshToken(user);
        var accessToken = BuildAccessToken(user, refreshToken);
        _refreshTokens.Add(refreshToken);

        return accessToken;
}

首先,处理程序生成一个刷新令牌,该令牌由一个随机哈希和一个高于访问令牌到期日的日期组成。

private RefreshToken BuildRefreshToken(User user)
{
    var refreshToken = new RefreshToken
    (
        token : _passwordHaser.HashPassword(Guid.NewGuid().ToString()),
        expiration : DateTime.UtcNow.AddSeconds(_tokenOptions.RefreshTokenExpiration).Ticks
    );

    return refreshToken;
}

然后,一个 JwtSecurityTokenclass 实例生成访问令牌,一个 JwtSecurityTokenHandler 实例创建一个编码字符串来表示我们的 JSON 对象。这两个类都是 System.IdentityModel.Token.Jwt 命名空间的一部分。

private AccessToken BuildAccessToken(User user, RefreshToken refreshToken)
{
    var accessTokenExpiration = DateTime.UtcNow.AddSeconds(_tokenOptions.AccessTokenExpiration);

    var securityToken = new JwtSecurityToken
    (
        issuer : _tokenOptions.Issuer,
        audience : _tokenOptions.Audience,
        claims : GetClaims(user),
        expires : accessTokenExpiration,
        notBefore : DateTime.UtcNow,
        signingCredentials : _signingConfigurations.SigningCredentials
    );

    var handler = new JwtSecurityTokenHandler();
    var accessToken = handler.WriteToken(securityToken);

    return new AccessToken(accessToken, accessTokenExpiration.Ticks, refreshToken);
}

claims 参数得到 GetClaims 方法的结果,负责生成以下声明:

  • 一个是带有令牌标识符的申请。
  • 另一个是用户的电子邮件。
  • 代表用户权限(角色)的要求。
private IEnumerable<Claim> GetClaims(User user)
{
    var claims = new List<Claim>
    {
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim(JwtRegisteredClaimNames.Sub, user.Email)
    };

    foreach (var userRole in user.UserRoles)
    {
        claims.Add(new Claim(ClaimTypes.Role, userRole.Role.Name));
    }

    return claims;
}

还请注意参数 signingCredentials。这个参数代表令牌签名,它是验证令牌所需要的。

一个来自 Microsoft.IdentityModel.Tokens 的 SigningCredentials 实例负责生成签名。

SigningConfigurations 的实例封装了签名凭证:

public class SigningConfigurations
{
    public SecurityKey Key { get; }
    public SigningCredentials SigningCredentials { get; }

    public SigningConfigurations()
    {
        using(var provider = new RSACryptoServiceProvider(2048))
        {
            Key = new RsaSecurityKey(provider.ExportParameters(true));
        }

        SigningCredentials = new SigningCredentials(Key, SecurityAlgorithms.RsaSha256Signature);
    }
}

该类的初始化发生在应用程序启动期间。它定义了以下属性:

  • Key:用于验证令牌签名的安全密钥。该类是 Microsoft.IdentityModel.Tokens 命名空间的一部分。密钥由一个 2048 位的安全密钥组成。
  • SigningCredentials:令牌签名。凭证使用安全密钥和 RSA SHA 256 算法的实例生成。也可以使用不同的算法,比如 HMAC 算法。

回到令牌生成过程,下一步包括将生成的刷新令牌添加到一个包含所有有效刷新令牌的集合中。当 API 收到新的访问令牌请求时,会检查这个集合,以检查刷新令牌是否仍然有效。

private readonly ISet<RefreshToken> _refreshTokens = new HashSet<RefreshToken>();

在现实世界的应用中,将刷新令牌保持在一组内存中并不是一个可行的解决方案(除非你使用一些分布式缓存策略和工具,比如 Redis)。

一种常见的方法是将刷新令牌存储在数据库中。在本例的范围内,这并不是必要的,因为我们只会有少量的令牌传入请求。

注意:如果你要把刷新令牌存储在数据库中,一定要加密。让然后不加密是一个很大的安全问题。

最后,API 创建一个 AccessTokenclass 的实例并将其返回给控制器,控制器将访问令牌映射到资源类。

public class AccessToken : JsonWebToken
{
    public RefreshToken RefreshToken { get; private set; }

    public AccessToken(string token, long expiration, RefreshToken refreshToken) : base(token, expiration)
    {
        if(refreshToken == null)
            throw new ArgumentException("Specify a valid refresh token.");

        RefreshToken = refreshToken;
    }
}

访问受保护的 API 端点

一旦你手中有一个有效的令牌,你就可以访问受保护的 API 资源。

有两个受保护的端点可以测试这个功能。

/api/protectedforcommonusers

上述端点为任何经过认证的用户返回一个简单的字符串(这意味着请求的头中必须有一个有效的访问令牌)。

/api/protectedforadministrators

第二个端点只有在认证用户是管理员时才返回一个简单的字符串。

端点通过使用 Authorize 属性来验证权限。

public class ProtectedController : Controller
{
    [HttpGet]
    [Authorize]
    [Route("/api/protectedforcommonusers")]
    public IActionResult GetProtectedData()
    {
        return Ok("Hello world from protected controller.");
    }

    [HttpGet]
    [Authorize(Roles = "Administrator")]
    [Route("/api/protectedforadministrators")]
    public IActionResult GetProtectedDataForAdmin()
    {
        return Ok("Hello admin!.");
    }
}

ASP.NET Core 管道使用在 Startup.cs 中定义的中间件和安全服务来执行权限验证。

services.Configure<TokenOptions>(Configuration.GetSection("TokenOptions"));
var tokenOptions = Configuration.GetSection("TokenOptions").Get<TokenOptions>();

var signingConfigurations = new SigningConfigurations();
services.AddSingleton(signingConfigurations);

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(jwtBearerOptions =>
    {
        jwtBearerOptions.TokenValidationParameters = new TokenValidationParameters()
        {
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = tokenOptions.Issuer,
            ValidAudience = tokenOptions.Audience,
            IssuerSigningKey = signingConfigurations.Key,
            ClockSkew = TimeSpan.Zero
        };
    });

首先,API 会从 appsettings.json 中加载令牌配置选项。

然后,加载令牌签名类,并将其注册为一个单例。

最后,还调用了扩展方法 AddAuthentication,即把默认的验证方案设置为 JSON Web Tokens 方案,以及 AddJwtBeares,负责配置令牌验证。

验证参数来自 TokenValidationParametersclass,它是 Microsoft.IdentityModel.Tokens 命名空间的一部分。这里配置了以下参数:

  • ValidAudience:表示向我们的 API 发送请求的应用程序必须与 appsettings.json 中指定的相同。
  • ValidIssuer:表示令牌发行者必须与 appsettings.json 中指定的发行者一致。
  • ValidateIssuerSigningKey:告诉 API 验证来自发行商应用的令牌的安全密钥。
  • IssuerSigningKey:签署令牌的安全密钥。
  • ValidateAudience:告诉我们的 API 检查消耗我们令牌的应用是否与 appsettings.json 中定义的应用相同。
  • ValidateLifetime:表示需要验证令牌的到期日期。 ClockSkew:检查令牌是否有效的有效延迟时间。

为了使令牌验证正确地工作,有必要在为我们的应用配置路由的中间件之前使用 UseAuthenticationmiddleware。

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseAuthentication();
    app.UseMvc();
}

要访问受保护的端点,你必须在你的请求中添加以下头:

Authorization: Bearer valid_access_token

想象一下,你为一个属于 Common 角色的用户请求了一个令牌。如果你指定了如上的认证头,并尝试从端点/api/protectedforcommonusers 获取响应,你将收到一个状态为 200(OK)的响应,以及响应体中的一个字符串。

token

如果您尝试使用相同的令牌从管理员端点获取数据,您将收到 403 状态(禁止)的响应,这表明您无权访问此内容。

token

如果你试图从管理员端点获取数据,在请求头中发送一个有效的管理员访问令牌,API 将发送一个 200 状态(Ok)的响应,并在响应体中发送一个字符串。

token

如果您从这些端点中的一个端点请求数据,发送无效令牌(过期的令牌,其字符已被某人更改),或发送请求时未在请求头中指定令牌,您将收到状态为 401(未授权)的响应。

token

刷新令牌

现在想象一下,您正在开发一个单页应用程序或移动应用程序,您不希望用户每次在他们的 "部分 "过期时(在这种情况下,每次访问令牌过期时)都必须登录。为了避免这个问题,可以使用有效的刷新令牌在幕后向 API 请求新的访问令牌。

要从刷新令牌请求新的访问令牌,请向 /api/token/refresh 发出 POST 请求。在请求主体中,您需要指定一个有效的刷新令牌,API 将使用该令牌来创建新的访问令牌。


[Route("/api/token/refresh")]
[HttpPost]
public async Task<IActionResult> RefreshTokenAsync([FromBody] RefreshTokenResource refreshTokenResource)
{
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }

    var response = await _authenticationService.RefreshTokenAsync(refreshTokenResource.Token, refreshTokenResource.UserEmail);
    if(!response.Success)
    {
        return BadRequest(response.Message);
    }

    var tokenResource = _mapper.Map<AccessToken, AccessTokenResource>(response.Token);
    return Ok(tokenResource);
}

认证服务实现了 RefreshTokenAsyncmethod,它接收一个刷新令牌和用户电子邮件作为参数。

这些参数对于正确生成新的访问令牌的请求是必要的。


public async Task<TokenResponse> RefreshTokenAsync(string refreshToken, string userEmail)
{
    var token = _tokenHandler.TakeRefreshToken(refreshToken);

    if (token == null)
    {
        return new TokenResponse(false, "Invalid refresh token.", null);
    }

    if (token.IsExpired())
    {
        return new TokenResponse(false, "Expired refresh token.", null);
    }

    var user = await _userService.FindByEmailAsync(userEmail);
    if (user == null)
    {
        return new TokenResponse(false, "Invalid refresh token.", null);
    }

    var accessToken = _tokenHandler.CreateAccessToken(user);
    return new TokenResponse(true, null, accessToken);
}

首先,这个方法从令牌处理程序中请求完整的刷新令牌,它检查刷新令牌是否存在于刷新令牌集中,并在将其返回给我们的身份验证服务之前从那里删除(毕竟,我们不希望刷新令牌可以被多次使用)。


public RefreshToken TakeRefreshToken(string token)
{
    if (string.IsNullOrWhiteSpace(token))
        return null;

    var refreshToken = _refreshTokens.SingleOrDefault(t => t.Token == token);
    if (refreshToken != null)
        _refreshTokens.Remove(refreshToken);

    return refreshToken;
}

然后,该方法会验证刷新令牌是否真的存在(如果它包含在刷新令牌集中),是否没有过期,以及用户的电子邮件是否有效。

如果一切正常,我们的 API 会根据用户数据生成一个新的访问令牌并返回。

撤销刷新令牌

回到我们的自动访问令牌请求流程,想象一下,现在你要创建签出功能。当用户签出时,可能会发生我们的 API 在令牌集上还有一个有效的对应刷新令牌。我们需要一种方法来撤销这个令牌,以避免安全问题。

API 有/api/token/revoke 端点。向这个端点发送 POST 请求,并在请求体上添加有效的刷新令牌,会导致我们的 API 调用 RevokeRefreshTokenmethod。这个方法调用同名的令牌处理方法,从集合中移除令牌。

[Route ("/api/token/revoke")]
[HttpPost]
public IActionResult RevokeToken ([FromBody] RevokeTokenResource revokeTokenResource) {
    if (!ModelState.IsValid) {
        return BadRequest (ModelState);
    }

    _authenticationService.RevokeRefreshToken (revokeTokenResource.Token);
    return NoContent ();
}
public void RevokeRefreshToken(string refreshToken)
{
    _tokenHandler.RevokeRefreshToken(refreshToken);
}

总结

当你使用新的令牌生成和验证机制时,在 ASP.NET Core 应用程序中通过令牌实现认证和授权是很容易的。

我创建这个例子的目的是为了帮助那些不知道 JSON Web Token 是什么的人,或者对如何通过 API 实现认证和授权有疑问的人,目标是多个客户端应用程序。

要在生产中实现这个解决方案,请记住,你必须改变刷新令牌的实现,以使用不同的存储机制和处理并发。你还应该使用用户秘密作为存储令牌配置的方式,而不是 appsettings.json。另外,考虑在向 API 发送数据时对用户密码进行加密。