JWT Auth in ASP.NET Core
翻译自:《JWT Auth in ASP.NET Core》(https://codeburst.io/jwt-auth-in-asp-net-core-148fb72bed03)。
在这篇文章,我将展示怎么样在 ASP.NET Core web API 应用程序中使用 JWT 认证和鉴权。这个web API 应用程序实现处理如登录,登出,刷新 token 等。下面的图片显示我们将用到的 API 接口。
我把我的解决方案分成两个部分:前端程序使用 Angular,后端项目使用 ASP.NET Core。你能找到完整的解决方案在我的 GitHub 仓库中(https://github.com/dotnet-labs/JwtAuthDemo)。前端和后端程序都支持 Docker,你可以在 Linux 中使用 Docker Compose 同时运行他们。
在这篇文章中,我们将重点关注后端实现。后端包括两个项目 JwtAuthDemo 和 JwtAuthDemo.IntegrationTests。在 integration testing 中二次型覆盖了常用的 JWT 场景。
通常怎么样使用 JWT
网页和手机 APP 的业务场景有很明显的差异,我们有很多方式使用 JWT。但是通常 JWT 使用流程如下:
1. 一个用户发送登录请求。
2. 后端站点验证通过这个登录请求后,按需要声明一些 claims 再生成一个 JWT Token 返回给用户。
3. 这个用户拿着这个 JWT Token 请求其它接口直到过期。
4. 后端站点验证这个 JWT Token 是否有权限访问这个资源,然后做相应处理。
通过这个介绍,我们知道 JWT 的安全性很重要。所以人们通常建议使用HTTPS发送请求,并且 Token 的有效时间设置得很短,JWT 中不包括敏感数据。
为了简单,上面的介绍中我省略了刷新 Token 的处理流程。通常第二步中一个随机字符串和 Refresh Token 和 JWT 一起生成。当这个JWT 访问 Token 快过期时,客户端将发送这个 Refresh Token 到服务端得到一个新的 JWT 访问token。建议系统同时返回一个新的 Refresh Token。因此这个应用程序不再有长期有效的 refresh token。
注意最佳实践总是随着时间改进。
这个 demo 中,我们创建一个 ASP.NET Core web API 命名为:JwtAuthDemo 和一个测试项目 JwtAuthDemo.IntegrationTests。我们将首先为我们的 web API 项目配置 JWT 认证。然后我们将实现登录,登出和刷新 token 的处理。
JWT 认证配置
我们一般会自定义和保护通过我们的应用程序生成 JWT 访问tokens。一组通常的配置已经被定义在 JwtTokenConfig 类中。
1 public class JwtTokenConfig 2 { 3 public string Secret { get; set; } 4 public string Issuer { get; set; } 5 public string Audience { get; set; } 6 public int AccessTokenExpiration { get; set; } 7 public int RefreshTokenExpiration { get; set; } 8 }
Secret 属性是一个需要放在一个安全的地方的字符串。例如:自助这应用程序的环境变量中或者云端的加密存储,或者密钥库。这个 AccessTokenExpiration 和 RefreshTokenExpiration 是两个整型表示这个 token 生成后总的有效时长。在这个 demo 里时间是以分钟为单位内。为了简单,我们将存储这些参数在 appsettings.json 文件里。我们准备使用这些值配置 JWT Bearer。
好消息是 JWT 认证很简单在 Microsoft.AspNetCore.Authentication.JwtBearer NuGet 包中已经实现了很多功能。我们安装这个的最新版。我们有两种方式配置 JWT 认证:1. 在 Startup.Configure 方法中使用 app.UseJwtBearerAuthentication() 中间件;2. 在 Startup.ConfigureServices 方法中使用 services.AddJwtBearer() 方法注册 JWT 认证 scheme。
这里我们将使用第二种方法配置 JWT Bearer 认证。下面的代码显示了配置的代码块。
1 public void ConfigureServices(IServiceCollection services) 2 { 3 var jwtTokenConfig = Configuration.GetSection("jwtTokenConfig").Get<JwtTokenConfig>(); 4 services.AddSingleton(jwtTokenConfig); 5 services.AddAuthentication(x => 6 { 7 x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 8 x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 9 }).AddJwtBearer(x => 10 { 11 x.RequireHttpsMetadata = true; 12 x.SaveToken = true; 13 x.TokenValidationParameters = new TokenValidationParameters 14 { 15 ValidateIssuer = true, 16 ValidIssuer = jwtTokenConfig.Issuer, 17 ValidateIssuerSigningKey = true, 18 IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtTokenConfig.Secret)), 19 ValidAudience = jwtTokenConfig.Audience, 20 ValidateAudience = true, 21 ValidateLifetime = true, 22 ClockSkew = TimeSpan.FromMinutes(1) 23 }; 24 }); 25 // ... 26 }
在上面的代码中,第3到4行读取配置并以单例形式注册 JwtTokenConfig 到依赖注入容器中。
第5到第8行设置认证的默认架构为 Bearer。第9到24行配置 JWT Bearer token,指定 token 的验证参数。
RequireHttpsMetadata:默认值为 true, 意思是认证请求需要使用 HTTPS 形式。
SaveToken:默认值为 true,意思是保存 JWT 访问 token 在当前的 HttpContext,方便我们可以使用 await HttpContext.GetTokenAsync(“Bearer”, “access_token”) 得到值。如果把值设置为 false,我们可以保存 JWT access token 在 claims 并使用 User.FindFirst("access_token")?.Value 得到值。
TokenValidationParameters:这个对象用于验证 identity tokens。注意这里设置 ClockSkew 属性为1分钟,表示在token过期后的一分钟内仍有效。
我们添加一个 app.UseAuthentication() 方法在 Startup.Configure 方法中,如下:
1 public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 2 { 3 // ... 4 app.UseRouting(); 5 app.UseAuthentication(); 6 app.UseAuthorization(); 7 8 app.UseEndpoints(endpoints => 9 { 10 endpoints.MapControllers(); 11 }); 12 }
第5行 Authentication
中间件用于认证用户。第6行 Authorization 中间件用于识别用户的权限。在这个项目中,我们使用默认基于角色授权。我们可以在 controllers 和 action 使用 [Authorize] 特性。注意他们的顺序不能变。
Token 生成和登录
我们将创建一个 JwtAuthManager 工具类。用来维护 JWT的访问 tokens 和 refresh tokens。代码如下:
1 public class JwtAuthManager : IJwtAuthManager 2 { 3 public IImmutableDictionary<string, RefreshToken> UsersRefreshTokensReadOnlyDictionary => _usersRefreshTokens.ToImmutableDictionary(); 4 private readonly ConcurrentDictionary<string, RefreshToken> _usersRefreshTokens; // can store in a database or a distributed cache 5 private readonly JwtTokenConfig _jwtTokenConfig; 6 private readonly byte[] _secret; 7 8 public JwtAuthManager(JwtTokenConfig jwtTokenConfig) 9 { 10 _jwtTokenConfig = jwtTokenConfig; 11 _usersRefreshTokens = new ConcurrentDictionary<string, RefreshToken>(); 12 _secret = Encoding.ASCII.GetBytes(jwtTokenConfig.Secret); 13 } 14 15 public JwtAuthResult GenerateTokens(string username, Claim[] claims, DateTime now) 16 { 17 var shouldAddAudienceClaim = string.IsNullOrWhiteSpace(claims?.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Aud)?.Value); 18 var jwtToken = new JwtSecurityToken( 19 _jwtTokenConfig.Issuer, 20 shouldAddAudienceClaim ? _jwtTokenConfig.Audience : string.Empty, 21 claims, 22 expires: now.AddMinutes(_jwtTokenConfig.AccessTokenExpiration), 23 signingCredentials: new SigningCredentials(new SymmetricSecurityKey(_secret), SecurityAlgorithms.HmacSha256Signature)); 24 var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken); 25 26 var refreshToken = new RefreshToken 27 { 28 UserName = username, 29 TokenString = GenerateRefreshTokenString(), 30 ExpireAt = now.AddMinutes(_jwtTokenConfig.RefreshTokenExpiration) 31 }; 32 _usersRefreshTokens.AddOrUpdate(refreshToken.TokenString, refreshToken, (s, t) => refreshToken); 33 34 return new JwtAuthResult 35 { 36 AccessToken = accessToken, 37 RefreshToken = refreshToken 38 }; 39 } 40 41 private static string GenerateRefreshTokenString() 42 { 43 var randomNumber = new byte[32]; 44 using var randomNumberGenerator = RandomNumberGenerator.Create(); 45 randomNumberGenerator.GetBytes(randomNumber); 46 return Convert.ToBase64String(randomNumber); 47 } 48 }
在 JwtAuthManager 类中,我们使用一个字典 _usersRefreshTokens 缓存 refresh tokens。我们也可以把刷新 refresh tokens 保存到数据库中或者分步式缓存中。保存一份 refresh tokens 在服务端让系统可以验证 refresh tokens 和查找用户会话相关数据。
GenerateTokens 方法用于创建一个 JWT access token 和 refresh token。我们通过用户的 Claims 保存用户的 JWT access token 并 设置 JWT token 验证相关参数。这个 refresh token 是一个简单的随机字符串。在 RefreshToken 对象里还加入了过期时间和用户名。我们还能附加其它数据到 RefreshToken 中。例如:客户 IP,user-agent,设备 ID 等。方便我们识别有效用户和伪造 tokens。
Caveat:注意第17行到20是为了防止 token 刷新多次时变长。因为 aud claim是一个数组它总是追加新的值。
我们以单例形式注册 JwtAuthManager 类到 IOC 容器中。在 Startup.ConfigureServices 方法中添加
1 services.AddSingleton<IJwtAuthManager, JwtAuthManager>();
现在我们可以在 AccountController 类中注入 JwtAuthManager 类了。
1 [ApiController] 2 [Authorize] 3 [Route("api/[controller]")] 4 public class AccountController : ControllerBase 5 { 6 private readonly ILogger<AccountController> _logger; 7 private readonly IUserService _userService; 8 private readonly IJwtAuthManager _jwtAuthManager; 9 10 public AccountController(ILogger<AccountController> logger, IUserService userService, IJwtAuthManager jwtAuthManager) 11 { 12 _logger = logger; 13 _userService = userService; 14 _jwtAuthManager = jwtAuthManager; 15 } 16 17 [AllowAnonymous] 18 [HttpPost("login")] 19 public ActionResult Login([FromBody] LoginRequest request) 20 { 21 if (!ModelState.IsValid) 22 { 23 return BadRequest(); 24 } 25 26 if (!_userService.IsValidUserCredentials(request.UserName, request.Password)) 27 { 28 return Unauthorized(); 29 } 30 31 var role = _userService.GetUserRole(request.UserName); 32 var claims = new[] 33 { 34 new Claim(ClaimTypes.Name,request.UserName), 35 new Claim(ClaimTypes.Role, role) 36 }; 37 38 var jwtResult = _jwtAuthManager.GenerateTokens(request.UserName, claims, DateTime.Now); 39 _logger.LogInformation($"User [{request.UserName}] logged in the system."); 40 return Ok(new LoginResult 41 { 42 UserName = request.UserName, 43 Role = role, 44 AccessToken = jwtResult.AccessToken, 45 RefreshToken = jwtResult.RefreshToken.TokenString 46 }); 47 } 48 }
在上面的代码第26到29行我们先验证这个用户是否有效。第31行到36行用来生成 claims 值。第38行调用 JwtAuthManager 类中的 GenerateTokens 方法得到一个 access token 和 一个 refresh token。最后这个登录方法返回一个带有 tokens 的对象给客户端。
Logout
JWT tokens 发送给客户保存后,当客户端需要退出时,我们能从 cookie 或者 localStorage 中移除 token。不管怎么样用户仍然能拿着 token 访问。通常这种风险很低,因为我们设置的过期时间很短。如果你仍然想设置 JWT access token 在服务端无效。你可以参考 StackOverflow 中的讨论和 GitHub issue 的建议使用一个 block-list 策略。
在这个项目中,我们将保留这个 access token,但我们将使 refresh token 在服务端无效。在 AccountController 中我们添加一个 Logout 方法如下:
1 [HttpPost("logout")] 2 [Authorize] 3 public ActionResult Logout() 4 { 5 var userName = User.Identity.Name; 6 _jwtAuthManager.RemoveRefreshTokenByUserName(userName); // can be more specific to ip, user agent, device name, etc. 7 _logger.LogInformation($"User [{userName}] logged out the system."); 8 return Ok(); 9 }
在这个退出方法,我们先得到当前用户 username(如果我们有保存 ID 在 claims,我们也可以使用这个用户ID)。通过这个 username, 我们移除这个用户的 refresh token。
注意通过用户名移除 tokens 不是一个好办法,因为它会退出所有的这个用户的会话,即使我们使用两个不同浏览器,或者一个在桌面端和个在手机端。因此,为了改进用户体验,我们可以只移除指定的 token(例如:通过user-agent 和 client IP)。
Refresh the JWT Access Token
一些手机 APP 只需要一次登录,所以刷新 JWT access tokens 不重要。但是对于大部分 web apps,refreshing access tokens 是必需的。当 access token 快过期时,客户端通常触发这个 refresh token 行为。客户端发送 RefreshToken 到 API 节点。实例代码在 AccountController 类中。
1 [HttpPost("refresh-token")] 2 [Authorize] 3 public async Task<ActionResult> RefreshToken([FromBody] RefreshTokenRequest request) 4 { 5 try 6 { 7 var userName = User.Identity.Name; 8 _logger.LogInformation($"User [{userName}] is trying to refresh JWT token."); 9 10 if (string.IsNullOrWhiteSpace(request.RefreshToken)) 11 { 12 return Unauthorized(); 13 } 14 15 var accessToken = await HttpContext.GetTokenAsync("Bearer", "access_token"); 16 var jwtResult = _jwtAuthManager.Refresh(request.RefreshToken, accessToken, DateTime.Now); 17 _logger.LogInformation($"User [{userName}] has refreshed JWT token."); 18 return Ok(new LoginResult 19 { 20 UserName = userName, 21 Role = User.FindFirst(ClaimTypes.Role)?.Value ?? string.Empty, 22 AccessToken = jwtResult.AccessToken, 23 RefreshToken = jwtResult.RefreshToken.TokenString 24 }); 25 } 26 catch (SecurityTokenException e) 27 { 28 return Unauthorized(e.Message); // return 401 so that the client side can redirect the user to login page 29 } 30 }
在这个代码之上,还验证了原始的 access token 确保 refresh token 和 access token 是匹配的。如果任何异常发生这个 API 将返回一个 Unauthorized 的 HTTP 状态码(第26到29行),以便让客户端重定用户到登录页。
第16行得到新的 token。具体刷新的代码如下:
1 public JwtAuthResult Refresh(string refreshToken, string accessToken, DateTime now) 2 { 3 var (principal, jwtToken) = DecodeJwtToken(accessToken); 4 if (jwtToken == null || !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature)) 5 { 6 throw new SecurityTokenException("Invalid token"); 7 } 8 9 var userName = principal.Identity.Name; 10 if (!_usersRefreshTokens.TryGetValue(refreshToken, out var existingRefreshToken)) 11 { 12 throw new SecurityTokenException("Invalid token"); 13 } 14 if (existingRefreshToken.UserName != userName || existingRefreshToken.ExpireAt < now) 15 { 16 throw new SecurityTokenException("Invalid token"); 17 } 18 19 return GenerateTokens(userName, principal.Claims.ToArray(), now); // need to recover the original claims 20 } 21 22 public (ClaimsPrincipal, JwtSecurityToken) DecodeJwtToken(string token) 23 { 24 if (string.IsNullOrWhiteSpace(token)) 25 { 26 throw new SecurityTokenException("Invalid token"); 27 } 28 var principal = new JwtSecurityTokenHandler() 29 .ValidateToken(token, 30 new TokenValidationParameters 31 { 32 ValidateIssuer = true, 33 ValidIssuer = _jwtTokenConfig.Issuer, 34 ValidateIssuerSigningKey = true, 35 IssuerSigningKey = new SymmetricSecurityKey(_secret), 36 ValidAudience = _jwtTokenConfig.Audience, 37 ValidateAudience = true, 38 ValidateLifetime = true, 39 ClockSkew = TimeSpan.FromMinutes(1) 40 }, 41 out var validatedToken); 42 return (principal, validatedToken as JwtSecurityToken); 43 }
在上面的代码我们先解码 JWT access token 得到一个当前用户标识。DecodeJwtToken 方法的参数应该匹配 TokenValidationParameters。
另一个原因我们需要解码原始的 JWT access token 得到所有的 claims,然后我们再生成新的 access token。
Refresh Tokens 管理
因为我们采取 Refresh Token Rotation technique,服务端可能需要保存很多 refresh tokens 和它们的元数据。在我们的演示解决方案中,我已经实现了一个后台 job (代码没有在这里),每分钟移除过期的 refresh tokens。你可以访问我的 GitHub 仓储 (link) 查看详细信息。
模拟用户
有时我们需要模拟用户进行测试或者调试。在我的演示方案中,我有实现 impersonation 和 stop-impersonation APIs。实现逻辑是伪造用户的 claims。当然还需要一些前端工作完整模拟的功能。
集成测试
集成测试在 ASP.NET 中很容易实现。首先我们创建一个 Test Host,Test HttpClient 和 ServiceProvider。因此我们能添加 Bearer token 到 HTTP headers,并发送 HTTP 请求到我们的 API。最后我们能验证这请求 codes 和内容。
这里有几个时间相关的集成测试例如:token 过期。这些测试不需要浏览器和任何 mock times。
HTTPS and Dockerization
上面提到,JWT tokens 应该使用 HTTPS 传输。在这开发模式下我们应该通过 ASP.NET Core,为 localhost 设置一个开发的 SSL 证书。方便我们使用 HTTPS 地址启用应用程序。
当我们 dockerize 一个 ASP.NET Core web app,我们需要映射我们的证书私钥到 Docker 容器。
结论
再次强调,完整的解决方案在我的 GitHub repository。希望它能帮到你。请在评论区或者我的 GitHub repository 分享你的想法。
原文链接:https://codeburst.io/jwt-auth-in-asp-net-core-148fb72bed03