有这么一个简单场景,某个应用采用前后端分离架构,前端有用于浏览器访问的网站、手机APP、桌面APP等,前端通过JWT认证和后端接口进行交互,用户可能会出现相同账号同时多端登录的问题,能否在验证JWT时去避免呢?Token字符中只有类似用户名、用户ID、用户Role以及过期时间等信息,只要在Token未过期前验证Token是不能规避这种问题的。
这样看来,必须对Token字符有一个能比较出差异的服务器端保存,比如往Token字符中增加版本ID,当用户每次登录系统,登录接口都往数据库表的Token版本ID进行一次自增,再将Token版本ID写入Token发放到用户终端,程序自身再构建一个拦截器,用户端的每次请求所携带的JWT Token都会校验版本是否一致,比如请求中的Token版本小于了系统数据库表中的Token版本,就给用户返回 401 Unauthorized。
上述的,使用数据库存储Token版本号是一种解决办法,不过每次用户访问都会迫使拦截器进行一次数据库查询,考虑降低数据库的压力,使用Redis对Token进行缓存也是被广泛使用的一种机制。
Mack没有看过其他人如何去写这段代码,自己琢磨着写了一个示例代码,示范项目是一个.NET Core Web API项目,它包含以下包:
- Microsoft.AspNetCore.Authentication.JwtBearer
- Microsoft.AspNetCore.Identity.EntityFrameworkCore
- Microsoft.AspNetCore.OpenApi
- Microsoft.EntityFrameworkCore.SqlServer
- Microsoft.EntityFrameworkCore.Tools
- Microsoft.Extensions.Caching.StackExchangeRedis
- Swashbuckle.AspNetCore
- System.IdentityModel.Tokens.Jwt
项目使用了Identity 这个RBAC框架,对于数据库映射操作依赖EF Core,数据库使用Sql Server,为了进行缓存操作也安装了Redis。
用户的每次登录所生成的Token都会被写进Redis,代码如下:
/*Controllers AuthController.cs*/
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Json;
namespace ProjectTodo.Controllers
{
[Route("api/[controller]/[action]")]
[ApiController]
[Produces("application/json")]
public class AuthController : ControllerBase
{
private readonly UserManager<User> _userManager;
private readonly IDistributedCache _distributedCache;
public AuthController(UserManager<User> userManager, IDistributedCache distributedCache)
{
_userManager = userManager;
_distributedCache = distributedCache;
}
private static string BuildToken(IEnumerable<Claim> claims, JWTSettings options)
{
DateTime expires = DateTime.Now.AddSeconds(options.ExpireSeconds);
byte[] keyBytes = Encoding.UTF8.GetBytes(options.SigningKey);
var secKey = new SymmetricSecurityKey(keyBytes);
var credentials = new SigningCredentials(secKey, SecurityAlgorithms.HmacSha256Signature);
var tokenDescriptor = new JwtSecurityToken(expires: expires, signingCredentials: credentials, claims: claims);
return new JwtSecurityTokenHandler().WriteToken(tokenDescriptor);
}
[HttpPost]
public async Task<ActionResult> Login([FromBody] AuthLoginViewModel model, [FromServices]IOptions<JWTSettings> jwtSettingsOpt)
{
var userName = model.UserName;
var password = model.Password;
var user = await _userManager.FindByNameAsync(userName);
if (user == null)
{
return BadRequest();
}
var result = await _userManager.CheckPasswordAsync(user, password);
if (!result)
{
return BadRequest();
}
var claims = new List<Claim>();
claims.Add(new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()));
claims.Add(new Claim(ClaimTypes.Name, user.UserName!));
var roles = await _userManager.GetRolesAsync(user);
foreach (var role in roles)
{
claims.Add(new Claim(ClaimTypes.Role, role));
}
string jwtToken = BuildToken(claims, jwtSettingsOpt.Value);
// 获取Redis缓存中的用户token
string? s = await _distributedCache.GetStringAsync("token-" + user.UserName!);
if (s == null)
{
// 没有token就写入
await _distributedCache.SetStringAsync("token-" + user.UserName!, JsonSerializer.Serialize(jwtToken));
}
else
{
// 删除旧的token,写入新的token
await _distributedCache.RemoveAsync("token-" + user.UserName!);
await _distributedCache.SetStringAsync("token-" + user.UserName!, JsonSerializer.Serialize(jwtToken));
}
return Ok(jwtToken);
}
}
}
项目配置一个专门校验Token的过滤器,代码如下:
/* JWTValidationFilter.cs */
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Caching.Distributed;
using System.Security.Claims;
namespace ProjectTodo
{
//[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class JWTValidationFilter :Attribute, IAsyncActionFilter
{
private readonly IDistributedCache _distributedCache;
public JWTValidationFilter(IDistributedCache distributedCache)
{
_distributedCache = distributedCache;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var userName = context.HttpContext.User.FindFirst(ClaimTypes.Name)?.Value;
if (userName == null)
{
await next();
return;
}
string? redisToken = await _distributedCache.GetStringAsync("token-" + userName!);
string token;
if (redisToken != null)
{
redisToken = redisToken.Trim('"');
var authorizationHeader = context.HttpContext.Request.Headers.Authorization.ToString();
if (authorizationHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
{
token = authorizationHeader.Substring("Bearer ".Length).Trim();
bool isValid = string.Equals(token, redisToken);
if (!isValid)
{
context.Result = new ObjectResult("Token 已失效,请重新登录") { StatusCode = 401 };
return;
}
}
await next();
return;
}
await next();
}
}
}
过滤器会从当前对接口进行访问的请求中获取到Token,也会从Redis中拿到Token,进行比较,发现差异会返回401。
过滤器需要在项目根目录Program.cs中进行注册。
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi.Models;
using ProjectTodo;
using System.Reflection;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddMemoryCache();
builder.Services.AddLogging();
string connStr = builder.Configuration.GetConnectionString("DefaultConnection")!;
// 添加数据库上下文注册
builder.Services.AddDbContext<ProjectTodoContext>(options => options.UseSqlServer(connStr));
// 添加JWT配置
builder.Services.Configure<JWTSettings>(builder.Configuration.GetSection("JWT"));
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(x =>
{
var jwtOpt = builder.Configuration.GetSection("JWT").Get<JWTSettings>();
byte[] keyBytes = Encoding.UTF8.GetBytes(jwtOpt!.SigningKey);
var secKey = new SymmetricSecurityKey(keyBytes);
x.TokenValidationParameters = new()
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = secKey
};
});
// 添加全局过滤器
//builder.Services.Configure<MvcOptions>(options =>
//{
// options.Filters.Add<JWTValidationFilter>();
// //options.Filters.Add<ExceptionHandleFilter>();
// // ... 这里还可以增加其他 Filter,但是需要注意顺序
//});
builder.Services.AddScoped<JWTValidationFilter>();
// Swagger 添加 Authorization
builder.Services.AddSwaggerGen(options =>
{
var scheme = new OpenApiSecurityScheme()
{
Description = "Authorization header. \r\nExample: 'Bearer 12345abcdef'",
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Authorization"
},
Scheme = "oauth2",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.ApiKey,
};
options.AddSecurityDefinition("Authorization",scheme);
var requirement = new OpenApiSecurityRequirement();
requirement[scheme] = new List<string>();
options.AddSecurityRequirement(requirement);
// using System.Reflection;
var xmlFilename = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, xmlFilename));
});
// Redis 缓存服务注册
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = "localhost";
options.InstanceName = "mack_"; // key 前缀
});
IServiceCollection services = builder.Services;
services.AddDbContext<IdDbContext>(opt =>
{
opt.UseSqlServer(connStr);
});
services.AddDataProtection();
services.AddIdentityCore<User>(options =>
{
//options.Lockout.MaxFailedAccessAttempts = 10; // 密码错误十次锁定
//options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromDays(1); // 默认锁定时间,可以通过FromDays等进行天、小时、分钟、秒的设置
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequiredLength = 6;
// 如果发重置链接到用户邮箱,可以注释掉下面一行
options.Tokens.PasswordResetTokenProvider = TokenOptions.DefaultEmailProvider; // 这个决定了重置密码的验证码长度
options.Tokens.EmailConfirmationTokenProvider = TokenOptions.DefaultEmailProvider;
});
var idBuilder = new IdentityBuilder(typeof(User), typeof(Role), services);
idBuilder.AddEntityFrameworkStores<IdDbContext>().AddDefaultTokenProviders().AddRoleManager<RoleManager<Role>>().AddUserManager<UserManager<User>>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
// 在app.UseAuthorization();上面添加
app.UseAuthentication();
app.UseAuthorization();
// 启用服务器缓存需要在 app.MapControllers() 前加上 app.UseResponseCaching()
// 如果启用了 app.UseCors 跨域,需要 app.UseCors 写到 app.UseResponseCaching 前面
/*app.UseResponseCaching();*/ // 启用服务器端缓存
app.MapControllers();
app.Run();
过滤器以[ServiceFilter(typeof(JWTValidationFilter), Order = -1)]这种标签方式作用到一个接口上(顺带吐槽一下,放在Python项目上接口被叫成视图函数,会更贴切),Order = -1 是表明过滤器的执行优先级,如果没有这个,程序会优先执行[Authorize]
/// <summary>
/// View project by id
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet("{id}")]
[ServiceFilter(typeof(JWTValidationFilter), Order = -1)]
[Authorize]
public async Task<ActionResult<ProjectDTO>> GetProject(long id)
{
var project = await context.Projects.SingleOrDefaultAsync(x=>x.Id == id);
if (project == null)
{
return NotFound();
}
var projectDTO = new ProjectDTO(project);
return Ok(projectDTO);
}
围绕上述的实现代码,调试出的结果:
使用已过期Token,接口直接返回401
使用未过期Token,并在Swagger界面对登录接口重新登录,使得Redis缓存最新Token,访问接口触发过滤器的验证