基于Redis缓存的Token失效策略实现

有这么一个简单场景,某个应用采用前后端分离架构,前端有用于浏览器访问的网站、手机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,访问接口触发过滤器的验证

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注