自定义声明在 AspNetCore Identity cookie 中一段时间​​后丢失

Ash*_*h K 2 c# asp.net-identity asp.net-core blazor-server-side .net-6.0

我看到一个几年前就已经被问过的问题,它解释了与使用 AspNet Identity 时丢失自定义声明相关的问题。不幸的是,那里提到的解决方案对我不起作用,因为我在 .NET 6 Blazor Server 应用程序上使用 AspNet Core Identity。

问题是类似的(在下面几点解释):

  1. 我在登录期间添加了一些声明(这些声明来自某些 API 调用,而不是来自 Identity 数据库,因此我在登录期间添加它们)。

  2. 我可以从 Blazor 组件访问它们。

  3. 它在 30% 的情况下工作正常,但在 70% 的情况下,cookie 会丢失我在登录期间添加的自定义声明,并且我的应用程序会遇到问题。我什至无法弄清楚这些声明何时会丢失,因为在这两种情况下都没有发生这种情况,RevalidationInterval因为我用 1 分钟的时间跨度对其进行了测试,并且当我多次测试它时,它至少在 5 分钟内运行良好。搜索了一堆答案,但没有找到 AspNet Core Identity 的正确答案。

这就是我的代码的样子:

  1. Program.cs 中的身份设置
    builder.Services
    .AddDefaultIdentity<IdentityUser>(options =>
    {
        options.SignIn.RequireConfirmedAccount = false;
        // Set Password options here if you'd like:
        options.Password.RequiredLength = 6;
    })
    .AddRoles<IdentityRole>()
    .AddUserManager<ADUserManager<IdentityUser>>()
    .AddEntityFrameworkStores<ApplicationDbContext>();

    builder.Services.AddScoped<AuthenticationStateProvider, RevalidatingIdentityAuthenticationStateProvider<ApplicationUser>>();

Run Code Online (Sandbox Code Playgroud)
  1. 在 Login.cshtml.cs 中登录期间添加声明
    public async Task<IActionResult> OnPostAsync(string returnUrl = null)
    {
        returnUrl ??= Url.Content("~/");
        if (!ModelState.IsValid) return Page();
    
        try
        {
            var adLoginResult = ADHelper.ADLogin(Input.Username, Input.Password);
            
            // Use adLoginResult data to populate custom claims here
            // Set additional info about the user using empTimeId and other custom claims
            var customClaims = new[]
            {
                new Claim("EmployeeTimeId", adLoginResult.TimeId)
            };
    
            // SignIn the user now
            await _signInManager.SignInWithClaimsAsync(user, Input.RememberMe, customClaims);
            return LocalRedirect(returnUrl);
        }
        catch (Exception ex)
        {
            ModelState.AddModelError(string.Empty, $"Login Failed. Error: {ex.Message}.");
            return Page();
        }
    }
Run Code Online (Sandbox Code Playgroud)
  1. RevalidatingIdentityAuthenticationStateProvider.cs 中的重新验证方法
    public class RevalidatingIdentityAuthenticationStateProvider<TUser>
        : RevalidatingServerAuthenticationStateProvider where TUser : class
    {
        private readonly IServiceScopeFactory _scopeFactory;
        private readonly IdentityOptions _options;
    
        public RevalidatingIdentityAuthenticationStateProvider(
            ILoggerFactory loggerFactory,
            IServiceScopeFactory scopeFactory,
            IOptions<IdentityOptions> optionsAccessor)
            : base(loggerFactory)
        {
            _scopeFactory = scopeFactory;
            _options = optionsAccessor.Value;
        }
    
        protected override TimeSpan RevalidationInterval => TimeSpan.FromMinutes(1); // More frequent for ease of testing
    
        protected override async Task<bool> ValidateAuthenticationStateAsync(AuthenticationState authenticationState, CancellationToken cancellationToken)
        {
            //Get the user manager from a new scope to ensure it fetches fresh data
            var scope = _scopeFactory.CreateScope();
    
            try
            {
                var userManager = scope.ServiceProvider.GetRequiredService<UserManager<TUser>>();
                return await ValidateSecurityTimeStampAsync(userManager, authenticationState.User);
            }
            finally
            {
                if(scope is IAsyncDisposable asyncDisposable)
                {
                    await asyncDisposable.DisposeAsync();
                }
                else
                {
                    scope.Dispose();
                }
            }
        }
    
        private async Task<bool> ValidateSecurityTimeStampAsync(UserManager<TUser> userManager, ClaimsPrincipal principal)
        {
            var user = await userManager.GetUserAsync(principal);
            if(user == null)
            {
                return false;
            }
            else if (!userManager.SupportsUserSecurityStamp)
            {
                return true;
            }
            else
            {
                var principalStamp = principal.FindFirstValue(_options.ClaimsIdentity.SecurityStampClaimType);
                var userStamp = await userManager.GetSecurityStampAsync(user);
                return principalStamp == userStamp;
            }
        }
    }
