ASP.NET Core Web API单设备登录的自定义授权过滤方案咨询
方案可行性说明
自定义授权过滤器的方案完全可行。核心思路是:在用户每次登录时生成唯一的会话标识(如SessionId)并存储,同时将该标识嵌入JWT Token的Claim中;自定义授权过滤器在每次API请求时,验证当前Token中的会话标识是否与存储的用户最新会话标识一致,不一致则判定为未授权(即用户已在其他设备登录)。
具体实现步骤与代码示例
1. 实现用户会话存储服务
用于记录每个用户当前有效的会话标识,支持Redis或数据库存储,这里以Redis为例:
public interface IUserSessionStore { // 存储用户最新会话ID Task SetLatestSessionIdAsync(string userId, string sessionId); // 获取用户最新会话ID Task<string> GetLatestSessionIdAsync(string userId); } // Redis实现 public class RedisUserSessionStore : IUserSessionStore { private readonly IDatabase _redisDb; private const string SessionKeyPrefix = "UserSession:"; public RedisUserSessionStore(IConnectionMultiplexer redis) { _redisDb = redis.GetDatabase(); } public async Task SetLatestSessionIdAsync(string userId, string sessionId) { var key = $"{SessionKeyPrefix}{userId}"; // 有效期与Refresh Token保持一致 await _redisDb.StringSetAsync(key, sessionId, TimeSpan.FromDays(7)); } public async Task<string> GetLatestSessionIdAsync(string userId) { var key = $"{SessionKeyPrefix}{userId}"; return await _redisDb.StringGetAsync(key); } }
2. 自定义单设备授权过滤器
继承IAuthorizationFilter,在请求时验证会话标识有效性:
public class SingleDeviceAuthorizationFilter : IAuthorizationFilter { private readonly IUserSessionStore _sessionStore; private readonly IHttpContextAccessor _httpContextAccessor; public SingleDeviceAuthorizationFilter(IUserSessionStore sessionStore, IHttpContextAccessor httpContextAccessor) { _sessionStore = sessionStore; _httpContextAccessor = httpContextAccessor; } public async void OnAuthorization(AuthorizationFilterContext context) { // 从Token Claim中获取用户ID和当前会话ID var userId = _httpContextAccessor.HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier); var currentSessionId = _httpContextAccessor.HttpContext.User.FindFirstValue("SessionId"); if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(currentSessionId)) { context.Result = new UnauthorizedResult(); return; } // 对比当前会话ID与存储的最新会话ID var latestSessionId = await _sessionStore.GetLatestSessionIdAsync(userId); if (latestSessionId != currentSessionId) { context.Result = new UnauthorizedObjectResult("您的账号已在其他设备登录"); } } }
3. 注册服务与配置JWT
在Program.cs中注册相关服务,并配置全局过滤器(或局部使用):
var builder = WebApplication.CreateBuilder(args); // 注册Redis连接(需提前安装StackExchange.Redis包) builder.Services.AddSingleton<IConnectionMultiplexer>( ConnectionMultiplexer.Connect(builder.Configuration["Redis:ConnectionString"])); builder.Services.AddScoped<IUserSessionStore, RedisUserSessionStore>(); // 注册自定义授权过滤器 builder.Services.AddScoped<SingleDeviceAuthorizationFilter>(); // 保留原有JWT配置 builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"], ValidAudience = builder.Configuration["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"])) }; }); // 全局注册过滤器(仅需单设备验证的接口也可局部标注) builder.Services.AddControllers(options => { options.Filters.Add<SingleDeviceAuthorizationFilter>(); }); var app = builder.Build(); app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();
4. 改造登录与刷新Token逻辑
登录时生成会话ID并存储,刷新Token时更新会话ID:
[ApiController] [Route("api/auth")] public class AuthController : ControllerBase { private readonly IUserSessionStore _sessionStore; private readonly IConfiguration _configuration; public AuthController(IUserSessionStore sessionStore, IConfiguration configuration) { _sessionStore = sessionStore; _configuration = configuration; } [HttpPost("login")] public async Task<IActionResult> Login([FromBody] LoginModel model) { // 替换为实际用户验证逻辑 var user = ValidateUser(model.Username, model.Password); if (user == null) { return Unauthorized("用户名或密码错误"); } // 生成唯一会话ID var sessionId = Guid.NewGuid().ToString(); // 存储用户最新会话ID await _sessionStore.SetLatestSessionIdAsync(user.Id.ToString(), sessionId); // 生成含SessionId的JWT Token var claims = new[] { new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim("SessionId", sessionId) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: creds); // 生成Refresh Token(需存储到数据库关联用户) var refreshToken = Guid.NewGuid().ToString(); return Ok(new { AccessToken = new JwtSecurityTokenHandler().WriteToken(token), RefreshToken = refreshToken, ExpiresIn = 30 * 60 }); } [HttpPost("refresh-token")] public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenModel model) { // 替换为实际Refresh Token验证逻辑 var refreshTokenRecord = GetRefreshTokenFromDb(model.RefreshToken); if (refreshTokenRecord == null || refreshTokenRecord.ExpiresAt < DateTime.Now) { return Unauthorized("Refresh Token无效"); } var userId = refreshTokenRecord.UserId.ToString(); // 生成新会话ID并更新存储 var newSessionId = Guid.NewGuid().ToString(); await _sessionStore.SetLatestSessionIdAsync(userId, newSessionId); // 生成新的Access Token var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId), new Claim(ClaimTypes.Name, refreshTokenRecord.Username), new Claim("SessionId", newSessionId) }; var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["Jwt:Key"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var newToken = new JwtSecurityToken( issuer: _configuration["Jwt:Issuer"], audience: _configuration["Jwt:Audience"], claims: claims, expires: DateTime.Now.AddMinutes(30), signingCredentials: creds); // 生成新Refresh Token并更新数据库 var newRefreshToken = Guid.NewGuid().ToString(); UpdateRefreshTokenInDb(model.RefreshToken, newRefreshToken); return Ok(new { AccessToken = new JwtSecurityTokenHandler().WriteToken(newToken), RefreshToken = newRefreshToken, ExpiresIn = 30 * 60 }); } // 模拟业务方法,需替换为实际逻辑 private User ValidateUser(string username, string password) => new User { Id = 1, Username = username }; private RefreshTokenRecord GetRefreshTokenFromDb(string refreshToken) => new RefreshTokenRecord { UserId = 1, Username = "test", ExpiresAt = DateTime.Now.AddDays(7) }; private void UpdateRefreshTokenInDb(string oldRefreshToken, string newRefreshToken) { } } // 模型类 public class LoginModel { public string Username { get; set; } public string Password { get; set; } } public class RefreshTokenModel { public string RefreshToken { get; set; } } public class User { public int Id { get; set; } public string Username { get; set; } } public class RefreshTokenRecord { public int UserId { get; set; } public string Username { get; set; } public DateTime ExpiresAt { get; set; } }
注意事项
- 存储选择:Redis适合高并发场景,小应用可改用EF Core数据库存储会话标识。
- 局部过滤器:若无需全局验证,可在指定控制器/Action上标注
[ServiceFilter(typeof(SingleDeviceAuthorizationFilter))]。 - 注销逻辑:用户主动注销时,需删除对应会话标识记录,确保后续请求直接返回未授权。
内容的提问来源于stack exchange,提问作者Dnyati
相关产品推荐
相关产品推荐

