前言
笔者之前开发过一套C/S架构的桌面应用,采用了JWT作为用户的登录认证和授权。遇到的唯一问题就是JWT过期了该怎么办?设想当一个用户正在进行业务操作,突然因为Token过期失效,莫名其妙地跳转到登录界面,是不是一件很无语的事。当然笔者也曾想过:为何不把JWT的有效期尽量设长些(假设24小时),用户每天总要下班退出系统吧,呵呵!这显然有点投机取巧,也违背了JWT的安全设计,看来等另想他法。
设计思路
后来笔者的做法是:当客户端每次发起Http请求时,先判断本地Token是否存在: 1. 如果不存在,则先向服务端发起登录验证请求,从而获取Token。2. 如果已存在,则检测Token是否即将过期。如果是的话,就重新发起登录验证更新Token,否则继续使用当前Token。其中判断Token是否即将过期没有一个标准设定,个人认为在1~5分钟之间比较合适。 以上就是实现Token自动续期的整个过程。
知识准备
什么是JWT
JWT(JSON Web Token) 是一个开发标准 (RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。JWT是由头部 (Header)、载荷 (Payload) 和签名 (Signature) 三部分组成,它们之间用圆点(.)连接。JWT最常见的应用场景是授权(Authorization)和信息交换(Information Exchange)。
什么是Refit
Refit 是一个受到Square的Retrofit库(Java)启发的自动类型安全REST库。我们的应用程序通过Refit请求网络,实际上是使用Refit接口层封装请求参数、Header、Url等信息,之后由HttpClient完成后续的请求操作,在服务端返回数据之后,HttpClient将原始的结果交给Refit,后者根据用户的需求对结果进行解析的过程。
技术实现
我们需要先创建一个客户端和一个服务端。为了演示方便,客户端仍用WinForm,服务器使用ASP.NET Core Web API。如图所示:
JwtToken.Shared 公共类库:定义了一些POCO对象,供客户端/服务端共享使用。其中 TokenResult 定义如下:
1 public record TokenResult 2 { 3 ///4 /// 访问令牌 5 /// 6 public string AccessToken { get; init; } 7 8 /// 9 /// 过期时间 10 /// 11 public DateTime ExpiredTime { get; init; } 12 }
服务端实现
JwtToken.Server 提供两个后台服务:一个是登录验证服务,为客户端颁发用户凭证(JWT),另一个是获取系统时间服务。
在 Program 启动类,我们需要添加和使用指定服务,从而开启JWT认证和授权。 代码如下:
1 public class Program 2 { 3 public static void Main(string[] args) 4 { 5 var builder = WebApplication.CreateBuilder(args); 6 builder.Services.AddControllers(); 7 builder.Services.AddAuthentication(options => 8 { 9 options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; 10 options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; 11 }) 12 .AddJwtBearer(o => 13 { 14 o.TokenValidationParameters = new TokenValidationParameters 15 { 16 NameClaimType = "Name", 17 RoleClaimType = "Role", 18 ValidateAudience = false, 19 ValidateIssuer = false, 20 ValidateLifetime = true, 21 ClockSkew = TimeSpan.FromSeconds(30), 22 IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(JwtConsts.SigningKey)) 23 }; 24 }); 25 builder.Services.AddAuthorization(); 26 27 var app = builder.Build(); 28 app.UseAuthentication(); 29 app.UseAuthorization(); 30 app.MapControllers(); 31 app.Run(); 32 } 33 }
DemoController 控制器:提供 LoginAsync() 和 GetCurrentTimeAsync() 两个方法,代码如下:
1 [ApiController] 2 [Route("[controller]")] 3 public class DemoController : ControllerBase 4 { 5 ///6 /// 登录 7 /// 8 /// 9 /// 10 [HttpPost("Login")] 11 public async ValueTask LoginAsync(LoginDto dto) 12 { 13 var user = GetUserInfo(dto.UserName); 14 if (user.Password == dto.Password) // 登录密码验证 15 { 16 TokenResult tokenResult = await JwtHelper.GenerateAsync(user.Id, user.UserName, user.Name, user.PhoneNumber); 17 return tokenResult; 18 } 19 return null; 20 } 21 22 /// 23 /// 获取当前时间 24 /// 25 /// 26 [Authorize] 27 [HttpGet("CurrentTime")] 28 public ValueTask GetCurrentTimeAsync() 29 { 30 return ValueTask.FromResult(DateTimeOffset.Now); 31 } 32 }
第26行代码:给 GetCurrentTimeAsync() 加上 [Authorize] 特性后, 当前服务必须授权后才能访问。
第16行代码:根据用户的Id、用户名、姓名等信息来生成 TokenResult ,它包含JWT令牌和过期时间。下面是JWT的生成代码:
1 public static class JwtHelper 2 { 3 ///4 /// 生成Token 5 /// 6 /// 7 public static ValueTask GenerateAsync(int id, string username, string name, string phoneNumber) 8 { 9 var claims = new List () 10 { 11 new Claim("UserId", id.ToString()), // 用户Id 12 new Claim("UserName", username), // 用户名 13 new Claim("Name", name) , // 姓名 14 new Claim("PhoneNumber", phoneNumber) // 手机号码 15 }; 16 17 var tokenHandler = new JwtSecurityTokenHandler(); 18 var expiresAt = DateTime.Now.AddMinutes(20); // 过期时间 19 var tokenDescriptor = new SecurityTokenDescriptor 20 { 21 Subject = new ClaimsIdentity(claims), 22 Expires = expiresAt, 23 SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(Encoding.ASCII.GetBytes(JwtConsts.SigningKey)), 24 SecurityAlgorithms.HmacSha256Signature) 25 }; 26 27 var token = tokenHandler.CreateToken(tokenDescriptor); 28 var tokenString = tokenHandler.WriteToken(token); 29 30 return ValueTask.FromResult(new TokenResult 31 { 32 AccessToken = tokenString, 33 ExpiredTime = expiresAt 34 }); 35 } 36 }
第18行代码:设置Token的过期时间,这里我们把有效期设为20分钟。
客户端实现
JwtToken.Client 定义后台服务调用接口和实现Token自动续期。IDemoApi 接口定义如下:
1 [Headers(new[] { "Authorization:Bearer" })] 2 public interface IDemoApi 3 { 4 ///5 /// 获取当前时间 6 /// 7 /// 8 [Get("/Demo/CurrentTime")] 9 Task GetCurrentTimeAsync(); 10 }
第1行代码:给 IDemApi 接口加上 [Headers(...)] 特性,这样每次调用 GetCurrentTimeAsync() 方法,Http请求头部都会加上此信息。JWT的标准授权头部格式为:Authorization: Bearer
接下来,就是实现Token自动续期功能。笔者封装了一个 RestHelper 类,核心代码如下:
1 ///2 /// Rest请求服务 3 /// 4 /// 5 /// 6 public static T For () 7 { 8 var settings = new RefitSettings() 9 { 10 AuthorizationHeaderValueGetter = () => GetTokenAsync(), 11 }; 12 13 return RestService.For (BaseUrl, settings); 14 } 15 16 /// 17 /// 获取Token 18 /// 19 /// 20 private static async Task<string> GetTokenAsync() 21 { 22 if (TokenResult is null || DateTimeOffset.Now.AddMinutes(1) >= TokenResult?.ExpiredTime) 23 { 24 var uri = new Uri($"{BaseUrl}/demo/login", UriKind.Absolute); 25 26 var dto = new LoginDto { UserName = "fjq", Password = "123456" }; 27 28 using var httpResMsg = await new HttpClient().PostAsync(uri, JsonContent.Create(dto)); 29 30 if (httpResMsg.IsSuccessStatusCode) 31 { 32 var jsonStr = await httpResMsg.Content.ReadAsStringAsync(); 33 34 TokenResult = JsonHelper.FromJson (jsonStr); 35 } 36 } 37 38 return TokenResult?.AccessToken; 39 }
第10行代码:AuthorizationHeaderValueGetter 是 RefitSettings 对象的一个委托属性,用来提供授权头部信息,即JWT字符串。
第22至35行代码:即按照笔者前面的思路转换成代码实现,这里就不再详细说明了。
最后,我们用一行代码来获取后台系统时间:
1 var dt = await RestHelper.For().GetCurrentTimeAsync();
界面运行效果如下(~亲测有效~):
参考资料
认识JWT - 废物大师兄 - 博客园 (cnblogs.com)
Refit | The automatic type-safe REST library for Xamarin and .NET (reactiveui.github.io)