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

解决SQL Server与.NET中Identity Framework的时间差问题

ASP.NET Identity 锁定时间与系统时间差30分钟问题解决

问题核心

使用_userManager.GetLockoutEndDateAsync(user)获取的用户锁定结束时间,与DateTime.Now始终存在30分钟差异,已确认SQL Server服务器时间准确。


1. 如何确保锁定结束时间与系统时间一致?

  • 统一使用UTC时间:所有时间操作优先用DateTime.UtcNow代替DateTime.Now,Identity的LockoutEnd字段默认存储UTC时间。对比时,将LockoutEnd的UTC值与DateTime.UtcNow对比,而非本地时间:
    var lockoutEnd = await _userManager.GetLockoutEndDateAsync(user);
    if (lockoutEnd.HasValue && lockoutEnd.Value.UtcDateTime > DateTime.UtcNow)
    {
        // 账户仍处于锁定状态
    }
    
  • 正确转换时区:如果需要向用户展示本地时间,将UTC时间转换为对应时区,而非直接用本地时间与UTC存储值对比:
    var localLockoutEnd = lockoutEnd.Value.LocalDateTime; // 转换为应用服务器本地时区
    
  • 修正异步调用:你的代码中直接调用GetLockoutEndDateAsync(user)后用Result,存在线程阻塞风险,应改为异步等待:
    var lockOutEndDate = await _userManager.GetLockoutEndDateAsync(user);
    

2. Identity Framework或SQL Server是否有导致差异的配置?

  • Identity Lockout配置:检查Identity的锁定时间配置,默认DefaultLockoutTimeSpan为5分钟,但如果自定义了该值,可能导致锁定时长不符合预期。在Program.cs/Startup.cs中查看:
    services.AddDefaultIdentity<IdentityUser>(options => options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(30))
        .AddEntityFrameworkStores<ApplicationDbContext>();
    
  • SQL Server时区与字段类型:SQL Server的datetimeoffset类型会存储时区信息,Identity默认用该类型存储LockoutEnd。如果应用服务器时区与UTC相差30分钟(如部分东南亚时区),直接用DateTime.Now(本地时间)对比LockoutEnd的UTC值会产生差异。
  • 用户模型定义:确认你的用户模型中LockoutEnd字段是DateTimeOffset?类型,而非DateTime?,避免时区信息丢失。

3. 有效排查解决步骤

  1. 直接验证数据库存储值:查询AspNetUsers表的LockoutEnd字段,查看实际存储的UTC时间,与应用服务器的DateTime.UtcNow对比,确认是否一致。
  2. 检查应用服务器时区:在代码中输出时区信息,确认本地时间与UTC的差值:
    _logger.LogInformation($"服务器时区:{TimeZoneInfo.Local.Id}");
    _logger.LogInformation($"UTC时间:{DateTime.UtcNow}");
    _logger.LogInformation($"本地时间:{DateTime.Now}");
    _logger.LogInformation($"差值:{DateTime.Now - DateTime.UtcNow}");
    
  3. 核对Identity Lockout配置:确认Lockout.DefaultLockoutTimeSpan、MaxFailedAccessAttempts等参数是否符合预期,是否有自定义的锁定逻辑覆盖默认行为。
  4. 修正时间对比逻辑:将所有时间对比操作统一为UTC时间,避免时区转换错误。
  5. 测试锁定流程:故意输错密码触发锁定,记录锁定时的UTC时间,对比数据库中LockoutEnd的值是否为当前UTC时间加上DefaultLockoutTimeSpan。

附优化后的Login方法代码

