JSON 网络令牌(JWT)是一个紧凑和 URL 安全的字符串,它以行业标准 RFC 7519 定义的特定格式表示声明。JWT 是一种在双方之间安全传输声明的标准方法。人们通常在网络应用和移动应用中使用 JWT 作为身份证明。
在本文中,我将向您展示如何使用 JWT 认证和授权来实现一个 ASP.NET Core Web API 应用程序。这个 web API 应用程序实现了登录、注销、刷新令牌、冒充等过程。下面的截图显示了我们在本文中要讲解的 API 端点。
我把我的解决方案分成两个部分:一个是 Angular 的前端应用,一个是 ASP.NET Core 的后端应用。你可以在我的 GitHub 仓库中找到完整的解决方案。前端和后端应用都支持 Docker,你也可以使用 Docker Compose 在 Linux 容器中同时运行它们。
在本文中,我们将重点介绍后端解决方案,其中包括两个项目。JwtAuthDemo 和 JwtAuthDemo.IntegrationTests。集成测试项目涵盖了 Web API 项目中所有常规的 JWT 进程。
开发者意见很大,Web 和移动原生应用不一样,业务场景也不一样,所以我们看到使用 JWT 的方法多种多样。但最常见的 JWT 流程工作原理如下:
从流程中我们知道,JWT 的安全性至关重要,所以人们通常建议通过 HTTPS 发送 JWT,而且 JWT 访问令牌应该是短暂的,不应该包含敏感数据。
为了简单起见,我在上面的流程中省略了刷新令牌的过程。通常,在步骤 2 中,一个随机的字符串,刷新令牌,会和 JWT 访问令牌一起生成。当 JWT 访问令牌即将到期时,客户端将刷新令牌发送给服务器端,以获得新的 JWT 访问令牌。建议系统应该将新的刷新令牌和新的访问令牌一起返回。因此,应用程序不再有一个长期存在的刷新令牌。这种技术被称为刷新令牌旋转。
注意到最佳做法总是随着时间的推移和技术的进步而发展。
为了演示的目的,我们创建一个 ASP.NET Core Web API 项目 JwtAuthDemo 和一个 MS 测试项目 JwtAuthDemo.IntegrationTests。我们将首先为我们的 Web API 项目配置 JWT 认证。然后我们将实现登录、注销和刷新令牌的过程。
我们通常希望自定义并保护应用程序生成的 JWT 访问令牌。在下面的 JwtTokenConfig 类中定义了一组常见的配置。
public class JwtTokenConfig
{
public string Secret { get; set; }
public string Issuer { get; set; }
public string Audience { get; set; }
public int AccessTokenExpiration { get; set; }
public int RefreshTokenExpiration { get; set; }
}
属性 Secret 是一个需要保存在安全地方的字符串,例如,应用池用户的环境变量,或者云秘密存储或密钥库。AccessTokenExpiration 和 RefreshTokenExpiration 是两个整数,代表令牌生成后的总寿命。根据本演示项目的实现,时间以分钟为单位。为了简单起见,我们将把参数存储在 appsettings.json 文件中。然后,我们准备将这些值传递到 JWT Bearer 配置中。
好消息是,使用 JWT 令牌进行身份验证是很直接的,Microsoft.AspNetCore.Authentication.JwtBearer NuGet 包为我们完成了大部分工作。当我们安装了这个 NuGet 包的最新版本后,我们有两种选择来配置 JWT 认证。
在这里,我们将通过第二种方法配置 JWT Bearer 认证。下面的代码片段显示了一个 ConfigureServices 方法的例子。
public void ConfigureServices(IServiceCollection services)
{
var jwtTokenConfig = Configuration.GetSection("jwtTokenConfig").Get<JwtTokenConfig>();
services.AddSingleton(jwtTokenConfig);
services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(x =>
{
x.RequireHttpsMetadata = true;
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = jwtTokenConfig.Issuer,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtTokenConfig.Secret)),
ValidAudience = jwtTokenConfig.Audience,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
};
});
// ...
}
在上面的代码中,第 2 行和第 3 行读取我们的设置,并将 JwtTokenConfig 注册为依赖注入(DI)容器中的 Singleton。
第 5 行到第 8 行将默认的身份验证和挑战方案设置为该应用程序中的 Bearer。第 9 到 24 行配置 JWT Bearer 令牌,尤其是令牌验证参数。我想指出以下属性:
然后我们继续在 Startup.Configure 方法中添加 app.UseAuthentication()方法,如下图:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
// ...
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
}
认证中间件,第 5 行,是使注册的认证方案(本例中为 JWT Bearer)工作的关键。另一方面,授权中间件,第 6 行,是使注册的授权机制工作的关键。在本项目中,我们使用默认的基于角色的授权。第 5 行和第 6 行都是需要的,这样我们才能在控制器和动作方法上使用[Authorize]属性。此外,请注意,中间件的顺序很重要。
好了,基础工作已经打好了,我迫不及待地给大家展示令牌的生成和登录过程。
public class JwtAuthManager : IJwtAuthManager
{
public IImmutableDictionary<string, RefreshToken> UsersRefreshTokensReadOnlyDictionary => _usersRefreshTokens.ToImmutableDictionary();
private readonly ConcurrentDictionary<string, RefreshToken> _usersRefreshTokens; // can store in a database or a distributed cache
private readonly JwtTokenConfig _jwtTokenConfig;
private readonly byte[] _secret;
public JwtAuthManager(JwtTokenConfig jwtTokenConfig)
{
_jwtTokenConfig = jwtTokenConfig;
_usersRefreshTokens = new ConcurrentDictionary<string, RefreshToken>();
_secret = Encoding.ASCII.GetBytes(jwtTokenConfig.Secret);
}
public JwtAuthResult GenerateTokens(string username, Claim[] claims, DateTime now)
{
var shouldAddAudienceClaim = string.IsNullOrWhiteSpace(claims?.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Aud)?.Value);
var jwtToken = new JwtSecurityToken(
_jwtTokenConfig.Issuer,
shouldAddAudienceClaim ? _jwtTokenConfig.Audience : string.Empty,
claims,
expires: now.AddMinutes(_jwtTokenConfig.AccessTokenExpiration),
signingCredentials: new SigningCredentials(new SymmetricSecurityKey(_secret), SecurityAlgorithms.HmacSha256Signature));
var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken);
var refreshToken = new RefreshToken
{
UserName = username,
TokenString = GenerateRefreshTokenString(),
ExpireAt = now.AddMinutes(_jwtTokenConfig.RefreshTokenExpiration)
};
_usersRefreshTokens.AddOrUpdate(refreshToken.TokenString, refreshToken, (s, t) => refreshToken);
return new JwtAuthResult
{
AccessToken = accessToken,
RefreshToken = refreshToken
};
}
private static string GenerateRefreshTokenString()
{
var randomNumber = new byte[32];
using var randomNumberGenerator = RandomNumberGenerator.Create();
randomNumberGenerator.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}
在 JwtAuthManager 类中,我们保存一个字典_usersRefreshTokens 作为刷新令牌的缓存。另外,我们也可以将刷新令牌保存在数据库或分布式缓存存储中。在服务器端保存刷新令牌的副本,可以让系统对刷新令牌进行验证,并查询用户会话的元数据。
GenerateTokens 方法创建一个 JWT 访问令牌和一个刷新令牌。我们在 JWT 访问令牌中向有效载荷中传递用户请求,并为 JWT 令牌验证参数设置适当的值。刷新令牌是一个简单的随机字符串,但我们也用到期时间和用户名来丰富 RefreshToken 对象。我们还可以进一步给 RefreshToken 对象附加其他元数据,比如客户端 IP、用户代理、设备 ID 等,这样我们就可以识别和监控用户会话,检测欺诈性的令牌。
注意事项:注意第 17 行和第 20 行将防止令牌多次刷新时令牌变长。JWT 令牌变长的原因是 aud 声明是一个数组,并不断向其追加新值。当然,还有其他方法可以保持 aud 声明的干净。
由于 JwtAuthManager 类没有 Scoped 或 Transient 依赖,我们可以在 DI 容器中把它注册为一个 Singleton。然后我们可以将 JwtAuthManager 注入到 AccountController 中,由 AccountController 执行 Login 动作。下面的代码片段显示了 AccountController 和 Login 动作方法。
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class AccountController : ControllerBase
{
private readonly ILogger<AccountController> _logger;
private readonly IUserService _userService;
private readonly IJwtAuthManager _jwtAuthManager;
public AccountController(ILogger<AccountController> logger, IUserService userService, IJwtAuthManager jwtAuthManager)
{
_logger = logger;
_userService = userService;
_jwtAuthManager = jwtAuthManager;
}
[AllowAnonymous]
[HttpPost("login")]
public ActionResult Login([FromBody] LoginRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest();
}
if (!_userService.IsValidUserCredentials(request.UserName, request.Password))
{
return Unauthorized();
}
var role = _userService.GetUserRole(request.UserName);
var claims = new[]
{
new Claim(ClaimTypes.Name,request.UserName),
new Claim(ClaimTypes.Role, role)
};
var jwtResult = _jwtAuthManager.GenerateTokens(request.UserName, claims, DateTime.Now);
_logger.LogInformation($"User [{request.UserName}] logged in the system.");
return Ok(new LoginResult
{
UserName = request.UserName,
Role = role,
AccessToken = jwtResult.AccessToken,
RefreshToken = jwtResult.RefreshToken.TokenString
});
}
}
在上面的代码中,我们首先在第 26 到 29 行使用 UserService 验证登录凭证。然后,我们在第 31 行到 36 行中生成申请。第 38 行调用 JwtAuthManager 类中的 GenerateTokens 方法来获取访问令牌和刷新令牌。最后,Login 方法返回一个包含令牌的对象给客户端。
当 JWT 令牌被发回客户端后,它们被存储在客户端。当客户端想要注销时,我们可以通过删除 cookie 或 localStorage 中的令牌来删除令牌。然而,用户可能仍然能够持有访问令牌。通常情况下,风险很低,因为访问令牌会在一小段时间后失效。如果你仍然想在服务器端使 JWT 访问令牌失效,那么你可以在这个 StackOverflow 讨论和这个 GitHub 问题中阅读更多内容,其中提出了一个块列表策略。
在这个项目中,我们将不使用访问令牌,但我们将在服务器端使刷新令牌无效。在 AccountController 中,我们添加 Logout 方法如下。
[HttpPost("logout")]
[Authorize]
public ActionResult Logout()
{
var userName = User.Identity.Name;
_jwtAuthManager.RemoveRefreshTokenByUserName(userName); // can be more specific to ip, user agent, device name, etc.
_logger.LogInformation($"User [{userName}] logged out the system.");
return Ok();
}
在这个 Logout 方法中,我们首先要得到当前的用户名(如果我们把 ID 保存在 claim 中,也可以得到用户 ID 来识别用户)。根据用户名,我们可以删除用户的刷新令牌,这样用户就无法刷新他/她的会话,直到重新登录。
请注意,基于用户名删除刷新令牌并不理想,因为即使用户使用两个浏览器,或者一个在桌面,一个在移动,也会注销该用户的所有会话。因此,为了改善用户体验,我们应该只删除特定的令牌(例如,基于用户代理和客户端 IP),这可以在请求主体或头文件中识别。
有些移动应用只需要登录一次,所以刷新 JWT 访问令牌的意义不大。但对于大多数 Web 应用来说,刷新访问令牌是强制性的。当访问令牌即将到期时,客户端通常会触发刷新令牌操作。当这种情况发生时,客户端会向 API 端点发送一个 RefreshToken。下面的代码片段显示了 AccountController 类中的一个 API 动作方法示例:
[HttpPost("refresh-token")]
[Authorize]
public async Task<ActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
try
{
var userName = User.Identity.Name;
_logger.LogInformation($"User [{userName}] is trying to refresh JWT token.");
if (string.IsNullOrWhiteSpace(request.RefreshToken))
{
return Unauthorized();
}
var accessToken = await HttpContext.GetTokenAsync("Bearer", "access_token");
var jwtResult = _jwtAuthManager.Refresh(request.RefreshToken, accessToken, DateTime.Now);
_logger.LogInformation($"User [{userName}] has refreshed JWT token.");
return Ok(new LoginResult
{
UserName = userName,
Role = User.FindFirst(ClaimTypes.Role)?.Value ?? string.Empty,
AccessToken = jwtResult.AccessToken,
RefreshToken = jwtResult.RefreshToken.TokenString
});
}
catch (SecurityTokenException e)
{
return Unauthorized(e.Message); // return 401 so that the client side can redirect the user to login page
}
}
在上面的代码中,还验证了原始的访问令牌,以确保刷新令牌和访问令牌是配对的。API 响应体包含一个类似于 Login 方法结果的对象(第 18-24 行),这样就可以正确还原申请和令牌。如果在刷新过程中发生任何异常,那么 API 将返回一个 Unauthorized HTTP 状态码(26-29 行),这样客户端就可以将用户重定向到登录页面。
第 16 行是神奇发生的地方。Refresh 方法的实现如下所示:
public JwtAuthResult Refresh(string refreshToken, string accessToken, DateTime now)
{
var (principal, jwtToken) = DecodeJwtToken(accessToken);
if (jwtToken == null || !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature))
{
throw new SecurityTokenException("Invalid token");
}
var userName = principal.Identity.Name;
if (!_usersRefreshTokens.TryGetValue(refreshToken, out var existingRefreshToken))
{
throw new SecurityTokenException("Invalid token");
}
if (existingRefreshToken.UserName != userName || existingRefreshToken.ExpireAt < now)
{
throw new SecurityTokenException("Invalid token");
}
return GenerateTokens(userName, principal.Claims.ToArray(), now); // need to recover the original claims
}
public (ClaimsPrincipal, JwtSecurityToken) DecodeJwtToken(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
throw new SecurityTokenException("Invalid token");
}
var principal = new JwtSecurityTokenHandler()
.ValidateToken(token,
new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = _jwtTokenConfig.Issuer,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(_secret),
ValidAudience = _jwtTokenConfig.Audience,
ValidateAudience = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromMinutes(1)
},
out var validatedToken);
return (principal, validatedToken as JwtSecurityToken);
}
在上面的代码中,我们首先对 JWT 访问令牌进行解码,以确认得到一个真实的身份。DecodeJwtToken 方法中的参数应该与 Startup.cs 文件中的 TokenValidationParameters 相匹配。我还包含了 SecurityTokenExpiredException、SecurityTokenInvalidSignatureException 和 SecurityTokenException 的几个集成测试,这样我们就可以更好的理解 token 的验证。
我们需要解码原始 JWT 访问令牌的另一个原因是,我们需要恢复原始令牌中的所有请求。然后,我们可以生成一个新的访问令牌,并有一个适当的有效载荷。
由于我们采用的是刷新令牌轮换技术,服务器可能需要保留大量的刷新令牌及其元数据。在我的演示方案中,我实现了一个后台作业(代码未在此显示),它每分钟运行一次,以删除内存中过期的刷新令牌。你可以访问我的 GitHub 仓库(链接)了解更多细节。
如果将刷新的令牌保存在数据库中或者分布式缓存中,为了提高查找效率,值得考虑类似的事情。
有时我们想冒充某个特定的用户进行测试或调试。在我的演示方案中,我已经实现了冒充和停止冒充的 API。主要的技巧是跟踪原始用户的诉求。当然,为了使冒充过程充分发挥作用,需要做一些前端工作。
ASP.NET Core 中的集成测试并不复杂。我们首先创建一个测试主机,然后我们将有一个测试 HttpClient 和 ServiceProvider 在手。这样,我们就可以在 HTTP 头中添加 Bearer 令牌,并向我们的 API 端点发送 HTTP 请求。最后,我们可以验证响应代码和内容。
如上所述,JWT 令牌应该是通过 HTTPS 传输的。在开发模式下,我们应该已经通过 ASP.NET Core 为 localhost 设置了开发者的 SSL 证书,这样我们就可以使用 HTTPS 地址来启动应用程序。
当我们对 ASP.NET Core Web 应用进行 docker 化时,我们需要将证书的私钥映射到 Docker 容器中。在我的 GitHub 仓库中,我已经包含了一个 pfx 文件,我们应该能够通过 Docker Compose 轻松启动应用程序。