Run Code Online (Sandbox Code Playgroud)
  1. 检索授权信息
    public class UserInfoService
    {
        private readonly AuthenticationStateProvider _authenticationStateProvider;
        private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
    
        public UserInfoService(AuthenticationStateProvider authenticationStateProvider, IDbContextFactory<ApplicationDbContext> dbContextFactory)
        {
            _authenticationStateProvider = authenticationStateProvider;
            _dbContextFactory = dbContextFactory;
        }
    
        public async Task<UserInfoFromAuthState?> GetCurrentUserInfoFromAuthStateAsync()
        {
            var userInfo = new UserInfoFromAuthState();
    
            var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
            if (authState == null ||
                authState.User == null ||
                authState.User.Identity == null ||
                !authState.User.Identity.IsAuthenticated)
            {
                return null;
            }
    
            userInfo.UserName = authState.User.Identity.Name!;
            
            // This comes out to be null after sometime a user has logged in
            userInfo.EmployeeTimeId = int.TryParse(authState.User.FindFirstValue("EmployeeTimeId", out var timeId) ? timeId : null;
    
            return userInfo;
        }
    }
Run Code Online (Sandbox Code Playgroud)

这就是当我的自定义声明为 null 时我面临的问题:"EmployeeTimeId"

Ash*_*h K 7

最后通过在aspnetcore github 存储库中提出问题解决了这个问题。 https://github.com/dotnet/aspnetcore/issues/49610

非常感谢@halter73 的帮助!


事实证明,只要 SecurityStamp 重新验证(默认间隔每 30 分钟发生一次),主体就会被替换。因此,我通过在校长刷新时重新添加声明来解决此问题。我使用选项模式来完成此任务。

步骤1:

在 Identity 文件夹下添加一个新类ConfigureSecurityStampOptions.cs(或您想要的任何地方):

第2步:

内容ConfigureSecurityStampOptions.cs应该是:

public class ConfigureSecurityStampOptions : IConfigureOptions<SecurityStampValidatorOptions>
{
    public void Configure(SecurityStampValidatorOptions options)
    {
        options.ValidationInterval = TimeSpan.FromMinutes(10); // Default interval is 30 minutes

        // When refreshing the principal, ensure custom claims that
        // might have been set with an external identity continue
        // to flow through to this new one.
        options.OnRefreshingPrincipal = refreshingPrincipal =>
        {
            ClaimsIdentity? newIdentity = refreshingPrincipal.NewPrincipal?.Identities.First();
            ClaimsIdentity? currentIdentity = refreshingPrincipal.CurrentPrincipal?.Identities.First();

            if (currentIdentity is not null && newIdentity is not null)
            {
                // Since this is refreshing an existing principal, we want to merge all claims.
                // Only work with claims in current identity that are not already present in the new identity with the same Type and Value.
                var currentClaimsNotInNewIdentity = currentIdentity.Claims.Where(c => !newIdentity.HasClaim(c.Type, c.Value));

                foreach (Claim claim in currentClaimsNotInNewIdentity)
                {
                    newIdentity.AddClaim(claim);
                }
            }

            return Task.CompletedTask;
        };
    }
}
Run Code Online (Sandbox Code Playgroud)

步骤3:

将其注册在Program.cs

// To ensure custom claims are added to new identity when principal is refreshed.
builder.Services.ConfigureOptions<ConfigureSecurityStampOptions>();
Run Code Online (Sandbox Code Playgroud)

完整源代码

https://github.com/akhanalcs/blazor-server-auth/tree/feature/AddClaimsDuringLogin


奖金

如果您在工作中有严格的代码覆盖率政策并且想要对此进行单元测试,那么我就是这样做的。我在这里使用xUnitShouldly

using Microsoft.AspNetCore.Identity;
using HMT.Web.Server.Areas.Identity;
using Shouldly;
using System.Security.Claims;

namespace HMT.UnitTests.Identity;

public class IdentityTests
{
    [Fact]
    public async Task Should_Add_CustomClaims_During_Principal_RefreshAsync()
    {
        // Arrange
        var currentIdentity = new ClaimsIdentity(new List<Claim>
        {
            new Claim("Type1", "Value1"), // Claim from Identity Db
            new Claim("Type2", "Value2") // Custom claim
        });

        var newIdentity = new ClaimsIdentity(new List<Claim>
        {
            new Claim("Type1", "Value1") // Claim from Identity Db
        });

        var options = new SecurityStampValidatorOptions();
        var configureOptions = new ConfigureSecurityStampOptions();

        var refreshingPrincipalCtx = new SecurityStampRefreshingPrincipalContext
        {
            CurrentPrincipal = new ClaimsPrincipal(currentIdentity),
            NewPrincipal = new ClaimsPrincipal(newIdentity) // To be refreshed principal
        };

        // Act
        configureOptions.Configure(options);

        // Assert
        refreshingPrincipalCtx.NewPrincipal.Claims.Count().ShouldBe(1);

        // Act
        await options.OnRefreshingPrincipal(refreshingPrincipalCtx);

        // Assert
        refreshingPrincipalCtx.NewPrincipal.Claims.Count().ShouldBe(2);
        refreshingPrincipalCtx.NewPrincipal.FindAll(c => c.Type == "Type2" && c.Value == "Value2").Count().ShouldBe(1);
    }
}
Run Code Online (Sandbox Code Playgroud)