public async Task<IActionResult> Login([FromBody] UserForAuthenticationDto userForAuthentication)
{
    try
    {
        _logger.LogInformation("Login Method execution started");

        var user = await _userManager.FindByNameAsync(userForAuthentication.Email);

        if (user == null)
        {
            _logger.LogError("User Email not found:" + userForAuthentication.Email);
            throw new Exception(ExceptionsConst.B0000049);
        }

        var token = "";
        if (await _userManager.IsLockedOutAsync(user))
        {
            var lockOutEndDate = await _userManager.GetLockoutEndDateAsync(user);
            if (!System.IO.Directory.Exists(EmailTemplatesFolderPath))
            {
                EmailTemplatesFolderPath = _emailTemplate.EmailTemplateFolderPath;
            }

            string FileName = Path.Combine(EmailTemplatesFolderPath, "Template_AccountLockedInfo.html");
            if (!System.IO.File.Exists(FileName))
            {
                FileName = Path.Combine(EmailTemplatesFolderPath, "Template_AccountLockedInfo.html");
            }

            var html = new HtmlDocument();
            html.Load(FileName);

            var titleNode = html.DocumentNode.SelectSingleNode("//title");
            string title = titleNode?.InnerText ?? "";

            string EmailContent = System.IO.File.ReadAllText(FileName);
            EmailContent = EmailContent.Replace("<%username%>", user.UserName);
            
            if (lockOutEndDate.HasValue)
            {
                EmailContent = EmailContent.Replace("<%lockoutenddate%>", lockOutEndDate.Value.LocalDateTime.ToString("dd/MM/yyyy hh:mm:ss tt K"));
            }
            else
            {
                EmailContent = EmailContent.Replace("<%lockoutenddate%>", "");
            }

            var message = new Message(user.Email, title, EmailContent, null);
            await _emailSender.SendEmailAsync(message);

            throw new Exception(ExceptionsConst.B0000051);
        }

        if (!await _userManager.CheckPasswordAsync(user, userForAuthentication.Password))
        {
            await _userManager.AccessFailedAsync(user);
            _logger.LogError("Invalid password for user:" + userForAuthentication.Email);
            throw new Exception(ExceptionsConst.B0000049);
        }

        if (!await _userManager.IsEmailConfirmedAsync(user))
        {
            _logger.LogError("Email is not confirmed");
            throw new Exception(ExceptionsConst.B0000050);
        }

        if (!user.IsAccountActive)
        {
            _logger.LogError("User account is inactive");
            throw new Exception(ExceptionsConst.B0000052);
        }

        if (await _userManager.GetTwoFactorEnabledAsync(user))
        {
            Request.Headers.TryGetValue("Trusteddevicetoken", out var TrustedDeviceToken);
            if (!string.IsNullOrEmpty(TrustedDeviceToken))
            {
                _logger.LogError("TrustedDeviceToken is : " + TrustedDeviceToken);
                string _userID = _accountsService.GettrustedDevices(TrustedDeviceToken);
                _logger.LogError("UserID is : " + _userID);
                if (_userID != user.Id)
                {
                    return await GenerateOTPFor2StepVerification(user);
                }
            }
            else
            {
                _logger.LogError("TrustedDeviceToken is Empty, Generating OTP ");
                return await GenerateOTPFor2StepVerification(user);
            }
        }

        bool changePasswordRequired = user.ChangePasswordOnNextLogin;
        user.LastLoginDate = DateTime.UtcNow;
        await _userManager.UpdateAsync(user);

        IList<string> roles = await _userManager.GetRolesAsync(user);
        token = await _jwtHandler.GenerateToken(user);
        await _userManager.ResetAccessFailedCountAsync(user);

        var cacheKey = $"LastActivity_{token}";
        var cacheEntryOptions = new MemoryCacheEntryOptions()
            .SetSlidingExpiration(TimeSpan.FromMinutes(Convert.ToDouble(_jwtHandler._jwtSettings["UserIdleTimeOutInMinutes"])))
            .SetAbsoluteExpiration(TimeSpan.FromMinutes(Convert.ToDouble(_jwtHandler._jwtSettings["expiryInMinutes"])));

        Global.memoryCache.Set(cacheKey, DateTime.UtcNow, cacheEntryOptions);

        AuditLogger auditLogger = new AuditLogger();
        string source = getIPAddress();
        auditLogger.LogAudit(UserOperation.AUDIT_USERLOGIN, $"{user.UserName} successfully logged into the system", userForAuthentication.Email, userForAuthentication.Email, ObjectType.USER, source, null, user.Id);
        
        return Ok(new AuthResponseDto { 
            FirstName = user.FirstName, 
            LastName = user.LastName, 
            Role = String.Join(",", roles), 
            IsAuthSuccessful = true, 
            Token = token, 
            ChangePassword = changePasswordRequired 
        });
    }
    catch (SqlException sqlex)
    {
        _logger.LogError("SQL Exception while login " + sqlex.ToString());
        string errorMessage = ExceptionsConst.B0000020.Replace("\n", "").Replace("\r", "");

        var resp = new HttpResponseMessage(HttpStatusCode.NoContent)
        {
            Content = new StringContent(errorMessage),
            ReasonPhrase = GetGeneralError(errorMessage)
        };
        return UnprocessableEntity(resp);
    }
    catch (Exception ex)
    {
        string errorMessage = ex.Message.Replace("\n", "").Replace("\r", "");
        _logger.LogInformation("Error occurred while login" + ex.ToString());
        var resp = new HttpResponseMessage(HttpStatusCode.NoContent)
        {
            Content = new StringContent(errorMessage),
            ReasonPhrase = GetGeneralError(errorMessage)
        };
        return UnprocessableEntity(resp);
    }
}

内容的提问来源于stack exchange,提问作者Puchakayala Manoj

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.24 01:39:50