背景
在工业控制系统中,常常需要支持用户在多台工控机上同时登录,但每台机器的会话需要独立管理。传统的单点登录方案只允许一处登录,而完全放开又无法有效管理会话。
本文介绍一种基于 JWT + Redis 的方案,实现"同用户多设备独立会话"的登录控制。
核心设计
Token 结构
在 JWT 中嵌入 clientId(机器码)用于区分不同客户端:
public static string GenerateAccessToken(
long userId,
string username,
long roleId,
string? clientId = null) {
var effectiveClientId = string.IsNullOrEmpty(clientId) ? "default" : clientId;
var claims = new List<Claim> {
new("userId", userId.ToString()),
new("username", username),
new("roleId", roleId.ToString()),
new("clientId", effectiveClientId), // 关键:嵌入机器码
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())
};
var token = new JwtSecurityToken(
issuer: _config.Issuer,
audience: _config.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(_config.AccessTokenExpiration),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
Redis 存储结构
使用 用户ID + 客户端ID 作为 Redis Key:
Web:Auth:UserToken:{userId}:{clientId}
例如:
Web:Auth:UserToken:1:abc123- 用户1在机器abc123的TokenWeb:Auth:UserToken:1:def456- 用户1在机器def456的Token
public static bool SaveUserToken(long userId, string token, string? clientId = null) {
var effectiveClientId = string.IsNullOrEmpty(clientId) ? "default" : clientId;
var key = $"Web:Auth:UserToken:{userId}:{effectiveClientId}";
var expiry = TimeSpan.FromMinutes(_config.AccessTokenExpiration);
RedisHelper.Set(key, token, expiry);
return true;
}
Token 验证流程
验证时除了校验 JWT 签名和有效期,还需比对 Redis 中存储的 Token:
public static bool ValidateAccessToken(string token, out ClaimsPrincipal? principal) {
principal = null;
// 1. 标准 JWT 验证
principal = tokenHandler.ValidateToken(token, validationParameters, out _);
// 2. Redis 验证(单点登录控制)
var userId = principal.FindFirst("userId")?.Value;
var clientId = principal.FindFirst("clientId")?.Value;
if (long.TryParse(userId, out long uid)) {
var storedToken = GetUserToken(uid, clientId);
if (string.IsNullOrEmpty(storedToken) || storedToken != token) {
return false; // Token 不匹配或已被覆盖
}
}
return true;
}
登出功能
按用户登出(踢出所有设备)
public static bool RemoveUserToken(long userId) {
var server = RedisHelper.GetServer();
var pattern = RedisHelper.GetPrefixedKey($"Web:Auth:UserToken:{userId}:*");
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0) {
RedisHelper.GetDatabase().KeyDelete(keys);
}
return true;
}
按机器码登出(桌面端关闭时)
public static int RemoveTokensByClientId(string clientId) {
var pattern = RedisHelper.GetPrefixedKey($"Web:Auth:UserToken:*:{clientId}");
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0) {
RedisHelper.GetDatabase().KeyDelete(keys);
}
return keys.Length;
}
登录流程
public async Task<object> LoginAsync(LoginRequest request, string machineId) {
// 1. 验证用户名密码
var user = await DbHelper.GetDb().Queryable<User>()
.Where(u => u.Username == request.Username)
.FirstAsync();
if (!BCrypt.Net.BCrypt.Verify(request.Password, user.Password)) {
throw new UnauthorizedAccessException("用户名或密码错误");
}
// 2. 生成 Token(携带机器码)
var accessToken = JwtHelper.GenerateAccessToken(
user.Id, user.Username, user.RoleId, machineId);
// 3. 踢掉同一客户端的旧连接
await _notificationService.KickUserAsync(user.Id.ToString());
// 4. 保存新 Token
JwtHelper.SaveUserToken(user.Id, accessToken, machineId);
return new { accessToken, user };
}
机器码生成
客户端启动时生成唯一机器码:
public static string MachineId { get; } = GenerateMachineId();
private static string GenerateMachineId() {
var combined = $"{Environment.MachineName}_{Environment.UserName}_Client";
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(combined));
return BitConverter.ToString(hashBytes, 0, 8).Replace("-", "").ToLowerInvariant();
}
应用场景
- 工控系统:同一操作员可在多台工控机登录,每台独立管理
- 后台管理:管理员可同时在办公电脑和移动端使用
- 多终端应用:支持 Web、桌面、移动端独立会话
总结
通过在 JWT 中嵌入 clientId 并使用 userId:clientId 作为 Redis Key,我们实现了灵活的多设备登录控制。这种方案既支持同用户多设备登录,又能在需要时精确踢出特定设备的会话。