.NET Core自定义身份服务多用户登录串号问题解决方案
多用户并发时身份串号是两个核心错误写法共同导致的:
- 服务生命周期注册错误:你将
IUserIdentity注册为*Singleton(单例)*生命周期,整个应用全局只会创建一个UserIdentity实例,所有用户的所有请求都会共用这同一个实例。 - 状态存储逻辑错误:
UserIdentity类中用来存储登录用户信息的private Users user;、private string userPosition;是实例级私有字段,单例模式下这两个字段属于全局共享的内存变量。并发场景下用户A刚把自己的身份信息写入字段,用户B的请求就会直接覆盖字段值,此时用户A的后续请求读到的就是用户B的信息,自然会出现跨用户身份混淆。 - 额外隐患:
SignInController中定义的private Users authorized_user;也是控制器实例字段,一旦控制器开启实例复用,同样会触发串号问题。
不需要新增额外数据表,也不需要依赖.NET Core Identity框架,只需要调整服务生命周期、将身份信息存储到请求隔离的上下文即可彻底解决问题。
第一步:修正服务注册生命周期
将IUserIdentity的注册生命周期从单例改为Scoped(作用域),该生命周期下每个HTTP请求会创建独立的UserIdentity实例,请求结束后实例自动回收,从实例层面隔离不同请求的状态:
// 替换原有的Singleton注册 // services.AddSingleton<IUserIdentity, UserIdentity>(); services.AddScoped<IUserIdentity, UserIdentity>(); // 注册HttpContext访问器,用于读取当前请求的Cookie和上下文 services.AddHttpContextAccessor();
注意:仅修改生命周期只能保证单次请求内的数据隔离,由于HTTP是无状态协议,跨请求的登录状态不会自动保留,还需要结合认证Cookie实现跨请求身份识别。
第二步:改造身份认证与读取逻辑
移除UserIdentity中全局共享的user、userPosition字段,认证通过后给客户端下发HttpOnly加密Cookie存储用户唯一标识(用户Id),后续每次请求先从当前请求携带的Cookie中解析用户Id,再加载对应用户信息,完全实现用户级隔离:
- 改造
UserIdentity的构造函数,注入IHttpContextAccessor用于访问当前请求上下文:private readonly IUnitOfWorkFactory _unitOfWorkFactory; private readonly IHttpContextAccessor _httpContextAccessor; // 替换原有构造函数 public UserIdentity(IUnitOfWorkFactory unitOfWorkFactory, IHttpContextAccessor httpContextAccessor) { _unitOfWorkFactory = unitOfWorkFactory ?? throw new ArgumentNullException(nameof(unitOfWorkFactory)); _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } - 改造
Authenticate方法,认证通过后写入认证Cookie,不再给共享字段赋值:// 服务器端固定签名密钥,不要泄露,可存在配置文件中 private const string SIGN_KEY = "your_own_secret_key_123456"; public void Authenticate(string Login, string PasswordHash) { using (var unitOfWork = _unitOfWorkFactory.MakeUnitOfWork()) { var loginUser = unitOfWork.Users.GetUser(Login, PasswordHash); if (loginUser != null) { var httpContext = _httpContextAccessor.HttpContext; // 生成签名防止用户篡改Cookie中的UserId var sign = Convert.ToBase64String( SHA256.HashData(Encoding.UTF8.GetBytes($"{loginUser.Id}{SIGN_KEY}")) ); var cookieOptions = new CookieOptions { HttpOnly = true, // 禁止前端JS读取,防范XSS攻击 Secure = true, // HTTPS部署时开启 Expires = DateTimeOffset.Now.AddHours(8), // 登录态有效期8小时 SameSite = SameSiteMode.Lax // 防范CSRF攻击 }; // 写入用户Id和签名到Cookie httpContext.Response.Cookies.Append("Uid", loginUser.Id.ToString(), cookieOptions); httpContext.Response.Cookies.Append("UidSign", sign, cookieOptions); } } } - 新增内部方法,从当前请求上下文加载当前用户信息,避免单次请求重复查库:
private Users GetCurrentUser() { var httpContext = _httpContextAccessor.HttpContext; // 先从当前请求的Items缓存取用户,单次请求内只查一次数据库 if (httpContext.Items.TryGetValue("CurrentUser", out var cached) && cached is Users cachedUser) { return cachedUser; } // 从Cookie读取用户Id和签名 if (!httpContext.Request.Cookies.TryGetValue("Uid", out var uidStr) || !int.TryParse(uidStr, out var userId) || !httpContext.Request.Cookies.TryGetValue("UidSign", out var sign)) { return null; } // 校验签名,防止Cookie被篡改 var expectedSign = Convert.ToBase64String( SHA256.HashData(Encoding.UTF8.GetBytes($"{userId}{SIGN_KEY}")) ); if (expectedSign != sign) { return null; } // 查询数据库获取用户信息 using (var unitOfWork = _unitOfWorkFactory.MakeUnitOfWork()) { var currentUser = unitOfWork.Users.GetById(userId); if (currentUser != null) { httpContext.Items["CurrentUser"] = currentUser; } return currentUser; } } - 改造所有原有获取用户、判断权限的方法,统一调用
GetCurrentUser()获取当前请求对应的用户,不再读取共享字段:public Users GetActiveUser() => GetCurrentUser(); public string GetActiveUserName() => GetCurrentUser()?.FullName ?? ""; public string GetActiveUserPositionName() { var user = GetCurrentUser(); if (user == null) return ""; using (var unitOfWork = _unitOfWorkFactory.MakeUnitOfWork()) { var position = unitOfWork.Positions.Get(user.PositionId); return position?.Name ?? ""; } } public Users DeactivateUser() { var httpContext = _httpContextAccessor.HttpContext; // 退出时清除认证Cookie httpContext.Response.Cookies.Delete("Uid"); httpContext.Response.Cookies.Delete("UidSign"); httpContext.Items.Remove("CurrentUser"); return null; } // 剩下的IsVisibleMainMenu、IsVisibleContractMenu、IsVisibleDistributionMenu方法 // 全部改为先调用GetCurrentUser()拿到当前用户,再做角色、岗位判断即可
安全提示:上述签名校验逻辑可以有效防止用户篡改Cookie伪造身份,全程不需要新增数据表,所有逻辑基于现有用户表实现。
第三步:清理控制器冗余字段
删除SignInController中定义的private Users authorized_user;实例字段,所有需要获取当前用户的位置直接注入IUserIdentity调用对应方法获取即可,避免控制器实例复用时出现状态共享。
改造完成后,每个请求的身份数据完全基于当前请求携带的Cookie解析,和其他用户的请求完全隔离,即使高并发场景下也不会出现身份串号问题。
内容的提问来源于stack exchange,提问作者Aziz Nortozhiev

