You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ASP.NET Core 7+JWT限制在线用户数(最多5-6人)方案咨询

ASP.NET Core 7 + JWT 实现用户登录数量限制方案

JWT本身是无状态的,没法直接追踪客户端在线状态,核心思路是在服务端记录每个用户的有效JWT Token,通过统计未失效、未过期的Token数量来实现登录数限制,登出时标记对应Token失效即可排除统计。下面是具体实现步骤:

1. 设计Token存储表

创建一个简单的数据库表,用来记录用户的有效Token信息:

public class UserActiveToken
{
    public int Id { get; set; }
    public string UserId { get; set; } // 关联用户ID
    public string TokenId { get; set; } // JWT的jti声明(每个Token唯一)
    public DateTime ExpiresAt { get; set; } // Token过期时间
    public bool IsRevoked { get; set; } // 是否已登出/失效
}

对应的EF Core配置(可选):

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<UserActiveToken>()
        .HasIndex(t => new { t.UserId, t.TokenId })
        .IsUnique();
}

2. 登录时校验并生成Token

用户登录时,先统计当前有效Token数量,超过上限则拒绝登录(或挤掉最早的Token),然后生成带唯一jti的JWT并保存记录:

[HttpPost("login")]
public async Task<IActionResult> Login(LoginRequest request)
{
    // 1. 验证用户账号密码(此处省略业务逻辑)
    var user = await _userManager.FindByEmailAsync(request.Email);
    if (user == null || !await _userManager.CheckPasswordAsync(user, request.Password))
    {
        return Unauthorized("账号或密码错误");
    }
    var userId = user.Id;

    // 2. 统计当前有效Token数量
    var activeTokenCount = await _dbContext.UserActiveTokens
        .CountAsync(t => t.UserId == userId && !t.IsRevoked && t.ExpiresAt > DateTime.UtcNow);

    const int MaxLoginCount = 5; // 设置最大登录数
    if (activeTokenCount >= MaxLoginCount)
    {
        // 可选逻辑:挤掉最早的有效Token
        var oldestToken = await _dbContext.UserActiveTokens
            .Where(t => t.UserId == userId && !t.IsRevoked && t.ExpiresAt > DateTime.UtcNow)
            .OrderBy(t => t.ExpiresAt)
            .FirstOrDefaultAsync();
        if (oldestToken != null)
        {
            oldestToken.IsRevoked = true;
            await _dbContext.SaveChangesAsync();
        }
        // 或者直接拒绝登录:return BadRequest("当前登录设备数量已达上限");
    }

    // 3. 生成带jti的JWT Token
    var tokenHandler = new JwtSecurityTokenHandler();
    var key = Encoding.ASCII.GetBytes(_configuration["Jwt:SecretKey"]);
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(new[] 
        { 
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Email, user.Email)
        }),
        Expires = DateTime.UtcNow.AddHours(2),
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature),
        Jti = Guid.NewGuid().ToString() // 生成唯一Token标识
    };
    var token = tokenHandler.CreateToken(tokenDescriptor);
    var tokenString = tokenHandler.WriteToken(token);

    // 4. 保存Token记录到数据库
    await _dbContext.UserActiveTokens.AddAsync(new UserActiveToken
    {
        UserId = userId,
        TokenId = token.Jti,
        ExpiresAt = tokenDescriptor.Expires.Value,
        IsRevoked = false
    });
    await _dbContext.SaveChangesAsync();

    return Ok(new { AccessToken = tokenString });
}

3. 登出时标记Token失效

用户登出时,根据Token的jti标记对应记录为失效,这样就不会被计入登录数统计:

[HttpPost("logout")]
[Authorize]
public async Task<IActionResult> Logout()
{
    var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
    var authHeader = HttpContext.Request.Headers["Authorization"].ToString();
    if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer "))
    {
        var tokenString = authHeader["Bearer ".Length..];
        var tokenHandler = new JwtSecurityTokenHandler();
        
        if (tokenHandler.CanReadToken(tokenString))
        {
            var jwtToken = tokenHandler.ReadJwtToken(tokenString);
            var jti = jwtToken.Claims.First(c => c.Type == "jti").Value;

            var activeToken = await _dbContext.UserActiveTokens
                .FirstOrDefaultAsync(t => t.UserId == userId && t.TokenId == jti && !t.IsRevoked);
            
            if (activeToken != null)
            {
                activeToken.IsRevoked = true;
                await _dbContext.SaveChangesAsync();
            }
        }
    }

    return Ok("登出成功");
}

4. 中间件校验Token有效性

因为JWT本身无状态,需要在每次请求时校验Token是否在有效记录中,防止已登出/过期的Token继续使用:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = _configuration["Jwt:Issuer"],
            ValidAudience = _configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_configuration["Jwt:SecretKey"]))
        };

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = async context =>
            {
                var userId = context.Principal.FindFirstValue(ClaimTypes.NameIdentifier);
                var jti = context.Principal.FindFirstValue("jti");

                using var scope = context.HttpContext.RequestServices.CreateScope();
                var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
                
                var tokenRecord = await dbContext.UserActiveTokens
                    .FirstOrDefaultAsync(t => t.UserId == userId && t.TokenId == jti);

                if (tokenRecord == null || tokenRecord.IsRevoked || tokenRecord.ExpiresAt < DateTime.UtcNow)
                {
                    context.Fail("Token已失效");
                }
            }
        };
    });

5. 定时清理过期Token

为了避免数据库存储冗余,可添加一个定时任务定期清理过期的Token记录:

public class CleanExpiredTokensService : IHostedService, IDisposable
{
    private readonly IServiceScopeFactory _scopeFactory;
    private Timer _timer;

    public CleanExpiredTokensService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        // 每天凌晨1点清理一次过期Token
        var now = DateTime.Now;
        var delay = TimeSpan.FromHours(1 - now.Hour) + TimeSpan.FromMinutes(1 - now.Minute) + TimeSpan.FromSeconds(1 - now.Second);
        if (delay < TimeSpan.Zero)
        {
            delay += TimeSpan.FromDays(1);
        }
        _timer = new Timer(DoCleanup, null, delay, TimeSpan.FromDays(1));
        return Task.CompletedTask;
    }

    private void DoCleanup(object state)
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

        var expiredTokens = dbContext.UserActiveTokens
            .Where(t => t.ExpiresAt < DateTime.UtcNow);

        dbContext.UserActiveTokens.RemoveRange(expiredTokens);
        dbContext.SaveChanges();
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _timer?.Change(Timeout.Infinite, 0);
        return Task.CompletedTask;
    }

    public void Dispose()
    {
        _timer?.Dispose();
    }
}

然后在Program.cs中注册这个后台服务:

builder.Services.AddHostedService<CleanExpiredTokensService>();

关键说明

  • 核心是利用JWT的jti声明唯一标识每个Token,通过数据库记录Token的状态来追踪有效登录数
  • 登出操作仅标记Token失效,无需删除记录(方便后续审计)
  • 定时清理过期Token可降低数据库存储压力

内容的提问来源于stack exchange,提问作者Masoud Zare

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 00:14:50